diff --git a/AGENTS.md b/AGENTS.md index da599de..6e40dda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ See `docs/REPOSITORY-OPERATIONS.md#delivery-resilience-for-ai-assisted-work` for ## Long-running Classroom Program -The Standalone / Student / Instructor program is tracked by #288. Any work touching experience roles, classes, classroom workspaces, instructor observation, coaching, or protected Case Studies must read `docs/classroom-architecture.md`, `docs/classroom-roadmap.md`, and `docs/classroom-workstream.md` before editing. Class/API authorization work must additionally read `docs/classroom-api.md` and `api/AGENTS.md`. +The Standalone / Student / Instructor program is tracked by #288. Any work touching experience roles, classes, classroom workspaces, instructor observation, coaching, or protected Case Studies must read `docs/classroom-architecture.md`, `docs/classroom-roadmap.md`, and `docs/classroom-workstream.md` before editing. Class/API authorization work must additionally read `docs/classroom-api.md` and `api/AGENTS.md`. Staged exercise/debrief work under #313 must also read `docs/classroom-staged-simulation.md`. Classroom invariants: @@ -48,6 +48,7 @@ Classroom invariants: - student credentials never provide class/workspace enumeration; - Case Study unlock passwords are pedagogy, not authentication; - protected Case Study metadata/payloads are server-gated, absent from public browser/static assets, and authored `templates/*.json` remains excluded from Vercel deployment; `src/templates.manifest.js` is Standard-only and browser code must never import `api/protected-case-studies.manifest.js`. +- staged simulation content follows `docs/classroom-staged-simulation.md`: future-stage and Instructor-only material are server-withheld, complete protected Case Study `state` cannot bypass an active staged release, and exercise/checkpoint state never enters Intake serialization; - public Vercel static output comes only from generated `dist/`; never set `outputDirectory` back to `.` or otherwise expose repository docs/tests/scripts/AGENTS/authoring files. `scripts/build-vercel-public.mjs` owns the allowlisted browser bundle and is part of the canonical quality gate. Every classroom PR must update `docs/classroom-workstream.md` before handoff with its issue, PR/base dependency, current HEAD, completed/incomplete work, validation, risks, and exact next action. For stacked PRs, state the merge order and never describe an unmerged dependent PR as independently mergeable. diff --git a/README.md b/README.md index f23f1eb..11a204c 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ The database stores only SHA-256 capability hashes. `CLASS_EXPIRY_DAYS` optional See `docs/classroom-api.md` for the endpoint/capability matrix and `SECURITY.md` for the security boundary. Instructor observation is implemented through the separate GET-only `/api/classes/observe` path, authorized by the Instructor class capability after class/workspace ownership is proven. It never resolves through the editable workspace-alias path. +#313 staged simulation is implemented end-to-end on the active classroom branch. Protected Case Studies may carry an explicit server-only `simulation` definition; additive Neon tables persist class exercise lifecycle, optional releases, workspace readiness, and immutable debrief checkpoints. Running exercises pin the staged-definition version plus a definition fingerprint and inherit the owning class expiry. Instructor APIs/UI support staged selection, Start/Pause/Resume, controlled evidence release, debrief, explicit edit freeze/unfreeze, checkpoint inspection, Advance, and Complete. Student staged reads use the stable class-session capability, expose only current/cumulative Student-safe material, support Ready/Resume Working, and project frozen debrief Intake read-only while the server enforces writes with HTTP 423. These tables and clients do **not** duplicate live Intake state: collaboration workspaces remain the only live snapshot/revision source of truth, checkpoint snapshots are historical read-only evidence, and Classroom exercise/session state stays outside Intake persistence/export/summary. Production official Case Study stage content remains intentionally unauthored until authoritative material is supplied. + ### Conflict recovery and two-window testing Updates use optimistic revision control: each complete snapshot retains the revision observed when it was captured. Text edits wait about 300 ms so typing is responsive, blur flushes pending text, and completed select, checkbox, radio, and button-driven changes save immediately where the owning control is identifiable. Only one PUT is sent at a time; edits made during that request collapse to the latest snapshot and follow the successful response at its returned revision. A stale write receives HTTP 409 and cannot overwrite the newer row. diff --git a/SECURITY.md b/SECURITY.md index b5f9384..a60075b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,10 @@ Protected Case Study delivery is a separate server boundary. The public browser Protected Case Study catalog responses contain metadata only; payloads are selected in authenticated POST bodies, not query strings. All protected Case Study responses use `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The rotating mode password remains a pedagogical progression control and is **not** an authentication boundary. +For #313 staged simulation, Student progressive disclosure is server-side authorization, not UI hiding. A Student class-session may receive only the cumulative Student-safe release through the current exercise stage. Future-stage material, Instructor facilitation/model/exemplar material, and the complete protected Case Study source `state` must not be returned through the staged Student endpoint. If a class has a staged exercise record for a Case Study, Student full-payload retrieval for that case is blocked even after exercise completion unless a future explicit reveal contract is added. + +Staged Student edit freeze is also server-enforced. When the represented class's current exercise has `student_editing_enabled=false`, an active `classroom-student` alias may still GET the collaboration workspace and maintain presence, but `PUT /api/workspaces/session` returns HTTP **423** with code `classroom-editing-locked`. The write must not mutate snapshot or revision. Production enforcement repeats the lock predicate atomically inside the workspace UPDATE to close check-then-write races. Standalone/primary collaboration tokens and classes with no current staged exercise are not subject to this Classroom lock. + Authored JSON under `templates/*.json` is build-time source and is excluded from Vercel uploads with `.vercelignore`. Production must use committed generated manifests and run `npm run verify:protected-cases`; do not change the Vercel build back to a command that needs excluded authoring JSON. The verifier must remain in the canonical quality gate and must prove protected IDs/names are absent from public browser runtime assets. Public static delivery is also an explicit allowlist boundary. `scripts/build-vercel-public.mjs` emits only browser runtime files plus the intentionally public `docs/eula.md` to `dist/`, and Vercel must keep `outputDirectory: "dist"`. Never return static output to the repository root: production previously exposed internal Markdown and build scripts when `outputDirectory` was `.`. Other `docs/`, `tests/`, `scripts/`, AGENTS files, authoring material, and repository metadata must not be public assets. diff --git a/api/AGENTS.md b/api/AGENTS.md index 0cfbe45..d51d7ec 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -133,3 +133,32 @@ Rules: - assignment changes use optimistic `assignment_revision` checks; a conflicting update may leave old access revoked, but must never leave old access valid or remap it to a new workspace. - stale presence cleanup after reassignment uses internal workspace identity, not the revoked bearer token. - individual live workspaces remain single-participant; cross-class destination assignment is rejected generically. + +## #313 staged simulation direction + +Read `docs/classroom-staged-simulation.md` before modifying exercise/stage/protected-resource behavior. + +Rules: +- exercise lifecycle is class-scoped Instructor authority and remains separate from Intake snapshots, collaboration revision, and coaching revision; +- Student staged exercise reads authenticate with the stable Student class-session capability and return only the represented learner's class-level release/readiness context; +- a Student class-session token still does **not** authorize collaboration edits; +- future-stage material and Instructor-only facilitation/model/exemplar content must be omitted server-side from Student responses; +- never ship a complete staged Case Study to the browser and rely on UI hiding for progressive disclosure; +- existing protected Case Study `state` is complete source/exemplar material; when that case is the active staged exercise, the Student full-payload route must not bypass staged release; +- optional content release, debrief, and stage advance are Instructor-only and optimistic-revision protected; +- Student/team Ready state resolves the participant's current assignment server-side; stale pre-reassignment authority cannot mutate old-team exercise state; +- beginning debrief must capture idempotent immutable workspace checkpoints before any optional post-debrief edits; +- if Student editing is frozen, enforce it on the `classroom-student` collaboration snapshot write path; UI read-only projection alone is not authority; +- frozen Student snapshot PUT uses HTTP 423 with stable code `classroom-editing-locked`; the rejected request must not advance collaboration revision; +- the real workspace mutation must repeat the Classroom freeze predicate atomically with the UPDATE so a freeze racing a Student save fails closed; a preflight policy check alone is insufficient; +- Classroom freeze must not block workspace GET, presence, Instructor observation/coaching, Student staged reads/readiness, or Standalone/primary collaboration tokens; +- Pause/Resume preserve the current Student editing policy. Only stage debrief defaults and explicit Instructor debrief `set-editing` may change that policy; +- late joiners receive only the current cumulative Student release; reassignment changes workspace Intake/readiness, not class exercise stage; +- exercise persistence uses one non-completed exercise per class initially, optimistic `exercise_revision`, idempotent optional releases, workspace-scoped readiness, immutable first-write-wins debrief checkpoints, and class-bounded expiry; +- every running exercise pins the staged simulation version plus a definition fingerprint; later API reads/mutations must fail closed if the current protected definition does not match; +- checkpoint snapshots are facilitation evidence only and must never overwrite or become the live collaboration snapshot automatically; +- normal Instructor exercise payloads must expose checkpoint **metadata only**; snapshot bytes require the explicit Instructor-only current-stage debrief checkpoint read and must remain class/workspace/stage scoped; +- the checkpoint read is GET-only, must never mint workspace edit authority, and must reject Student/join/assignment/unrelated-class authority without leaking whether another team's checkpoint exists; +- `student_editing_enabled=false` is now a server-enforced security boundary for `classroom-student` snapshot PUT; preserve that invariant when changing exercise or collaboration code; +- do not create production stage definitions from guessed official case boundaries. Synthetic deterministic staged content is acceptable in tests until authoritative material is supplied. + diff --git a/api/_classroom.js b/api/_classroom.js index 05154fc..4ea2d86 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -324,6 +324,96 @@ async function initializeClassroomRepository() { await sql`CREATE INDEX IF NOT EXISTS classroom_coaching_feedback_workspace_idx ON classroom_coaching_feedback (workspace_id, updated_at)`; + await sql`CREATE TABLE IF NOT EXISTS classroom_exercises ( + id BIGSERIAL PRIMARY KEY, + public_id UUID UNIQUE NOT NULL, + class_id BIGINT NOT NULL REFERENCES classroom_classes(id) ON DELETE CASCADE, + case_study_id VARCHAR(160) NOT NULL, + simulation_version INTEGER NOT NULL CHECK (simulation_version > 0), + simulation_fingerprint CHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'active', 'paused', 'completed')), + current_stage_id VARCHAR(80), + stage_phase VARCHAR(16) NOT NULL DEFAULT 'work' + CHECK (stage_phase IN ('work', 'debrief')), + exercise_revision INTEGER NOT NULL DEFAULT 1 CHECK (exercise_revision > 0), + student_editing_enabled BOOLEAN NOT NULL DEFAULT TRUE, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + UNIQUE (class_id, id) + )`; + await sql`ALTER TABLE classroom_exercises + ADD COLUMN IF NOT EXISTS simulation_version INTEGER`; + await sql`ALTER TABLE classroom_exercises + ADD COLUMN IF NOT EXISTS simulation_fingerprint CHAR(64)`; + await sql`UPDATE classroom_exercises + SET simulation_version = COALESCE(simulation_version, 1), + simulation_fingerprint = COALESCE(simulation_fingerprint, repeat('0', 64)) + WHERE simulation_version IS NULL OR simulation_fingerprint IS NULL`; + await sql`ALTER TABLE classroom_exercises + ALTER COLUMN simulation_version SET NOT NULL`; + await sql`ALTER TABLE classroom_exercises + ALTER COLUMN simulation_fingerprint SET NOT NULL`; + + await sql`CREATE UNIQUE INDEX IF NOT EXISTS classroom_exercises_one_open_per_class_idx + ON classroom_exercises (class_id) + WHERE status <> 'completed'`; + await sql`CREATE INDEX IF NOT EXISTS classroom_exercises_class_history_idx + ON classroom_exercises (class_id, created_at DESC)`; + await sql`CREATE INDEX IF NOT EXISTS classroom_exercises_expiry_idx + ON classroom_exercises (expires_at)`; + + await sql`CREATE TABLE IF NOT EXISTS classroom_exercise_releases ( + exercise_id BIGINT NOT NULL REFERENCES classroom_exercises(id) ON DELETE CASCADE, + stage_id VARCHAR(80) NOT NULL, + content_id VARCHAR(80) NOT NULL, + released_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (exercise_id, stage_id, content_id) + )`; + + await sql`CREATE TABLE IF NOT EXISTS classroom_exercise_workspace_state ( + class_id BIGINT NOT NULL, + exercise_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + stage_id VARCHAR(80) NOT NULL, + ready_for_debrief BOOLEAN NOT NULL DEFAULT FALSE, + ready_at TIMESTAMPTZ, + ready_workspace_revision INTEGER CHECK (ready_workspace_revision IS NULL OR ready_workspace_revision > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (exercise_id, workspace_id, stage_id), + FOREIGN KEY (class_id, exercise_id) + REFERENCES classroom_exercises(class_id, id) + ON DELETE CASCADE, + FOREIGN KEY (class_id, workspace_id) + REFERENCES classroom_workspaces(class_id, workspace_id) + ON DELETE CASCADE + )`; + await sql`CREATE INDEX IF NOT EXISTS classroom_exercise_workspace_state_class_idx + ON classroom_exercise_workspace_state (class_id, exercise_id, stage_id)`; + + await sql`CREATE TABLE IF NOT EXISTS classroom_exercise_checkpoints ( + class_id BIGINT NOT NULL, + exercise_id BIGINT NOT NULL, + stage_id VARCHAR(80) NOT NULL, + workspace_id BIGINT NOT NULL, + workspace_revision INTEGER NOT NULL CHECK (workspace_revision > 0), + snapshot JSONB NOT NULL, + captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (exercise_id, stage_id, workspace_id), + FOREIGN KEY (class_id, exercise_id) + REFERENCES classroom_exercises(class_id, id) + ON DELETE CASCADE, + FOREIGN KEY (class_id, workspace_id) + REFERENCES classroom_workspaces(class_id, workspace_id) + ON DELETE CASCADE + )`; + await sql`CREATE INDEX IF NOT EXISTS classroom_exercise_checkpoints_class_idx + ON classroom_exercise_checkpoints (class_id, exercise_id, stage_id)`; + async function classByInstructor(tokenHash) { const rows = await sql`SELECT id AS internal_id, public_id AS id, title, student_join_code AS "joinCode", @@ -689,6 +779,742 @@ async function initializeClassroomRepository() { return classByInstructor(instructorHash); }, + async createExercise(instructorHash, { + publicId, + caseStudyId, + simulationVersion, + simulationFingerprint + }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`INSERT INTO classroom_exercises + ( + public_id, class_id, case_study_id, + simulation_version, simulation_fingerprint, expires_at + ) + VALUES ( + ${publicId}::uuid, + ${classroom.internal_id}, + ${caseStudyId}, + ${simulationVersion}, + ${simulationFingerprint}, + ${classroom.expiresAt}::timestamptz + ) + ON CONFLICT DO NOTHING + RETURNING + public_id AS id, + case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt"`; + + if (rows[0]) { + return { status: 'created', classroom, exercise: rows[0] }; + } + + const existing = await sql`SELECT + public_id AS id, + case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${classroom.internal_id} + AND status <> 'completed' + AND expires_at > NOW() + ORDER BY created_at DESC, id DESC + LIMIT 1`; + + return existing[0] + ? { status: 'exists', classroom, exercise: existing[0] } + : null; + }, + + async getCurrentExerciseForInstructor(instructorHash) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + const rows = await sql`SELECT + public_id AS id, + case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${classroom.internal_id} + AND status <> 'completed' + AND expires_at > NOW() + ORDER BY created_at DESC, id DESC + LIMIT 1`; + return rows[0] ? { classroom, exercise: rows[0] } : null; + }, + + async getExerciseForInstructor(instructorHash, exercisePublicId) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + const rows = await sql`SELECT + public_id AS id, + case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${classroom.internal_id} + AND public_id = ${exercisePublicId}::uuid + AND expires_at > NOW()`; + return rows[0] ? { classroom, exercise: rows[0] } : null; + }, + + async getCurrentExerciseByClassInternalId(classInternalId) { + const rows = await sql`SELECT + e.public_id AS id, + e.case_study_id AS "caseStudyId", + e.simulation_version AS "simulationVersion", + e.simulation_fingerprint AS "simulationFingerprint", + e.status, + e.current_stage_id AS "currentStageId", + e.stage_phase AS "stagePhase", + e.exercise_revision AS "exerciseRevision", + e.student_editing_enabled AS "studentEditingEnabled", + e.started_at AS "startedAt", + e.completed_at AS "completedAt", + e.created_at AS "createdAt", + e.updated_at AS "updatedAt", + e.expires_at AS "expiresAt" + FROM classroom_exercises e + JOIN classroom_classes c ON c.id = e.class_id + WHERE e.class_id = ${classInternalId} + AND e.status <> 'completed' + AND e.expires_at > NOW() + AND c.revoked_at IS NULL + AND c.expires_at > NOW() + ORDER BY e.created_at DESC, e.id DESC + LIMIT 1`; + return rows[0] || null; + }, + + async hasExerciseForClassCase(classInternalId, caseStudyId) { + const rows = await sql`SELECT 1 AS found + FROM classroom_exercises e + JOIN classroom_classes c ON c.id = e.class_id + WHERE e.class_id = ${classInternalId} + AND e.case_study_id = ${caseStudyId} + AND e.expires_at > NOW() + AND c.revoked_at IS NULL + AND c.expires_at > NOW() + LIMIT 1`; + return Boolean(rows[0]); + }, + + async getExerciseForStudentSession(sessionHash) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + + const rows = await sql`SELECT + id AS "internalId", + public_id AS id, + case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${context.internal.classId} + AND status <> 'completed' + AND expires_at > NOW() + ORDER BY created_at DESC, id DESC + LIMIT 1`; + const exercise = rows[0] || null; + if (!exercise) { + return { ...context, exercise: null, releases: [], readiness: null }; + } + + const releases = await sql`SELECT + stage_id AS "stageId", + content_id AS "contentId", + released_at AS "releasedAt" + FROM classroom_exercise_releases + WHERE exercise_id = ${exercise.internalId} + ORDER BY released_at, stage_id, content_id`; + + let readiness = null; + if (context.internal.workspaceId && exercise.currentStageId) { + const states = await sql`SELECT + stage_id AS "stageId", + ready_for_debrief AS "readyForDebrief", + ready_at AS "readyAt", + ready_workspace_revision AS "readyWorkspaceRevision", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM classroom_exercise_workspace_state + WHERE exercise_id = ${exercise.internalId} + AND workspace_id = ${context.internal.workspaceId} + AND stage_id = ${exercise.currentStageId}`; + readiness = states[0] || null; + } + + const { internalId: _internalId, ...publicExercise } = exercise; + return { + ...context, + exercise: publicExercise, + releases, + readiness + }; + }, + + async beginExerciseDebrief(instructorHash, { + exercisePublicId, + expectedRevision, + stageId, + studentEditingEnabled + }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`WITH target AS ( + UPDATE classroom_exercises e + SET stage_phase = 'debrief', + student_editing_enabled = ${studentEditingEnabled}, + exercise_revision = exercise_revision + 1, + updated_at = NOW() + WHERE e.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.exercise_revision = ${expectedRevision} + AND e.status = 'active' + AND e.current_stage_id = ${stageId} + AND e.stage_phase = 'work' + AND e.expires_at > NOW() + RETURNING + e.id AS internal_id, + e.class_id, + e.public_id AS id, + e.case_study_id AS "caseStudyId", + e.simulation_version AS "simulationVersion", + e.simulation_fingerprint AS "simulationFingerprint", + e.status, + e.current_stage_id AS "currentStageId", + e.stage_phase AS "stagePhase", + e.exercise_revision AS "exerciseRevision", + e.student_editing_enabled AS "studentEditingEnabled", + e.started_at AS "startedAt", + e.completed_at AS "completedAt", + e.created_at AS "createdAt", + e.updated_at AS "updatedAt", + e.expires_at AS "expiresAt" + ), + captured AS ( + INSERT INTO classroom_exercise_checkpoints + ( + class_id, exercise_id, stage_id, workspace_id, + workspace_revision, snapshot + ) + SELECT + t.class_id, + t.internal_id, + t."currentStageId", + cw.workspace_id, + w.revision, + w.snapshot_json + FROM target t + JOIN classroom_workspaces cw + ON cw.class_id = t.class_id + AND cw.revoked_at IS NULL + JOIN collaboration_workspaces w + ON w.id = cw.workspace_id + AND w.expires_at > NOW() + ON CONFLICT DO NOTHING + RETURNING workspace_id + ) + SELECT + t.*, + (SELECT COUNT(*)::int FROM captured) AS "capturedCount" + FROM target t`; + + if (rows[0]) { + const row = rows[0]; + const { + internal_id: _internalId, + class_id: _classId, + capturedCount, + ...exercise + } = row; + return { status: 'updated', classroom, exercise, capturedCount }; + } + + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + return current + ? { status: 'conflict', classroom: current.classroom, exercise: current.exercise } + : null; + }, + + async updateExerciseLifecycle(instructorHash, { + exercisePublicId, + expectedRevision, + status, + currentStageId, + stagePhase, + studentEditingEnabled + }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`UPDATE classroom_exercises + SET status = ${status}, + current_stage_id = ${currentStageId}, + stage_phase = ${stagePhase}, + student_editing_enabled = ${studentEditingEnabled}, + exercise_revision = exercise_revision + 1, + started_at = CASE + WHEN ${status} = 'active' AND started_at IS NULL THEN NOW() + ELSE started_at + END, + completed_at = CASE + WHEN ${status} = 'completed' THEN COALESCE(completed_at, NOW()) + ELSE completed_at + END, + updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND public_id = ${exercisePublicId}::uuid + AND exercise_revision = ${expectedRevision} + AND expires_at > NOW() + RETURNING + public_id AS id, + case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt"`; + if (rows[0]) return { status: 'updated', classroom, exercise: rows[0] }; + + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + return current + ? { status: 'conflict', classroom: current.classroom, exercise: current.exercise } + : null; + }, + + async releaseExerciseContent(instructorHash, { + exercisePublicId, + expectedRevision, + stageId, + contentId + }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`WITH inserted AS ( + INSERT INTO classroom_exercise_releases + (exercise_id, stage_id, content_id) + SELECT e.id, ${stageId}, ${contentId} + FROM classroom_exercises e + WHERE e.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.exercise_revision = ${expectedRevision} + AND e.expires_at > NOW() + ON CONFLICT DO NOTHING + RETURNING exercise_id, stage_id, content_id, released_at + ), + bumped AS ( + UPDATE classroom_exercises e + SET exercise_revision = exercise_revision + 1, + updated_at = NOW() + FROM inserted i + WHERE e.id = i.exercise_id + AND e.exercise_revision = ${expectedRevision} + RETURNING + e.id AS internal_id, + e.public_id AS id, + e.case_study_id AS "caseStudyId", + e.simulation_version AS "simulationVersion", + e.simulation_fingerprint AS "simulationFingerprint", + e.status, + e.current_stage_id AS "currentStageId", + e.stage_phase AS "stagePhase", + e.exercise_revision AS "exerciseRevision", + e.student_editing_enabled AS "studentEditingEnabled", + e.started_at AS "startedAt", + e.completed_at AS "completedAt", + e.created_at AS "createdAt", + e.updated_at AS "updatedAt", + e.expires_at AS "expiresAt" + ) + SELECT + b.*, + i.stage_id AS "releaseStageId", + i.content_id AS "releaseContentId", + i.released_at AS "releasedAt" + FROM bumped b + JOIN inserted i ON i.exercise_id = b.internal_id`; + + if (rows[0]) { + const row = rows[0]; + const { internal_id: _internalId, releaseStageId, releaseContentId, releasedAt, ...exercise } = row; + return { + status: 'updated', + classroom, + exercise, + release: { stageId: releaseStageId, contentId: releaseContentId, releasedAt } + }; + } + + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + + const releases = await sql`SELECT + r.stage_id AS "stageId", + r.content_id AS "contentId", + r.released_at AS "releasedAt" + FROM classroom_exercise_releases r + JOIN classroom_exercises e ON e.id = r.exercise_id + WHERE e.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND r.stage_id = ${stageId} + AND r.content_id = ${contentId} + AND e.expires_at > NOW()`; + + return releases[0] + ? { + status: 'unchanged', + classroom, + exercise: current.exercise, + release: releases[0] + } + : { + status: 'conflict', + classroom, + exercise: current.exercise + }; + }, + + async listExerciseReleasesForInstructor(instructorHash, exercisePublicId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const releases = await sql`SELECT + r.stage_id AS "stageId", + r.content_id AS "contentId", + r.released_at AS "releasedAt" + FROM classroom_exercise_releases r + JOIN classroom_exercises e ON e.id = r.exercise_id + WHERE e.class_id = ${current.classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.expires_at > NOW() + ORDER BY r.released_at, r.stage_id, r.content_id`; + return { ...current, releases }; + }, + + async setExerciseWorkspaceReadinessBySession(sessionHash, { + exercisePublicId, + stageId, + ready, + workspaceRevision, + expectedWorkspaceInternalId = null + }) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + if (!context.internal.workspaceId || !context.assignment) { + return { status: 'waiting', ...context }; + } + if ( + expectedWorkspaceInternalId !== null + && Number(context.internal.workspaceId) !== Number(expectedWorkspaceInternalId) + ) { + return { status: 'conflict', ...context }; + } + + const exercises = await sql`SELECT + id AS "internalId", + public_id AS id, + case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${context.internal.classId} + AND public_id = ${exercisePublicId}::uuid + AND current_stage_id = ${stageId} + AND status <> 'completed' + AND expires_at > NOW()`; + const exercise = exercises[0]; + if (!exercise) return null; + + const rows = await sql`INSERT INTO classroom_exercise_workspace_state + ( + class_id, exercise_id, workspace_id, stage_id, + ready_for_debrief, ready_at, ready_workspace_revision + ) + VALUES ( + ${context.internal.classId}, + ${exercise.internalId}, + ${context.internal.workspaceId}, + ${stageId}, + ${ready}, + CASE WHEN ${ready} THEN NOW() ELSE NULL END, + CASE WHEN ${ready} THEN ${workspaceRevision} ELSE NULL END + ) + ON CONFLICT (exercise_id, workspace_id, stage_id) DO UPDATE + SET ready_for_debrief = EXCLUDED.ready_for_debrief, + ready_at = CASE WHEN EXCLUDED.ready_for_debrief THEN NOW() ELSE NULL END, + ready_workspace_revision = EXCLUDED.ready_workspace_revision, + updated_at = NOW() + WHERE classroom_exercise_workspace_state.ready_for_debrief IS DISTINCT FROM EXCLUDED.ready_for_debrief + OR classroom_exercise_workspace_state.ready_workspace_revision IS DISTINCT FROM EXCLUDED.ready_workspace_revision + RETURNING + stage_id AS "stageId", + ready_for_debrief AS "readyForDebrief", + ready_at AS "readyAt", + ready_workspace_revision AS "readyWorkspaceRevision", + created_at AS "createdAt", + updated_at AS "updatedAt"`; + + let readiness = rows[0] || null; + if (!readiness) { + const existing = await sql`SELECT + stage_id AS "stageId", + ready_for_debrief AS "readyForDebrief", + ready_at AS "readyAt", + ready_workspace_revision AS "readyWorkspaceRevision", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM classroom_exercise_workspace_state + WHERE exercise_id = ${exercise.internalId} + AND workspace_id = ${context.internal.workspaceId} + AND stage_id = ${stageId}`; + readiness = existing[0] || null; + } + + const { internalId: _internalId, ...publicExercise } = exercise; + return { + status: rows[0] ? 'updated' : 'unchanged', + classroom: context.classroom, + participant: context.participant, + workspace: context.assignment, + exercise: publicExercise, + readiness + }; + }, + + async listExerciseWorkspaceStateForInstructor(instructorHash, exercisePublicId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const states = await sql`SELECT + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel", + s.stage_id AS "stageId", + s.ready_for_debrief AS "readyForDebrief", + s.ready_at AS "readyAt", + s.ready_workspace_revision AS "readyWorkspaceRevision", + s.created_at AS "createdAt", + s.updated_at AS "updatedAt" + FROM classroom_exercise_workspace_state s + JOIN classroom_exercises e ON e.id = s.exercise_id + JOIN classroom_workspaces cw + ON cw.class_id = s.class_id + AND cw.workspace_id = s.workspace_id + WHERE s.class_id = ${current.classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.expires_at > NOW() + AND cw.revoked_at IS NULL + ORDER BY cw.created_at, cw.workspace_id, s.stage_id`; + return { ...current, workspaceState: states }; + }, + + async captureExerciseCheckpoint(instructorHash, { + exercisePublicId, + stageId, + workspacePublicId, + workspaceRevision, + snapshot + }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`INSERT INTO classroom_exercise_checkpoints + ( + class_id, exercise_id, stage_id, workspace_id, + workspace_revision, snapshot + ) + SELECT + ${classroom.internal_id}, + e.id, + ${stageId}, + cw.workspace_id, + ${workspaceRevision}, + ${JSON.stringify(snapshot)}::jsonb + FROM classroom_exercises e + JOIN classroom_workspaces cw + ON cw.class_id = e.class_id + WHERE e.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.expires_at > NOW() + AND cw.public_id = ${workspacePublicId}::uuid + AND cw.revoked_at IS NULL + ON CONFLICT DO NOTHING + RETURNING + stage_id AS "stageId", + workspace_revision AS "workspaceRevision", + snapshot, + captured_at AS "capturedAt"`; + + if (rows[0]) { + return { + status: 'captured', + classroom, + workspace: { id: workspacePublicId }, + checkpoint: rows[0] + }; + } + + const existing = await sql`SELECT + cp.stage_id AS "stageId", + cp.workspace_revision AS "workspaceRevision", + cp.snapshot, + cp.captured_at AS "capturedAt" + FROM classroom_exercise_checkpoints cp + JOIN classroom_exercises e ON e.id = cp.exercise_id + JOIN classroom_workspaces cw + ON cw.class_id = cp.class_id + AND cw.workspace_id = cp.workspace_id + WHERE cp.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND cp.stage_id = ${stageId} + AND cw.public_id = ${workspacePublicId}::uuid + AND e.expires_at > NOW()`; + + return existing[0] + ? { + status: 'unchanged', + classroom, + workspace: { id: workspacePublicId }, + checkpoint: existing[0] + } + : null; + }, + + async listExerciseCheckpointsForInstructor(instructorHash, exercisePublicId, stageId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const checkpoints = await sql`SELECT + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel", + cp.stage_id AS "stageId", + cp.workspace_revision AS "workspaceRevision", + cp.captured_at AS "capturedAt" + FROM classroom_exercise_checkpoints cp + JOIN classroom_exercises e ON e.id = cp.exercise_id + JOIN classroom_workspaces cw + ON cw.class_id = cp.class_id + AND cw.workspace_id = cp.workspace_id + WHERE cp.class_id = ${current.classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND cp.stage_id = ${stageId} + AND e.expires_at > NOW() + ORDER BY cw.created_at, cw.workspace_id`; + return { ...current, checkpoints }; + }, + + async getExerciseCheckpointForInstructor( + instructorHash, + exercisePublicId, + stageId, + workspacePublicId + ) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const rows = await sql`SELECT + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel", + cp.stage_id AS "stageId", + cp.workspace_revision AS "workspaceRevision", + cp.snapshot, + cp.captured_at AS "capturedAt" + FROM classroom_exercise_checkpoints cp + JOIN classroom_exercises e ON e.id = cp.exercise_id + JOIN classroom_workspaces cw + ON cw.class_id = cp.class_id + AND cw.workspace_id = cp.workspace_id + WHERE cp.class_id = ${current.classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND cp.stage_id = ${stageId} + AND cw.public_id = ${workspacePublicId}::uuid + AND cw.revoked_at IS NULL + AND e.expires_at > NOW() + LIMIT 1`; + return rows[0] + ? { + ...current, + workspace: { + id: rows[0].workspaceId, + kind: rows[0].workspaceKind, + label: rows[0].workspaceLabel + }, + checkpoint: rows[0] + } + : null; + }, + async rotateStudentJoin(instructorHash, nextHash) { const rows = await sql`UPDATE classroom_classes SET student_join_token_hash = ${nextHash}, updated_at = NOW() diff --git a/api/_classroomExercise.js b/api/_classroomExercise.js new file mode 100644 index 0000000..eaaa9eb --- /dev/null +++ b/api/_classroomExercise.js @@ -0,0 +1,688 @@ +/** + * @module api/classroomExercise + * @description + * Server-only #313 staged exercise orchestration. Instructor handlers receive + * the complete staged definition; Student handlers receive only the cumulative + * Student-safe release through the current stage. No exercise route grants + * collaboration edit authority. + */ + +import { createHash, randomUUID } from 'node:crypto'; + +import { getClassroomRepository } from './_classroom.js'; +import { PROTECTED_CASE_STUDY_MANIFEST } from './protected-case-studies.manifest.js'; +import { + getWorkspaceRepository, + hashWorkspaceToken, + parseAuthorizationToken +} from './_workspace.js'; + +function send(res, status, body) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + return res.status(status).json(body); +} + +function methodNotAllowed(res, allowed) { + res.setHeader('Allow', allowed); + return send(res, 405, { error: 'Method not allowed.' }); +} + +function requireBearer(req) { + if (req.headers?.authorization === undefined) { + return { ok: false, status: 401, error: 'Authorization required.' }; + } + const token = parseAuthorizationToken(req.headers.authorization); + return token + ? { ok: true, token } + : { ok: false, status: 400, error: 'Invalid authorization.' }; +} + +function positiveRevision(value) { + const revision = Number(value); + return Number.isInteger(revision) && revision > 0 ? revision : null; +} + +const CLASSROOM_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +function requestedCaseStudyId(body) { + const id = typeof body?.caseStudyId === 'string' ? body.caseStudyId.trim() : ''; + return id && id.length <= 160 ? id : null; +} + +function requestedWorkspaceId(req) { + const value = Array.isArray(req.query?.workspaceId) ? '' : req.query?.workspaceId; + return typeof value === 'string' && CLASSROOM_UUID_PATTERN.test(value.trim()) + ? value.trim() + : null; +} + +function canonicalJson(value) { + if (Array.isArray(value)) { + return '[' + value.map(canonicalJson).join(',') + ']'; + } + if (value && typeof value === 'object') { + return '{' + Object.keys(value) + .sort() + .map(key => JSON.stringify(key) + ':' + canonicalJson(value[key])) + .join(',') + '}'; + } + return JSON.stringify(value); +} + +/** + * Return a stable SHA-256 identity for one normalized staged definition. + * + * @param {object} simulation Normalized server-only simulation definition. + * @returns {string} Lowercase SHA-256 hex digest. + */ +export function fingerprintStagedSimulation(simulation) { + return createHash('sha256').update(canonicalJson(simulation)).digest('hex'); +} + +function stagedCaseStudy(manifest, caseStudyId) { + const entry = manifest.find(candidate => candidate.id === caseStudyId) || null; + return entry?.templateKind === 'case-study' && entry.simulation ? entry : null; +} + +function caseStudySummary(caseStudy) { + return { + id: caseStudy.id, + name: caseStudy.name, + description: caseStudy.description, + supportedModes: [...caseStudy.supportedModes] + }; +} + +function stagedCaseStudyCatalog(manifest) { + return manifest + .filter(entry => entry?.templateKind === 'case-study' && entry.simulation) + .map(caseStudySummary); +} + +function instructorDefinition(caseStudy) { + return { + ...caseStudySummary(caseStudy), + simulation: caseStudy.simulation + }; +} + +function definitionMatches(exercise, caseStudy) { + if (!exercise || !caseStudy?.simulation) return false; + return exercise.simulationVersion === caseStudy.simulation.version + && exercise.simulationFingerprint === fingerprintStagedSimulation(caseStudy.simulation); +} + +function currentStage(caseStudy, exercise) { + if (!exercise?.currentStageId) return { stage: null, index: -1 }; + const index = caseStudy.simulation.stages.findIndex(stage => stage.id === exercise.currentStageId); + return { + stage: index >= 0 ? caseStudy.simulation.stages[index] : null, + index + }; +} + +function releasedStudentContent(caseStudy, exercise, releases) { + const { index } = currentStage(caseStudy, exercise); + if (index < 0 || exercise.status === 'draft') return []; + + const blocks = new Map(caseStudy.simulation.studentContent.map(block => [block.id, block])); + const releasesByStage = new Map(); + for (const release of releases || []) { + const items = releasesByStage.get(release.stageId) || []; + items.push(release); + releasesByStage.set(release.stageId, items); + } + + const result = []; + const seen = new Set(); + const append = (stageId, contentId, releaseType, releasedAt = null) => { + if (seen.has(contentId)) return; + const content = blocks.get(contentId); + if (!content) return; + seen.add(contentId); + result.push({ + stageId, + releaseType, + releasedAt, + content + }); + }; + + caseStudy.simulation.stages.slice(0, index + 1).forEach(stage => { + stage.initialReleaseIds.forEach(contentId => append(stage.id, contentId, 'initial')); + const allowedOptional = new Set(stage.optionalReleaseIds); + for (const release of releasesByStage.get(stage.id) || []) { + if (allowedOptional.has(release.contentId)) { + append(stage.id, release.contentId, 'optional', release.releasedAt || null); + } + } + }); + + return result; +} + +function studentExercisePayload(context, caseStudy) { + if (!context.exercise) { + return { + class: context.classroom, + participant: context.participant, + assignment: context.assignment, + exercise: null + }; + } + + const { stage } = currentStage(caseStudy, context.exercise); + return { + class: context.classroom, + participant: context.participant, + assignment: context.assignment, + exercise: { + id: context.exercise.id, + caseStudyId: context.exercise.caseStudyId, + caseStudy: caseStudySummary(caseStudy), + status: context.exercise.status, + stagePhase: context.exercise.stagePhase, + exerciseRevision: context.exercise.exerciseRevision, + studentEditingEnabled: context.exercise.studentEditingEnabled !== false, + editFreezeEnforced: true, + currentStage: stage + ? { + id: stage.id, + title: stage.title, + studentObjective: stage.studentObjective + } + : null, + releasedContent: releasedStudentContent( + caseStudy, + context.exercise, + context.releases + ), + readiness: context.readiness + ? { + stageId: context.readiness.stageId, + readyForDebrief: context.readiness.readyForDebrief, + readyAt: context.readiness.readyAt, + readyWorkspaceRevision: context.readiness.readyWorkspaceRevision + } + : null + } + }; +} + +async function instructorExercisePayload(repository, instructorHash, classroom, exercise, caseStudy) { + const releases = await repository.listExerciseReleasesForInstructor(instructorHash, exercise.id); + const workspaceState = await repository.listExerciseWorkspaceStateForInstructor(instructorHash, exercise.id); + const checkpoints = exercise.currentStageId + ? await repository.listExerciseCheckpointsForInstructor( + instructorHash, + exercise.id, + exercise.currentStageId + ) + : null; + const checkpointMetadata = (checkpoints?.checkpoints || []).map(item => ({ + workspaceId: item.workspaceId, + workspaceKind: item.workspaceKind, + workspaceLabel: item.workspaceLabel, + stageId: item.stageId, + workspaceRevision: item.workspaceRevision, + capturedAt: item.capturedAt + })); + + return { + class: classroom, + exercise, + caseStudy: instructorDefinition(caseStudy), + releases: releases?.releases || [], + workspaceState: workspaceState?.workspaceState || [], + checkpoints: checkpointMetadata, + editFreezeEnforced: true + }; +} + +/** + * Instructor-only explicit read of one immutable current-stage debrief checkpoint. + * + * Normal exercise reads return checkpoint metadata only. Snapshot bytes are + * returned only when an authorized Instructor explicitly selects one current + * workspace checkpoint for inspection. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classExerciseCheckpointHandler({ + getRepository = getClassroomRepository, + manifest = PROTECTED_CASE_STUDY_MANIFEST +} = {}) { + return async (req, res) => { + if (req.method !== 'GET') return methodNotAllowed(res, 'GET'); + + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + + const workspaceId = requestedWorkspaceId(req); + if (!workspaceId) return send(res, 400, { error: 'Invalid workspace.' }); + + const instructorHash = hashWorkspaceToken(authorization.token); + try { + const repository = await getRepository(); + const current = await repository.getCurrentExerciseForInstructor(instructorHash); + if (!current?.exercise) return send(res, 404, { error: 'Exercise checkpoint not found.' }); + + const exercise = current.exercise; + const caseStudy = stagedCaseStudy(manifest, exercise.caseStudyId); + if (!caseStudy || !definitionMatches(exercise, caseStudy)) { + return send(res, 409, { error: 'Exercise definition changed. Refresh the class.' }); + } + if (exercise.stagePhase !== 'debrief' || !exercise.currentStageId) { + return send(res, 409, { error: 'Checkpoint inspection is available during debrief.' }); + } + + const result = await repository.getExerciseCheckpointForInstructor( + instructorHash, + exercise.id, + exercise.currentStageId, + workspaceId + ); + if (!result?.checkpoint || !result?.workspace) { + return send(res, 404, { error: 'Exercise checkpoint not found.' }); + } + + return send(res, 200, { + class: { + id: result.classroom.id, + title: result.classroom.title, + expiresAt: result.classroom.expiresAt + }, + exercise: { + id: exercise.id, + currentStageId: exercise.currentStageId, + stagePhase: exercise.stagePhase, + exerciseRevision: exercise.exerciseRevision + }, + workspace: { + id: result.workspace.id, + kind: result.workspace.kind, + label: result.workspace.label + }, + checkpoint: { + stageId: result.checkpoint.stageId, + workspaceRevision: result.checkpoint.workspaceRevision, + capturedAt: result.checkpoint.capturedAt, + snapshot: result.checkpoint.snapshot + } + }); + } catch { + return send(res, 500, { error: 'Unable to inspect exercise checkpoint.' }); + } + }; +} + +function conflict(res, result, fallback = 'Exercise changed. Refresh and retry.') { + return send(res, 409, { + error: fallback, + exercise: result?.exercise || null + }); +} + +/** + * Instructor staged exercise create/read/lifecycle handler. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classExerciseHandler({ + getRepository = getClassroomRepository, + getWorkspaceRepo = getWorkspaceRepository, + manifest = PROTECTED_CASE_STUDY_MANIFEST, + idFactory = randomUUID +} = {}) { + return async (req, res) => { + if (!['POST', 'GET', 'PATCH'].includes(req.method)) { + return methodNotAllowed(res, 'POST, GET, PATCH'); + } + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + const instructorHash = hashWorkspaceToken(authorization.token); + + try { + const repository = await getRepository(); + const classroom = await repository.getClassByInstructor(instructorHash); + if (!classroom) return send(res, 404, { error: 'Class not found.' }); + + if (req.method === 'POST') { + const caseStudyId = requestedCaseStudyId(req.body); + if (!caseStudyId) return send(res, 400, { error: 'Invalid Case Study selection.' }); + const caseStudy = stagedCaseStudy(manifest, caseStudyId); + if (!caseStudy) return send(res, 404, { error: 'Staged Case Study not found.' }); + + const existing = await repository.getCurrentExerciseForInstructor(instructorHash); + if (existing) { + const existingCase = stagedCaseStudy(manifest, existing.exercise.caseStudyId); + if (!definitionMatches(existing.exercise, existingCase)) { + return conflict(res, existing, 'Exercise definition is unavailable or has changed.'); + } + if (existing.exercise.caseStudyId !== caseStudyId) { + return conflict(res, existing, 'Another exercise is already open for this class.'); + } + const body = await instructorExercisePayload( + repository, + instructorHash, + existing.classroom, + existing.exercise, + existingCase + ); + return send(res, 200, { ...body, created: false }); + } + + const result = await repository.createExercise(instructorHash, { + publicId: idFactory(), + caseStudyId, + simulationVersion: caseStudy.simulation.version, + simulationFingerprint: fingerprintStagedSimulation(caseStudy.simulation) + }); + if (!result) return send(res, 404, { error: 'Class not found.' }); + + const resolvedCase = stagedCaseStudy(manifest, result.exercise.caseStudyId); + if (!definitionMatches(result.exercise, resolvedCase)) { + return conflict(res, result, 'Exercise definition is unavailable or has changed.'); + } + if (result.exercise.caseStudyId !== caseStudyId) { + return conflict(res, result, 'Another exercise is already open for this class.'); + } + + const body = await instructorExercisePayload( + repository, + instructorHash, + result.classroom, + result.exercise, + resolvedCase + ); + return send(res, result.status === 'created' ? 201 : 200, { + ...body, + created: result.status === 'created' + }); + } + + const current = await repository.getCurrentExerciseForInstructor(instructorHash); + if (!current) { + return req.method === 'GET' + ? send(res, 200, { + class: classroom, + exercise: null, + availableCaseStudies: stagedCaseStudyCatalog(manifest) + }) + : send(res, 404, { error: 'Exercise not found.' }); + } + const caseStudy = stagedCaseStudy(manifest, current.exercise.caseStudyId); + if (!definitionMatches(current.exercise, caseStudy)) { + return conflict(res, current, 'Exercise definition is unavailable or has changed.'); + } + + if (req.method === 'GET') { + return send(res, 200, { + ...(await instructorExercisePayload( + repository, + instructorHash, + current.classroom, + current.exercise, + caseStudy + )), + availableCaseStudies: stagedCaseStudyCatalog(manifest) + }); + } + + const expectedRevision = positiveRevision(req.body?.expectedRevision); + if (!expectedRevision) return send(res, 400, { error: 'Invalid exercise revision.' }); + const action = req.body?.action; + const { stage, index } = currentStage(caseStudy, current.exercise); + let result = null; + + if (action === 'start') { + if (current.exercise.status !== 'draft' || current.exercise.currentStageId !== null) { + return conflict(res, current, 'Exercise cannot be started from its current state.'); + } + const firstStage = caseStudy.simulation.stages[0]; + if (!firstStage) return conflict(res, current, 'Exercise has no staged definition.'); + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'active', + currentStageId: firstStage.id, + stagePhase: 'work', + studentEditingEnabled: true + }); + } else if (action === 'pause') { + if (current.exercise.status !== 'active') { + return conflict(res, current, 'Exercise is not active.'); + } + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'paused', + currentStageId: current.exercise.currentStageId, + stagePhase: current.exercise.stagePhase, + studentEditingEnabled: current.exercise.studentEditingEnabled !== false + }); + } else if (action === 'resume') { + if (current.exercise.status !== 'paused') { + return conflict(res, current, 'Exercise is not paused.'); + } + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'active', + currentStageId: current.exercise.currentStageId, + stagePhase: current.exercise.stagePhase, + studentEditingEnabled: current.exercise.studentEditingEnabled !== false + }); + } else if (action === 'release-content') { + const contentId = typeof req.body?.contentId === 'string' ? req.body.contentId.trim() : ''; + if ( + current.exercise.status !== 'active' + || current.exercise.stagePhase !== 'work' + || !stage + || !stage.optionalReleaseIds.includes(contentId) + ) { + return send(res, 400, { error: 'Invalid current-stage content release.' }); + } + result = await repository.releaseExerciseContent(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + stageId: stage.id, + contentId + }); + } else if (action === 'begin-debrief') { + if (current.exercise.status !== 'active' || current.exercise.stagePhase !== 'work' || !stage) { + return conflict(res, current, 'Exercise is not ready to enter debrief.'); + } + const workspaceRepository = await getWorkspaceRepo(); + result = await repository.beginExerciseDebrief(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + stageId: stage.id, + workspaceRepository, + studentEditingEnabled: stage.defaultDebriefEditPolicy !== 'frozen' + }); + } else if (action === 'set-editing') { + if ( + current.exercise.status !== 'active' + || current.exercise.stagePhase !== 'debrief' + || !stage + || typeof req.body?.enabled !== 'boolean' + ) { + return send(res, 400, { error: 'Invalid debrief editing policy.' }); + } + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: current.exercise.status, + currentStageId: current.exercise.currentStageId, + stagePhase: current.exercise.stagePhase, + studentEditingEnabled: req.body.enabled + }); + } else if (action === 'advance') { + if (current.exercise.status !== 'active' || current.exercise.stagePhase !== 'debrief' || !stage) { + return conflict(res, current, 'Exercise is not ready to advance.'); + } + const nextStage = caseStudy.simulation.stages[index + 1]; + if (!nextStage) return send(res, 409, { error: 'Exercise is already at its final stage.' }); + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'active', + currentStageId: nextStage.id, + stagePhase: 'work', + studentEditingEnabled: true + }); + } else if (action === 'complete') { + if ( + current.exercise.status !== 'active' + || current.exercise.stagePhase !== 'debrief' + || !stage + || index !== caseStudy.simulation.stages.length - 1 + ) { + return conflict(res, current, 'Exercise cannot be completed from its current state.'); + } + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'completed', + currentStageId: stage.id, + stagePhase: 'debrief', + studentEditingEnabled: true + }); + } else { + return send(res, 400, { error: 'Invalid exercise action.' }); + } + + if (!result) return send(res, 404, { error: 'Exercise not found.' }); + if (result.status === 'conflict') return conflict(res, result); + if (!definitionMatches(result.exercise, caseStudy)) { + return conflict(res, result, 'Exercise definition is unavailable or has changed.'); + } + + return send(res, 200, { + ...(await instructorExercisePayload( + repository, + instructorHash, + result.classroom, + result.exercise, + caseStudy + )), + changed: result.status === 'updated', + capturedCount: Number.isInteger(result.capturedCount) ? result.capturedCount : undefined + }); + } catch { + return send(res, 500, { error: 'Unable to access staged exercise.' }); + } + }; +} + +/** + * Student current staged-release handler. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classStudentExerciseHandler({ + getRepository = getClassroomRepository, + manifest = PROTECTED_CASE_STUDY_MANIFEST +} = {}) { + return async (req, res) => { + if (req.method !== 'GET') return methodNotAllowed(res, 'GET'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + + try { + const repository = await getRepository(); + const context = await repository.getExerciseForStudentSession( + hashWorkspaceToken(authorization.token) + ); + if (!context) return send(res, 404, { error: 'Student class session not found.' }); + if (!context.exercise) return send(res, 200, studentExercisePayload(context, null)); + + const caseStudy = stagedCaseStudy(manifest, context.exercise.caseStudyId); + if (!definitionMatches(context.exercise, caseStudy)) { + return send(res, 409, { error: 'Exercise definition is unavailable or has changed.' }); + } + return send(res, 200, studentExercisePayload(context, caseStudy)); + } catch { + return send(res, 500, { error: 'Unable to load staged exercise.' }); + } + }; +} + +/** + * Student current-workspace readiness handler. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classStudentExerciseReadyHandler({ + getRepository = getClassroomRepository, + getWorkspaceRepo = getWorkspaceRepository, + manifest = PROTECTED_CASE_STUDY_MANIFEST +} = {}) { + return async (req, res) => { + if (req.method !== 'PUT') return methodNotAllowed(res, 'PUT'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + if (typeof req.body?.ready !== 'boolean') { + return send(res, 400, { error: 'Invalid readiness state.' }); + } + + const sessionHash = hashWorkspaceToken(authorization.token); + try { + const repository = await getRepository(); + const context = await repository.getExerciseForStudentSession(sessionHash); + if (!context) return send(res, 404, { error: 'Student class session not found.' }); + if (!context.exercise || !context.exercise.currentStageId) { + return send(res, 409, { error: 'No active exercise stage.' }); + } + const caseStudy = stagedCaseStudy(manifest, context.exercise.caseStudyId); + if (!definitionMatches(context.exercise, caseStudy)) { + return send(res, 409, { error: 'Exercise definition is unavailable or has changed.' }); + } + if (context.exercise.status !== 'active' || context.exercise.stagePhase !== 'work') { + return send(res, 409, { error: 'Readiness is not available in the current exercise phase.' }); + } + if (!context.internal.workspaceId || !context.assignment) { + return send(res, 409, { status: 'waiting', error: 'Assign a workspace before marking Ready.' }); + } + + let workspaceRevision = null; + if (req.body.ready) { + const workspaceRepository = await getWorkspaceRepo(); + const observation = await workspaceRepository.observeById(context.internal.workspaceId); + if (!observation) return send(res, 404, { error: 'Assigned workspace not found.' }); + workspaceRevision = observation.revision; + } + + const result = await repository.setExerciseWorkspaceReadinessBySession(sessionHash, { + exercisePublicId: context.exercise.id, + stageId: context.exercise.currentStageId, + ready: req.body.ready, + workspaceRevision, + expectedWorkspaceInternalId: context.internal.workspaceId + }); + if (!result) return send(res, 404, { error: 'Exercise readiness not found.' }); + if (result.status === 'waiting') { + return send(res, 409, { status: 'waiting', error: 'Assign a workspace before marking Ready.' }); + } + if (result.status === 'conflict') { + return send(res, 409, { error: 'Student assignment changed. Refresh and retry.' }); + } + return send(res, 200, { + class: result.classroom, + participant: result.participant, + assignment: result.workspace, + readiness: result.readiness, + changed: result.status === 'updated' + }); + } catch { + return send(res, 500, { error: 'Unable to update exercise readiness.' }); + } + }; +} diff --git a/api/_classroomWritePolicy.js b/api/_classroomWritePolicy.js new file mode 100644 index 0000000..2b646ee --- /dev/null +++ b/api/_classroomWritePolicy.js @@ -0,0 +1,50 @@ +/** + * @module api/classroomWritePolicy + * @description + * Resolves server-enforced Classroom Student snapshot-write policy without + * making Standalone collaboration depend on Classroom authorization. + */ + +import { getClassroomRepository } from './_classroom.js'; + +/** + * Create a resolver for one active classroom-student capability hash. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {(accessHash:string)=>Promise} Write-policy resolver. + */ +export function createClassroomStudentWritePolicy({ + getRepository = getClassroomRepository +} = {}) { + return async accessHash => { + const repository = await getRepository(); + const context = await repository.getStudentContext(accessHash); + if (!context) return null; + + const exercise = await repository.getCurrentExerciseByClassInternalId( + context.internal.classId + ); + + if (!exercise) { + return { + allowed: true, + classroom: context.classroom, + workspace: context.workspace, + exercise: null + }; + } + + return { + allowed: exercise.studentEditingEnabled !== false, + classroom: context.classroom, + workspace: context.workspace, + exercise: { + id: exercise.id, + status: exercise.status, + stagePhase: exercise.stagePhase, + exerciseRevision: exercise.exerciseRevision, + studentEditingEnabled: exercise.studentEditingEnabled + } + }; + }; +} diff --git a/api/_protectedCaseStudies.js b/api/_protectedCaseStudies.js index 5093529..7b694f7 100644 --- a/api/_protectedCaseStudies.js +++ b/api/_protectedCaseStudies.js @@ -46,7 +46,8 @@ function createProtectedCaseStudyHandler({ getRepository, manifest, authorize, - unauthorizedMessage + unauthorizedMessage, + blockPayload = null }) { return async (req, res) => { if (!['GET', 'POST'].includes(req.method)) return methodNotAllowed(res); @@ -68,6 +69,12 @@ function createProtectedCaseStudyHandler({ const caseStudy = requestedCaseStudy(manifest, req.body); if (!caseStudy) return send(res, 404, { error: 'Case Study not found.' }); + if (blockPayload && await blockPayload(repository, context, caseStudy)) { + return send(res, 409, { + error: 'Case Study content is available only through the staged exercise.' + }); + } + return send(res, 200, { class: context.classroom, workspace: context.workspace || null, @@ -118,6 +125,10 @@ export function studentCaseStudiesHandler({ getRepository, manifest, authorize: (repository, tokenHash) => repository.getStudentContext(tokenHash), - unauthorizedMessage: 'Class resources not found.' + unauthorizedMessage: 'Class resources not found.', + blockPayload: async (repository, context, caseStudy) => { + if (!context.internal?.classId || !repository.hasExerciseForClassCase) return false; + return repository.hasExerciseForClassCase(context.internal.classId, caseStudy.id); + } }); } diff --git a/api/_workspace.js b/api/_workspace.js index 61e281a..37cc7bd 100644 --- a/api/_workspace.js +++ b/api/_workspace.js @@ -190,6 +190,106 @@ async function initializeRepository() { const current = await sql`SELECT revision FROM collaboration_workspaces WHERE id = ${workspaceId} AND expires_at > NOW()`; return current[0] ? { status: 'conflict', revision: current[0].revision } : { status: 'missing' }; }, + async updateClassroomStudent(tokenHash, snapshot, revision) { + const rows = await sql`WITH scope AS ( + SELECT cp.class_id, cap.workspace_id + FROM collaboration_workspace_capabilities cap + JOIN classroom_participants cp + ON cp.workspace_access_token_hash = cap.token_hash + AND cp.workspace_id = cap.workspace_id + AND cp.revoked_at IS NULL + WHERE cap.token_hash = ${tokenHash} + AND cap.capability_kind = 'classroom-student' + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) + UNION ALL + SELECT cm.class_id, cap.workspace_id + FROM collaboration_workspace_capabilities cap + JOIN classroom_memberships cm + ON cm.access_token_hash = cap.token_hash + AND cm.workspace_id = cap.workspace_id + WHERE cap.token_hash = ${tokenHash} + AND cap.capability_kind = 'classroom-student' + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) + LIMIT 1 + ) + UPDATE collaboration_workspaces w + SET snapshot_json = ${JSON.stringify(snapshot)}::jsonb, + revision = revision + 1, + updated_at = NOW() + WHERE w.id = (SELECT workspace_id FROM scope) + AND w.revision = ${revision} + AND w.expires_at > NOW() + AND NOT EXISTS ( + SELECT 1 + FROM classroom_exercises e + JOIN scope s ON s.class_id = e.class_id + WHERE e.status <> 'completed' + AND e.expires_at > NOW() + AND e.student_editing_enabled = FALSE + ) + RETURNING w.revision, w.expires_at`; + if (rows[0]) return { status: 'updated', workspace: rows[0] }; + + const current = await sql`WITH scope AS ( + SELECT cp.class_id, cap.workspace_id + FROM collaboration_workspace_capabilities cap + JOIN classroom_participants cp + ON cp.workspace_access_token_hash = cap.token_hash + AND cp.workspace_id = cap.workspace_id + AND cp.revoked_at IS NULL + WHERE cap.token_hash = ${tokenHash} + AND cap.capability_kind = 'classroom-student' + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) + UNION ALL + SELECT cm.class_id, cap.workspace_id + FROM collaboration_workspace_capabilities cap + JOIN classroom_memberships cm + ON cm.access_token_hash = cap.token_hash + AND cm.workspace_id = cap.workspace_id + WHERE cap.token_hash = ${tokenHash} + AND cap.capability_kind = 'classroom-student' + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) + LIMIT 1 + ) + SELECT + w.revision, + e.public_id AS "exerciseId", + e.status AS "exerciseStatus", + e.stage_phase AS "stagePhase", + e.exercise_revision AS "exerciseRevision", + e.student_editing_enabled AS "studentEditingEnabled" + FROM scope s + JOIN collaboration_workspaces w + ON w.id = s.workspace_id AND w.expires_at > NOW() + LEFT JOIN LATERAL ( + SELECT public_id, status, stage_phase, exercise_revision, student_editing_enabled + FROM classroom_exercises + WHERE class_id = s.class_id + AND status <> 'completed' + AND expires_at > NOW() + ORDER BY created_at DESC, id DESC + LIMIT 1 + ) e ON TRUE`; + if (!current[0]) return { status: 'missing' }; + if (current[0].studentEditingEnabled === false) { + return { + status: 'locked', + revision: current[0].revision, + exercise: { + id: current[0].exerciseId, + status: current[0].exerciseStatus, + stagePhase: current[0].stagePhase, + exerciseRevision: current[0].exerciseRevision, + studentEditingEnabled: false + } + }; + } + return { status: 'conflict', revision: current[0].revision }; + }, async upsertPresence(tokenHash, participantId, requestedName, activity = {}) { const workspaceId = await resolveWorkspaceId(tokenHash); if (!workspaceId) return null; @@ -334,6 +434,14 @@ async function initializeRepository() { WHERE token_hash = ${tokenHash} AND revoked_at IS NULL RETURNING token_hash`; return rows.length > 0; + }, + async getCapabilityKind(tokenHash) { + const rows = await sql`SELECT capability_kind AS kind + FROM collaboration_workspace_capabilities + WHERE token_hash = ${tokenHash} + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > NOW())`; + return rows[0]?.kind || null; } }; } @@ -413,7 +521,10 @@ export function presenceHandler({ getRepository = getWorkspaceRepository } = {}) } /** Creates a Vercel load/update handler. @param {object} [dependencies] Dependencies. @returns {Function} Handler. */ -export function workspaceHandler({ getRepository = getWorkspaceRepository } = {}) { +export function workspaceHandler({ + getRepository = getWorkspaceRepository, + getWritePolicy = null +} = {}) { return async (req, res) => { const authorization = req.headers?.authorization; if (authorization === undefined) return send(res, 401, { error: 'Authorization required.' }); @@ -439,7 +550,39 @@ export function workspaceHandler({ getRepository = getWorkspaceRepository } = {} const validation = validateSnapshot(req.body?.snapshot); if (!validation.ok) return send(res, validation.status, { error: validation.status === 413 ? 'Snapshot is too large.' : 'Invalid request.' }); if (!Number.isInteger(req.body?.revision) || req.body.revision < 1) return send(res, 400, { error: 'Invalid request.' }); - const result = await repository.update(hashWorkspaceToken(token), req.body.snapshot, req.body.revision); + + const tokenHash = hashWorkspaceToken(token); + let writePolicy = null; + if (getWritePolicy && repository.getCapabilityKind) { + const capabilityKind = await repository.getCapabilityKind(tokenHash); + if (capabilityKind === 'classroom-student') { + writePolicy = await getWritePolicy(tokenHash); + if (!writePolicy) return send(res, 404, { error: 'Workspace not found.' }); + if (writePolicy.allowed === false) { + return send(res, 423, { + error: 'Student editing is temporarily locked by the Instructor.', + code: 'classroom-editing-locked', + exercise: writePolicy.exercise || null + }); + } + } + } + + const result = writePolicy && repository.updateClassroomStudent + ? await repository.updateClassroomStudent( + tokenHash, + req.body.snapshot, + req.body.revision, + writePolicy + ) + : await repository.update(tokenHash, req.body.snapshot, req.body.revision); + if (result.status === 'locked') { + return send(res, 423, { + error: 'Student editing is temporarily locked by the Instructor.', + code: 'classroom-editing-locked', + exercise: result.exercise || writePolicy?.exercise || null + }); + } if (result.status === 'conflict') return send(res, 409, { error: 'Revision conflict.', revision: result.revision }); if (result.status === 'missing') return send(res, 404, { error: 'Workspace not found.' }); return send(res, 200, result.workspace); diff --git a/api/classes/exercise.js b/api/classes/exercise.js new file mode 100644 index 0000000..c8d0e1b --- /dev/null +++ b/api/classes/exercise.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/exercise + * @description Instructor-authorized staged Classroom exercise lifecycle. + */ +import { classExerciseHandler } from '../_classroomExercise.js'; + +export default classExerciseHandler(); diff --git a/api/classes/exercise/checkpoint.js b/api/classes/exercise/checkpoint.js new file mode 100644 index 0000000..88050a1 --- /dev/null +++ b/api/classes/exercise/checkpoint.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/exercise/checkpoint + * @description Instructor-authorized immutable current-stage checkpoint inspection. + */ +import { classExerciseCheckpointHandler } from '../../_classroomExercise.js'; + +export default classExerciseCheckpointHandler(); diff --git a/api/classes/exercise/student.js b/api/classes/exercise/student.js new file mode 100644 index 0000000..d62e75f --- /dev/null +++ b/api/classes/exercise/student.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/exercise/student + * @description Student class-session current staged release endpoint. + */ +import { classStudentExerciseHandler } from '../../_classroomExercise.js'; + +export default classStudentExerciseHandler(); diff --git a/api/classes/exercise/student/ready.js b/api/classes/exercise/student/ready.js new file mode 100644 index 0000000..4b772a1 --- /dev/null +++ b/api/classes/exercise/student/ready.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/exercise/student/ready + * @description Student class-session current-workspace Ready endpoint. + */ +import { classStudentExerciseReadyHandler } from '../../../_classroomExercise.js'; + +export default classStudentExerciseReadyHandler(); diff --git a/api/workspaces/session.js b/api/workspaces/session.js index de6f8e1..ac4f552 100644 --- a/api/workspaces/session.js +++ b/api/workspaces/session.js @@ -2,6 +2,9 @@ * @module api/workspaces/session * @description Stable Vercel endpoint that loads and conditionally updates the bearer-token workspace. */ +import { createClassroomStudentWritePolicy } from '../_classroomWritePolicy.js'; import { workspaceHandler } from '../_workspace.js'; -export default workspaceHandler(); +export default workspaceHandler({ + getWritePolicy: createClassroomStudentWritePolicy() +}); diff --git a/docs/classroom-api.md b/docs/classroom-api.md index 4678d53..ef1738d 100644 --- a/docs/classroom-api.md +++ b/docs/classroom-api.md @@ -229,6 +229,106 @@ The build produces two generated resources: - `api/protected-case-studies.manifest.js` — server-only Case Study metadata and payloads. Authored `templates/*.json` files are excluded from Vercel uploads. Production functions consume the committed server-only manifest, while GitHub CI continues to validate all authored JSON and generated-file freshness. +## Staged exercise orchestration (#313) + +#313 adds class-level exercise orchestration without changing collaboration authority. + +### `POST /api/classes/exercise` + +Requires the Instructor class capability. + +Body: +- `caseStudyId` for a protected Case Study carrying an explicit server-only `simulation` definition. + +Creates the represented class's draft exercise when no non-completed exercise exists. The exercise pins the normalized simulation version plus canonical SHA-256 definition fingerprint. Repeating the same selection returns the existing exercise; selecting a different case while another exercise is open conflicts. + +### `GET /api/classes/exercise` + +Requires the Instructor class capability. + +Returns only the represented class's current non-completed exercise plus: +- complete Instructor-authorized staged definition; +- optional-content releases; +- workspace readiness/progress state; +- **metadata only** for current-stage immutable debrief checkpoints: workspace identity, captured workspace revision, stage, and capture time. + +Normal exercise reads never include checkpoint snapshot bytes. Every read fails closed if the pinned simulation version/fingerprint no longer matches the current protected definition. + +### `GET /api/classes/exercise/checkpoint?workspaceId=` + +Requires the Instructor class capability. + +This is the explicit, read-only path for inspecting one immutable checkpoint during the represented exercise's current debrief stage. The server re-resolves the Instructor's current class/exercise, validates the pinned staged definition, scopes the requested workspace to that class/stage, and returns only that workspace's captured checkpoint snapshot plus its captured collaboration revision. + +Security/behavior rules: +- Student class-session, workspace-edit, assignment, human join-code, or unrelated Instructor authority does not grant checkpoint access; +- the endpoint is GET-only and never mints Student workspace authority; +- reading a checkpoint never writes, restores, merges, or replaces the live collaboration snapshot; +- checkpoint snapshot bytes are fetched only after an explicit Instructor inspection action and remain outside normal exercise state, Intake persistence/export/summary, and browser resume state; +- returning to current live Intake uses the existing Instructor observer endpoint rather than treating the checkpoint as collaboration state. + +### `PATCH /api/classes/exercise` + +Requires the Instructor class capability and positive `expectedRevision`. + +Supported actions: +- `start`; +- `pause`; +- `resume`; +- `release-content` with current-stage optional `contentId`; +- `begin-debrief`; +- `advance`; +- `complete`. + +Lifecycle changes use optimistic `exercise_revision`. Optional-content replay is idempotent; a stale request for a different release conflicts. `begin-debrief` captures immutable class/workspace-scoped snapshot/revision checkpoints before later live changes. Stage ordering comes from the pinned staged definition, never client-provided next-stage IDs. + +`begin-debrief` also applies the current stage's authored `defaultDebriefEditPolicy`. During debrief, Instructor action `set-editing` with boolean `enabled` provides an explicit revision-safe freeze/unfreeze override. Pause/Resume preserve the current editing policy; pause is not synonymous with freeze. + +### Server-enforced Student editing lock + +For an active `classroom-student` workspace alias, `PUT /api/workspaces/session` enforces the current exercise's `student_editing_enabled` before any collaboration snapshot revision mutation. + +When editing is frozen: +- the response is **423 Locked**; +- body includes stable `code: "classroom-editing-locked"` plus current exercise context; +- snapshot and collaboration revision remain unchanged; +- GET workspace reads remain available; +- presence, Instructor observation/coaching, Student exercise reads, and readiness are not blocked. + +The production mutation repeats the freeze predicate atomically inside the SQL UPDATE, rather than relying only on a preflight policy lookup. This closes the race where an Instructor freezes between a Student's permission check and snapshot write. + +Primary/Standalone collaboration tokens bypass Classroom policy entirely. Classroom classes with no current staged exercise remain writable. Both current live-participant and legacy Classroom membership `classroom-student` aliases are covered. + +Exercise responses now expose `editFreezeEnforced: true` and the authoritative `studentEditingEnabled` value. + +### `GET /api/classes/exercise/student` + +Requires the stable Student **class-session** capability, not a workspace edit capability or human join code. + +Returns: +- represented public class/participant/current assignment context; +- public exercise identity/status/phase/revision; +- current Student-visible stage title/objective; +- cumulative released Student content through the current stage; +- represented workspace readiness when assigned. + +It never returns future-stage metadata, Instructor content/IDs, complete Case Study `state`, another workspace's readiness, or workspace enumeration. Waiting Students can read the current class release safely. + +### `PUT /api/classes/exercise/student/ready` + +Requires the Student class-session capability. + +Body: +- `ready: boolean`. + +When setting Ready, the server resolves the participant's current assigned workspace and records the collaboration revision observed server-side. Waiting is rejected. A reassignment race conflicts rather than attaching readiness to the old workspace. + +### Protected full-payload interaction + +When a class has a staged exercise record for Case Study X, Student `POST /api/classes/case-studies/student` must not return X's complete protected payload. The Student must use the staged exercise endpoint instead. This block remains after exercise completion; completing a simulation is not an implicit exemplar/model-answer release. Instructor full protected access remains class-scoped and unchanged, and unrelated protected resources keep their existing behavior. + +All exercise responses are private/no-store/no-referrer. Exercise state, releases, readiness, and checkpoints remain outside Intake serialization and collaboration revision state. + ## Individual and group semantics ### Individual @@ -269,6 +369,15 @@ The legacy membership table remains supported for two-code compatibility. Live a Database foreign keys and handler authorization jointly enforce class/workspace isolation. Existing collaboration tables, Standalone secret links, and the legacy Classroom path remain valid. +#313 adds the following persistence primitives beneath the exercise HTTP/UI layers: + +- `classroom_exercises` — class-scoped exercise run with protected Case Study ID, pinned simulation version + definition fingerprint, status/stage/phase, optimistic `exercise_revision`, editing-policy state, timestamps, and owning-class expiry; +- `classroom_exercise_releases` — idempotent optional-content release records; +- `classroom_exercise_workspace_state` — workspace-scoped Ready state and workspace-revision evidence; +- `classroom_exercise_checkpoints` — immutable exercise/stage/workspace snapshot + revision evidence captured for debrief. + +At most one non-completed exercise exists per class in the initial model. Exercise mutation is optimistic-revision protected; replaying an already-recorded optional release does not advance revision. Ready state follows the workspace, not the participant, and checkpoint rows are first-write-wins. All repository access is class/workspace scoped and bounded by the class expiry. + No classroom credential is stored in browser Intake state. ## Rotation and revocation diff --git a/docs/classroom-architecture.md b/docs/classroom-architecture.md index 1d9a3f1..9cb582a 100644 --- a/docs/classroom-architecture.md +++ b/docs/classroom-architecture.md @@ -70,11 +70,13 @@ Class independent feedback revision created/updated metadata - -> Classroom Exercise / Stage State [future #313] - selected protected Case Study - exercise status / current stage - released Student content - Instructor-only facilitation/debrief material + -> Classroom Exercise / Stage State [#313] + selected protected Case Study + pinned staged-definition identity + exercise status / current stage / phase / optimistic revision + optional Student-content release state + per-workspace readiness + immutable per-workspace debrief checkpoints + Instructor-only facilitation/debrief material remains in protected definition ``` The **Classroom Workspace** references the existing collaboration workspace rather than duplicating its snapshot, revision, participant, or presence engine. #312 deliberately separates stable class participation from mutable workspace assignment so a Student can wait unassigned, move between teams, or be unassigned again without changing identity or merging team Intake state. @@ -260,7 +262,14 @@ The Classroom persistence model is additive: - `classroom_participants` — #312 live participant identity, optional current assignment, `assignment_revision`, Student class-session capability hash, and current workspace-access capability hash; - `classroom_coaching_feedback` — separate class/workspace/target coaching channel. -Protected Case Study delivery (#295) adds no public/browser data table and remains server-gated. #313 should add exercise/stage state separately from Intake snapshots and from participant authorization; it must not overload collaboration revision or coaching state. +Protected Case Study delivery (#295) adds no public/browser data table and remains server-gated. #313 Tranche 3 now adds exercise orchestration persistence separately from Intake snapshots and participant authorization: + +- `classroom_exercises` — one exercise run, owning class, protected Case Study identity, pinned simulation version/fingerprint, lifecycle/stage/phase, optimistic revision, editing-policy state, and class-bounded expiry; +- `classroom_exercise_releases` — explicitly released optional Student content by exercise/stage/content ID; +- `classroom_exercise_workspace_state` — workspace/team Ready state plus collaboration-revision evidence; +- `classroom_exercise_checkpoints` — immutable pre-debrief workspace snapshot/revision evidence. + +These tables do not replace or duplicate collaboration state. Checkpoints are historical facilitation evidence only; the live Intake remains in `collaboration_workspaces`. Normal Instructor exercise reads expose only checkpoint metadata. Snapshot bytes are available only through an explicit Instructor-authorized current-stage debrief inspection read, are rendered through the existing read-only observer surface, and never become live collaboration state or browser-persisted exercise state. Exercise state must not overload collaboration revision or coaching revision. Schema initialization/migration must remain idempotent and documented. Browser boot for Standalone must never depend on Classroom schema creation. diff --git a/docs/classroom-roadmap.md b/docs/classroom-roadmap.md index f92f38e..6fbea84 100644 --- a/docs/classroom-roadmap.md +++ b/docs/classroom-roadmap.md @@ -221,7 +221,59 @@ The eight-slice #288 program establishes the secure Classroom foundation; it doe - **#312 — Instructor-run live class, roster, and dynamic team management:** Instructor-created classes, one human-friendly Student join path, waiting/late roster, accessible team formation, live reassignment, automatic Student workspace switching, and high-signal team monitoring. - **#313 — Instructor-orchestrated case simulation and staged debrief:** staged protected Case Study release, case reference beside Intake, Instructor pause/debrief/advance controls, progressive disclosure, team progress, and server-gated future-stage / Instructor-only material. -#312 is implemented on draft PR #314 (`feature/classroom-live-management`): Start Class, one human Student join code, Waiting/late roster, stable Student class sessions, accessible team creation/assignment, safe reassignment/unassign with stale-authority cutoff, observer/coaching integration, and integrated multi-browser acceptance are green. The final #312 gate is documentation/security/merge readiness. #313 remains the next dependent product slice and must start from the merged #312 substrate. +#312 is complete and merged as PR #314 at `b141d55050207e4cf7ce842a4452a220a5268968`: Start Class, one human Student join code, Waiting/late roster, stable Student class sessions, accessible team creation/assignment, safe reassignment/unassign with stale-authority cutoff, observer/coaching integration, and integrated multi-browser acceptance are the live substrate. + +#313 is implementation-complete on `feature/classroom-staged-simulation`, based directly on the #312 merge. Its canonical contract is `docs/classroom-staged-simulation.md`. Tranches 1–10 are complete: server-only staged authoring schema, additive exercise persistence, Instructor/Student APIs, server-enforced Student freeze, Instructor orchestration console, Student case reference/readiness, immutable checkpoint inspection, integrated browser/security/mobile/accessibility acceptance, and final security/documentation audit. PR #315 is the publish/merge vehicle. + +## Next production-Classroom roadmap + +The secure classroom/orchestration engine is complete, but a **real production KT simulation is not yet runnable from source-faithful case material**. The remaining roadmap is now product/content enablement rather than another exercise-engine tranche. + +### P0 — production case material delivery + +**#316 — Protected staged case assets and rich Student case rendering** + +This is the immediate blocker. The staged schema already allows `image`, `table`, and `document-page` Student blocks, but the current Student case-reference projects released content to `id/kind/title/body` and therefore cannot faithfully render protected case pages/images/tables. Implement server-gated asset delivery and accessible rich rendering without weakening progressive disclosure or the public-static boundary. + +**#317 — Author and validate the first production staged KT Case Study** + +Depends on #316. Use only authoritative participant/instructor source material. Author reviewed stage boundaries/releases/facilitation/assets, then run a complete real-case Instructor + multi-team Student rehearsal. Do not infer stage structure from completed Intake state or similarly named cases. + +### P1 — facilitation completeness + +**#318 — Stage-linked Intake guidance and complete coachable target identity** + +The `simulation.stages[].intakeTargetIds` authoring contract exists but is not consumed by Student/Instructor runtime. Wire those targets to stable Intake identities so a stage can point learners toward the relevant work without revealing answers. In the same identity layer, close #294's intentionally deferred dynamic Possible Cause coaching gap. + +**#319 — Instructor class debrief comparison and progress view** + +Build on Ready/Working, coaching, immutable checkpoints, and live observation to provide a class-level comparison/heatmap-style debrief surface. Keep it read-only and Instructor-only; compare selected checkpoint/current targets without duplicating or grading Intake state. + +### P2 — delivery polish + +**#320 — Join sharing and compact mobile workspace chrome** + +Add QR/share-link convenience around the human join code without putting bearer capabilities in URLs, and finish the acknowledged mobile Notes/Class/Case chrome alignment so these persistent workspaces remain discoverable without consuming excessive Intake real estate. + +### Recommended dependency/order + +```text +#315 merge + | + #316 protected rich staged content + | + #317 first production staged KT Case Study + | + +----------> production classroom rehearsal / case-specific acceptance + +#318 stage-linked Intake targets + dynamic coaching identity + | + #319 scalable class debrief comparison + +#320 join/mobile polish (can proceed in parallel after #315) +``` + +The first next implementation action after #315 merges is **#316**, not authoring an official case prematurely. #317 may prepare source mapping in parallel, but must not ship production staged content until #316 provides the secure rich-content path. ## Stacked PR rules diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md new file mode 100644 index 0000000..e0ac546 --- /dev/null +++ b/docs/classroom-staged-simulation.md @@ -0,0 +1,1014 @@ +# Classroom Staged Simulation and Debrief Contract + +## Purpose + +This document is the canonical architecture/server contract for #313 — Instructor-orchestrated Case Study simulation and staged debrief. + +#313 builds directly on the merged #312 live-class substrate at `b141d55050207e4cf7ce842a4452a220a5268968`. + +The target experience is a facilitated class, not a self-paced LMS: + +```text +Instructor starts/resumes live class + -> selects a staged Case Study + -> starts Stage 1 + -> Students receive only released case material + -> teams reason and document in the same Intake + -> teams may mark Ready for debrief + -> Instructor observes/coaches + -> Instructor begins debrief + -> optional server-enforced edit freeze + -> immutable pre-debrief checkpoints preserve team work + -> Instructor facilitates discussion / teaching points + -> Instructor advances the whole class + -> newly released material appears automatically + -> same Intake continues across stages + -> final debrief / completion +``` + +The Instructor controls major transitions. Student/team work remains local to each authorized collaboration workspace. + +## Scope of #313 + +Own: +- class-level exercise lifecycle; +- current stage and phase; +- server-gated progressive release of protected Case Study material; +- Instructor-only facilitation/debrief material; +- Student stage-aware case reference UI; +- team readiness and debrief checkpoints; +- optional server-enforced Student edit freeze; +- Instructor exercise console and progress signals; +- late-join and reassignment behavior; +- staged browser/security/accessibility acceptance. + +Do not turn Intake into: +- a graded LMS; +- a general course-authoring system; +- an account/identity platform; +- a second collaboration engine; +- a place where Instructor-only solution material is serialized into Student Intake. + +## Relationship to #312 + +#312 remains authoritative for: +- class identity; +- Instructor capability; +- human Student join code; +- Student class-session capability; +- Waiting / assignment state; +- team/individual workspaces; +- assignment-specific editable workspace capabilities; +- reassignment/unassign revocation; +- collaboration snapshots/revisions/presence; +- read-only Instructor observation; +- field-level coaching. + +#313 layers exercise orchestration over those primitives. It must not reinterpret or duplicate them. + +## Non-negotiable security invariants + +1. Future-stage Student content is withheld **server-side**. It must not be sent to the browser and hidden with CSS, `hidden`, disabled controls, or client-side filtering. +2. Instructor facilitation notes, teaching points, model/exemplar reasoning, and answer material are never returned by Student endpoints unless a future explicit release action authorizes a specifically Student-safe item. +3. A human class join code is never exercise authorization. +4. A Student class-session capability may read only the represented Student's class-level exercise state/released Student material. It may not enumerate teams, classmates, Instructor notes, or future content. +5. Student workspace edit capability continues to authorize only the current collaboration workspace. It does not grant stage-control authority. +6. Student cannot start, pause, resume, debrief, advance, complete, or release exercise content. +7. Instructor may control only the class represented by the Instructor capability. +8. Exercise state is separate from `kt-intake-full-v2`, collaboration snapshot state, coaching state, exports, templates, and summaries. +9. Reassignment never copies/merges Intake snapshots and never carries team readiness/checkpoint state from the old workspace to the destination workspace. +10. Protected authored Case Study source and staged content remain absent from public browser/static assets. +11. Standalone remains backend-optional and unaffected. +12. Existing non-staged protected Case Study behavior remains compatible unless the represented class has an active staged exercise for that Case Study. + +## Critical protected-content decision + +The current #295 Case Study record contains a complete protected `state` suitable for applying a full Case Study/teaching artifact. For staged simulation this complete payload must be treated as **source/exemplar material**, not as the Student progressive-disclosure feed. + +A staged Case Study must therefore have an explicit server-only simulation definition. Do **not** infer stages automatically from the filled `state`, and do not invent stage boundaries for official cases without authoritative case material. + +Recommended authored shape for a `case-study` record: + +```json +{ + "id": "case-id", + "name": "Case name", + "description": "Short teaching description", + "templateKind": "case-study", + "supportedModes": ["intake", "is-is-not", "dc", "full"], + "state": { "...": "complete protected source/exemplar Intake" }, + "simulation": { + "version": 1, + "studentContent": [ + { + "id": "briefing-1", + "kind": "narrative", + "title": "Initial briefing", + "body": "Student-safe content" + } + ], + "instructorContent": [ + { + "id": "teach-1", + "kind": "facilitation", + "title": "Stage 1 teaching points", + "body": "Instructor-only content" + } + ], + "stages": [ + { + "id": "stage-1", + "title": "Clarify the situation", + "studentObjective": "What the learner is trying to accomplish, without giving the answer.", + "initialReleaseIds": ["briefing-1"], + "optionalReleaseIds": [], + "intakeTargetIds": ["problem.one-line"], + "suggestedMinutes": 15, + "instructorContentIds": ["teach-1"], + "defaultDebriefEditPolicy": "frozen" + } + ] + } +} +``` + +This is a contract shape, not permission to author official case staging from guesses. Production staged definitions should be source-faithful and explicitly reviewed. + +### Student content block types + +Start with a small allowlist rather than arbitrary HTML: +- `narrative` — titled plain/rich-safe text block; +- `image` — server-protected image/evidence reference with required alt text; +- `table` — structured headers/rows; +- `document-page` — future protected page/image/PDF-page reference; +- `prompt` — Student-safe question/instruction; +- `evidence` — discrete released fact/evidence item. + +Do not allow executable markup, script, inline event handlers, arbitrary remote embeds, or public asset URLs that bypass Classroom authorization. + +### Instructor content + +Instructor-only content may include: +- facilitation notes; +- likely reasoning / teaching points; +- debrief prompts; +- anticipated misconceptions; +- exemplar/model reasoning; +- recommended timing; +- reveal guidance. + +Instructor content IDs and payloads are omitted entirely from Student responses. + +## Implementation checkpoint — staged Case Study authoring schema + +Tranche 2 is implemented before exercise persistence/API/UI. + +Implemented: +- `scripts/staged-simulation-schema.mjs` defines simulation version 1 validation and canonical normalization; +- `scripts/build-templates-manifest.mjs` accepts optional `simulation` only on protected `case-study` resources and preserves it only in the server-only Case Study manifest; +- existing Case Studies without `simulation` remain valid and unchanged; +- Standard Templates are rejected if they attempt to carry `simulation`, protecting the public manifest boundary; +- Student content and Instructor content are separate namespaces and their IDs may not collide; +- stage IDs and content IDs must be stable lowercase slugs and unique; +- stage Student release references must resolve only to Student content; +- stage Instructor references must resolve only to Instructor content; +- one content item cannot be both initial and optional release in the same stage; +- orphan Student/Instructor content is rejected so protected material cannot silently ride along outside an explicit stage; +- version-1 definitions reject unknown fields instead of silently widening the contract; +- Student content kinds currently supported by the authoring contract are `narrative`, `prompt`, `evidence`, `image`, `table`, and `document-page`; +- protected image/document references use stable `assetId` values rather than arbitrary URLs; +- image/document blocks require accessible alternate text; table blocks require a rectangular string matrix; +- Instructor content kinds are `facilitation`, `debrief`, and `exemplar`; +- stage `defaultDebriefEditPolicy` is explicitly `open | frozen`. + +Durable implementation commits: +- `46109491808bedfbf5e778d0979859b3cd96b56c` — version-1 staged simulation validator/normalizer; +- `1f716470cfdf95a64c00a8a99a62735133feae61` — optional simulation integration into the protected manifest build; +- `9322cecabe9a487e072dd279519b5f358f089ebd` — focused synthetic schema/cross-reference/security tests; +- `1e22806b8a1e50e94150a13ecf3b7083f0747667` — public/protected manifest boundary regression; +- `af6b6f21a8e4f25f014a8362857a1c15e252775b` — server-agent guardrails restored alongside real tests. + +Validation on `af6b6f21...`: +- canonical repository quality: **260 tests / 259 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- Template Manifest Guard and Dependency Review: green; +- no production authored Case Study gained a staged definition, so existing generated protected/public resource behavior remains compatible. + +**Exact next tranche:** implement additive exercise persistence/repository primitives only: `classroom_exercises`, optional-content releases, per-workspace stage readiness, immutable debrief checkpoints, optimistic exercise revision, idempotent checkpoint/release behavior, class scoping, retention/expiry, and deterministic in-memory parity. Do not add HTTP exercise routes, edit-freeze enforcement, or Instructor/Student UI in that tranche. + +## Implementation checkpoint — exercise persistence / repository + +Tranche 3 is implemented below the HTTP/UI layer. + +Persistence: +- `classroom_exercises` stores one exercise run with public UUID, owning class, protected Case Study ID, pinned simulation version + SHA-256 definition fingerprint, lifecycle status/stage/phase, optimistic `exercise_revision`, editing-policy state, timestamps, and class-bounded expiry; +- a partial unique index permits at most one non-completed exercise per class; +- `classroom_exercise_releases` stores optional Student-content releases by exercise + stage + content ID; +- `classroom_exercise_workspace_state` stores team/workspace readiness and the collaboration revision observed when Ready was marked; +- `classroom_exercise_checkpoints` stores immutable per-workspace stage snapshots/revisions for debrief evidence; +- class/workspace composite foreign keys prevent cross-class workspace state/checkpoint rows; +- exercise rows inherit the owning class's absolute expiry; all repository access also requires the active, unexpired owning class. + +Repository semantics: +- draft creation is idempotent at the class level: an existing non-completed exercise is returned rather than creating a parallel run; +- exercise lifecycle mutation requires the expected exercise revision and returns an explicit conflict without mutating on stale input; +- lifecycle mutations increment the revision once and retain start/completion timestamps; +- a new optional release is atomic with one exercise-revision increment; +- replaying the same optional release is an unchanged success and does not increment revision, even if the caller repeats its earlier expected revision; +- a different release with a stale revision conflicts; +- Student readiness resolves from the stable Student class session to the **currently assigned** workspace; Waiting Students cannot create workspace readiness; +- readiness remains attached to the old team after reassignment, while subsequent readiness writes attach to the destination team; +- checkpoint capture validates Instructor class + exercise + workspace scope and is first-write-wins for exercise + stage + workspace; +- replaying checkpoint capture returns the original revision/snapshot rather than overwriting debrief evidence; +- checkpoint snapshots are cloned in deterministic parity so later mutation of the source object cannot mutate captured evidence; +- no stage/lifecycle operation copies or merges live Intake snapshots. + +Definition drift protection: +- each exercise pins the staged simulation schema version and a 64-character definition fingerprint supplied by the later API/service layer; +- a migration-safe fallback fingerprint of all zeroes is used only if an earlier schema-only table somehow existed before these columns; the API tranche must treat a fingerprint mismatch/sentinel as fail-closed rather than silently reinterpret a running exercise. + +Durable implementation commits: +- `ea964de7afc002ca307a903db7e1493d56837f13` — additive exercise/release/workspace-state/checkpoint schema; +- `3e519b8f4ab136404ade550376af0c80e06574f1` — real Neon repository primitives; +- `8595e1ec1072b365e82f759494ea0edb25001a4c` — deterministic in-memory parity; +- `f7c1e580ca8fdc5e818e1f6974af374850f6d3a6` — focused repository contract tests; +- `09ffaa6c1ce081166240fe484bbccea6b29901f5`, `6979abc429c6d67169e044758cf394eb052ef85b`, `257981611cccea907a37bc8a037caa68cbb9bc88`, and `c01dfff2f2bd1f9d7625901860210cc00eee7051` — pin simulation definition identity and make those columns migration-safe. + +Validation on the pre-definition-hardening implementation head `f7c1e58...`: +- repository quality: **265 tests / 264 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +The final documented head must rerun the same complete gate after definition-identity hardening. + +## Exercise domain model + +Persist orchestration state separately from static Case Study definitions. + +### classroom_exercises + +One row represents one run of one Case Study in one class. + +Implemented fields include: +- internal `id` plus public UUID `public_id`; +- `class_id`; +- `case_study_id`; +- `simulation_version`; +- `simulation_fingerprint` — SHA-256 of the normalized staged definition supplied by the exercise service; +- `status: draft | active | paused | completed`; +- `current_stage_id` (nullable before start); +- `stage_phase: work | debrief`; +- `exercise_revision` positive monotonic integer; +- `student_editing_enabled` boolean; +- `started_at`; +- `completed_at`; +- `created_at`; +- `updated_at`; +- `expires_at` copied from the owning class. + +A class may have historical completed exercises. At most one non-completed exercise should be active for a class unless a later product decision explicitly introduces parallel exercises. + +The database stores the selected `case_study_id` and dynamic lifecycle state, not a second copy of the static stage definition. + +### Released optional content + +Initial stage release is deterministic from the stage definition. + +Optional hints/supplemental evidence need durable state. Prefer an additive release table: + +`classroom_exercise_releases` +- `exercise_id`; +- `stage_id`; +- `content_id`; +- `released_at`. + +The server validates that a released ID belongs to the current stage's `optionalReleaseIds`. A Student response is the union of: +- all initial releases from completed/current stages that are intended to remain visible; +- explicitly released optional content; +- no future-stage content. + +### Workspace stage state + +Readiness is a **team/workspace** concept, not participant identity. + +`classroom_exercise_workspace_state` +- `exercise_id`; +- `workspace_id`; +- `stage_id`; +- `ready_for_debrief` boolean; +- `ready_at`; +- `ready_workspace_revision`; +- timestamps. + +A reassigned Student adopts the destination workspace's readiness state. Their old team's readiness stays with the old workspace. + +### Immutable debrief checkpoints + +A collaboration revision number alone is insufficient to reconstruct what a team believed before a debrief because collaboration currently retains only the live snapshot. + +When the Instructor begins debrief, capture an immutable stage checkpoint for each active workspace: + +`classroom_exercise_checkpoints` +- `exercise_id`; +- `stage_id`; +- `workspace_id`; +- `workspace_revision`; +- `snapshot` JSONB; +- `captured_at`. + +Checkpoint snapshots are evidence for facilitation/comparison only: +- never become the live Intake automatically; +- never overwrite team work; +- are class/workspace scoped; +- inherit class retention; +- are never sent to another team; +- are not included in normal Intake Save/Load/export. + +This gives future debrief tooling a trustworthy “what the team had at debrief start” view without requiring collaboration history as a prerequisite. + +## Lifecycle state machine + +### Draft + +Exercise exists and the Instructor can inspect its full staged definition. + +Student receives no staged exercise content. + +Allowed Instructor transitions: +- start first stage; +- replace/cancel draft selection. + +### Active + work + +Students receive the cumulative Student-safe release through the current stage. + +Student Intake editing is normally enabled. + +Instructor may: +- release an optional current-stage content item; +- pause; +- begin debrief; +- observe/coach teams. + +Teams may mark Ready for debrief while assigned. + +### Paused + +Current stage and phase remain unchanged; `status=paused` records the hold. + +Pause is not automatically an edit freeze. The Instructor may choose the exercise editing policy independently. + +Resume returns to the same phase. + +### Active + debrief + +Entering debrief: +1. validates expected exercise revision; +2. captures immutable workspace checkpoints exactly once for that stage; +3. applies the stage's default edit policy unless Instructor explicitly overrides it; +4. changes `stage_phase` to `debrief`; +5. increments exercise revision. + +Student continues to see released case material and their own live Intake. If editing is frozen, the UI becomes read-only and the server rejects Student snapshot mutation. + +Instructor may: +- facilitate using Instructor-only notes; +- observe checkpoint vs current work; +- coach; +- unfreeze/refreeze if policy permits; +- advance to the next stage; +- complete the exercise on the final stage. + +### Advance + +Advance is Instructor-only and optimistic-revision protected. + +The server: +- validates current stage/phase; +- resolves the next stage from the immutable simulation definition; +- sets next `current_stage_id`; +- sets phase to `work`; +- restores the next stage's default editing policy; +- clears/creates workspace readiness for the new stage without changing Intake; +- increments exercise revision. + +Student clients discover the new revision and fetch the new safe release. + +### Completed + +No future automatic release occurs. + +Completion does **not** automatically release Instructor exemplar/model material. If learner-facing exemplar reveal is later desired, it must be an explicit separately authorized release. + +## API direction + +Use separate exercise endpoints rather than overloading assignment/status routes. + +### Instructor + +`POST /api/classes/exercise` +- Instructor class capability; +- create/select a draft exercise from a Case Study that has a valid `simulation` definition; +- reject non-staged cases for this path. + +`GET /api/classes/exercise` +- Instructor class capability; +- return current exercise lifecycle plus complete staged definition including Instructor-only content. + +`PATCH /api/classes/exercise` +- Instructor class capability; +- requires `expectedRevision` for state-changing actions; +- actions: + - `start`; + - `pause`; + - `resume`; + - `release-content`; + - `begin-debrief`; + - `set-editing`; + - `advance`; + - `complete`. + +Return 409 on revision conflict; never perform a best-effort double advance. + +Instructor controls use stable IDs in request bodies, not case/stage identifiers in URLs. + +### Student + +`GET /api/classes/exercise/student` +- Student **class-session capability**, not the human join code; +- deliberately class-level so a safely admitted late/unassigned Student can receive the current class release while Waiting; +- returns only: + - public exercise identity; + - current Student-visible stage title/objective; + - status/phase/revision; + - editing-enabled state; + - cumulative released Student content; + - represented Student's current workspace readiness if assigned; +- never returns future stage metadata, Instructor content IDs, Instructor content, exemplar `state`, other team readiness, or workspace enumeration. + +`PUT /api/classes/exercise/student/ready` +- Student class-session capability; +- server resolves the represented participant's **current** assigned workspace; +- marks/unmarks readiness for current exercise/stage; +- rejects Waiting/unassigned Students; +- records current collaboration revision as evidence; +- stale pre-reassignment workspace authority cannot mark the old team ready. + +Student stage reads are class-session authorized because stage release is class-level. Intake edits remain workspace-capability authorized. + +## Existing protected Case Study endpoint interaction + +This is a critical anti-bypass rule. + +While a class has an active staged exercise for Case Study X: +- Student `POST /api/classes/case-studies/student` must **not** return Case Study X's complete `state`; +- the Student must use the staged exercise endpoint to receive only released content; +- Instructor `POST /api/classes/case-studies` may continue to receive the complete protected record; +- unrelated non-staged protected resources may retain current behavior unless a later product rule narrows them. + +Do not rely on the Student drawer hiding the full-case Apply button. The server must close the full-payload bypass for the active staged case. + +A completed exercise still does not imply automatic exemplar release. + +## Server-enforced edit freeze + +If the Instructor freezes Student editing: +- Student UI projects the Intake read-only and clearly says the class is paused/debriefing; +- existing presence/observation may continue; +- Student snapshot mutation through `PUT /api/workspaces/session` must be rejected server-side for `classroom-student` aliases in the frozen exercise; +- legacy Standalone collaboration is unaffected; +- Instructor observer remains read-only as before; +- unfreeze resumes the same collaboration workspace/revision. + +Prefer a specific non-terminal response such as HTTP 423 with a stable machine-readable Classroom code. The collaboration controller must treat this as temporary read-only state, not invalid/expired authority. + +No freeze is complete until the server write path enforces it. + +## Student client lifecycle + +The Student exercise controller is separate from `classroomStudent.js` assignment authority but consumes the active Student class-session capability. + +Responsibilities: +- poll exercise revision/state; +- hold only currently released Student-safe content in memory; +- render current stage/phase; +- announce stage/pause/debrief changes accessibly; +- open case reference beside Intake without navigating away; +- expose team Ready control only while assigned; +- project temporary edit freeze; +- discard staged content on class disconnect/role switch. + +Never persist protected staged content or exercise credentials into Intake state. + +### Case reference UX + +Preferred desktop direction: +- resizable or bounded side panel beside Intake; +- current stage header + objective; +- released content grouped in release order; +- obvious “new evidence” indication after Instructor release; +- Intake remains editable in the main pane when allowed. + +Preferred narrow/mobile direction: +- accessible drawer/sheet; +- returns focus to the invoking control; +- no horizontal two-column dependency. + +The exact layout is a UI tranche decision and must be verified in real browsers. + +## Instructor exercise console + +Extend the existing #312 live dashboard rather than building a second Instructor app. + +The console should eventually show: +- selected Case Study; +- current stage + phase; +- exercise revision/status; +- elapsed/suggested time as advisory only; +- Start / Pause / Resume / Begin debrief / Advance / Complete controls; +- optional evidence/hint release controls; +- Freeze/Unfreeze editing control; +- team readiness; +- team activity/progress/coaching indicators; +- one-click read-only observation; +- Instructor-only stage notes/debrief prompts. + +Accessible buttons/selects are the canonical controls. Keyboard operation must not depend on drag-and-drop. + +## Late join + +A newly admitted Student: +- receives a class-session capability as in #312; +- may read the **current** Student release from the exercise endpoint even while Waiting; +- receives no future or Instructor content; +- joins the current stage rather than replaying old transitions; +- when assigned, connects to the destination team's current Intake without changing class exercise state. + +## Reassignment + +Exercise stage is class-global. + +When Student A moves Team A -> Team B: +- Student A's staged case release does not change; +- old Team A workspace authority is revoked by #312; +- Student A connects to Team B's current Intake; +- Team A readiness/checkpoints remain Team A; +- Student A inherits Team B's current readiness state; +- no checkpoint or Intake snapshot is copied between teams; +- future releases continue from the class exercise state. + +## Failure and concurrency rules + +- Every Instructor lifecycle mutation uses `expectedRevision`. +- Duplicate/retried begin-debrief must not create duplicate checkpoints. +- Duplicate/retried release-content must be idempotent. +- Duplicate/retried advance must not skip a stage. +- Student polling treats a newer exercise revision as authoritative. +- An out-of-order older response must never replace newer stage state in the browser. +- Case definition changes after an exercise starts must not silently reinterpret the running exercise. The exercise pins both simulation version and a definition fingerprint; later API reads/mutations must fail closed when the current server-only definition does not match that identity. +- If a staged definition is missing/corrupt after an exercise was created, fail closed: do not expose full Case Study state to Students. + +## Accessibility requirements + +- current stage and lifecycle changes announced through a polite live region; +- pause/freeze/debrief state is conveyed in text, not color alone; +- Student case panel is fully keyboard reachable and closable; +- all content block images require alt text; +- tables use semantic headers; +- Instructor controls have explicit accessible names and disabled/busy states; +- focus is intentionally managed after stage transitions/dialog actions; +- no timing requirement depends on a Student reacting within a fixed duration; +- real-browser axe serious/critical violations remain fatal. + +## Browser acceptance journeys + +Before #313 is mergeable, deterministic real-browser coverage should prove: + +1. Instructor starts class, creates teams, selects a staged synthetic test case, and starts Stage 1. +2. Two Students see only Stage 1 content; a direct attempt to fetch Stage 2/future content fails. +3. Student full-payload request for the active staged case cannot bypass progressive disclosure. +4. Team A and Team B keep separate Intake work. +5. Team marks Ready; Instructor sees readiness without seeing another team's editable authority. +6. Late Student joins during Stage 1 and sees exactly the current cumulative release. +7. Instructor releases optional evidence; all Students receive it without page reload. +8. Instructor begins debrief; immutable team checkpoints are captured. +9. With freeze enabled, Student UI is read-only **and direct server PUT is rejected**. +10. Instructor observer/coaching still works during debrief. +11. Instructor advances to Stage 2; Students receive new content and retain their same team Intake. +12. Instructor moves a Student A -> B; stage access remains correct, old A edit authority fails, and destination Intake wins. +13. Unassigned Student remains in the current exercise safely but cannot mark a workspace Ready. +14. Instructor-only notes/model content never appear in Student responses, DOM, public browser assets, logs, or error messages. +15. Mobile case reference and all stage controls remain keyboard/screen-reader viable. + +Use a **synthetic deterministic staged case in the test fixture** until authoritative production stage content has been explicitly supplied/reviewed. Do not fabricate official case pages merely to satisfy browser tests. + +## Implementation sequence + +### Tranche 1 — architecture / server contract +- this document; +- cold-start/workstream/roadmap/API-agent governance; +- no runtime/schema changes. + +### Tranche 2 — staged Case Study authoring schema +- extend Case Study validator/server-only manifest for optional `simulation`; +- keep public manifest unchanged; +- add validation tests proving Student/Instructor separation; +- no production staged definition unless authoritative content exists. + +### Tranche 3 — exercise persistence/repository +- additive exercise/release/workspace-state/checkpoint tables; +- repository methods; +- deterministic in-memory parity; +- optimistic revision and idempotency tests. + +### Tranche 4 — exercise APIs +- Instructor create/read/lifecycle actions; +- Student class-session stage read; +- Student readiness; +- full-payload bypass closure for active staged case; +- authorization matrix. + +### Tranche 5 — server-enforced editing policy +- classroom-student write guard; +- temporary lock response contract; +- no impact on Standalone collaboration. + +### Tranche 6 — Instructor exercise console +- staged case selection; +- lifecycle/release/freeze controls; +- readiness/progress integrated with existing dashboard. + +### Tranche 7 — Student case reference +- stage-aware side panel/drawer; +- released content only; +- ready/debrief/freeze UX; +- automatic revision transitions. + +### Tranche 8 — checkpoints / debrief views +- stage checkpoint capture/inspection; +- Instructor facilitation prompts; +- preserve Student work; never overwrite with exemplar. + +### Tranche 9 — integrated browser/security/accessibility acceptance +- complete multi-browser staged journey; +- unauthorized future/full payload checks; +- late join/reassignment; +- server freeze; +- mobile/a11y. + +### Tranche 10 — final docs/security/merge readiness +- full diff audit; +- cold-start reconciliation; +- merge only from a green documented head. + +## Rollback boundary + +The first server changes should be additive: +- #312 class/participant/workspace behavior remains valid without an exercise; +- existing collaboration remains authoritative for live Intake; +- existing non-staged protected Case Studies remain supported; +- exercise tables/routes may be unused if the staged UI is rolled back; +- no destructive migration is required to disable #313. + +Do not make Standalone or ordinary Classroom assignment boot depend on an active exercise. + +## Implementation checkpoint — exercise APIs / authorization + +Tranche 4 is implemented on top of the additive exercise repository contract. + +Routes: +- Instructor `POST /api/classes/exercise` creates/reuses the represented class's draft only for an explicitly staged protected Case Study and pins simulation version + canonical SHA-256 definition fingerprint; +- Instructor `GET /api/classes/exercise` returns the represented class's current exercise, releases, workspace readiness, checkpoints, and the complete Instructor-authorized staged definition; +- Instructor `PATCH /api/classes/exercise` supports `start`, `pause`, `resume`, `release-content`, `begin-debrief`, `advance`, and `complete`, all revision-safe and stage-order aware; +- Student `GET /api/classes/exercise/student` authenticates with the stable Student class-session capability and returns only the represented learner's public class/assignment context plus cumulative Student-safe content released through the current stage; +- Student `PUT /api/classes/exercise/student/ready` resolves the participant's current workspace server-side, records the server-observed collaboration revision when Ready is set, rejects Waiting, and fails closed if reassignment races the readiness write. + +Progressive-disclosure / security semantics: +- every staged read or lifecycle mutation revalidates the pinned simulation version/fingerprint against the current protected definition and returns conflict on definition drift; +- Student payload construction never returns future-stage metadata, Instructor content IDs/content, exemplar/model material, or the complete protected Case Study `state`; +- optional content is released only for the current work phase and replay of the same release remains idempotent; +- entering debrief captures immutable current-workspace snapshots/revisions before later live edits; +- Student full protected Case Study retrieval is blocked for any class/case that has a staged exercise record, including after completion; completion is not an implicit exemplar reveal; +- unrelated protected non-staged resources retain their existing behavior; +- Instructor and Student credentials are not interchangeable and the human join code has no exercise authority; +- all staged responses remain `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. + +Tranche 4 implementation/security-test checkpoint: `5afc0041db89348d97ca0986cab89491765d0e00`. + +Tranche 4 validation: +- repository quality: **273 tests / 272 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +## Implementation checkpoint — server-enforced Student editing policy + +Tranche 5 makes the staged editing policy an actual server authorization boundary rather than a UI convention. + +Write-path contract: +- only snapshot mutation through `PUT /api/workspaces/session` is gated; ordinary GET, presence, observation, coaching, staged reads, and readiness remain available; +- the collaboration repository first distinguishes an active `classroom-student` alias from a primary/Standalone workspace token; +- Standalone/primary collaboration therefore does not depend on Classroom policy resolution; +- a Classroom Student alias resolves its represented class/workspace and current non-completed staged exercise; +- if `student_editing_enabled=false`, the PUT returns **HTTP 423 Locked** with stable code `classroom-editing-locked`; +- a locked request does not change snapshot or collaboration revision; +- the real Neon mutation repeats the lock predicate inside the SQL UPDATE itself, so an Instructor freeze racing an already-started Student save fails closed rather than slipping through after a preflight check; +- both #312 live-participant aliases and legacy Classroom membership aliases are covered by the atomic scope lookup; +- classes with no staged exercise remain writable exactly as before. + +Exercise-policy contract: +- entering debrief applies the stage's explicit `defaultDebriefEditPolicy`: `frozen` sets Student editing false, `open` leaves it true; +- Instructor `set-editing` is a revision-safe debrief-only server action for an explicit freeze/unfreeze override; +- Pause and Resume preserve the current editing policy; pausing is not itself a freeze and resuming a frozen debrief does not silently unlock it; +- advancing to the next work stage restores Student editing for work; +- completing an exercise removes it from current-exercise policy lookup, so the staged lock no longer applies after completion; +- staged Instructor/Student payloads may now truthfully expose `editFreezeEnforced: true` and the persisted `studentEditingEnabled` state. + +Focused regression proves: +- Classroom Student write succeeds during work; +- Stage 1 debrief defaults frozen; +- direct frozen PUT returns 423; +- snapshot and revision remain unchanged after the rejected write; +- GET continues while frozen; +- Standalone write continues while the class is frozen; +- Pause -> Resume preserves the freeze; +- Instructor unfreeze increments exercise revision and restores Student writes. + +Implementation checkpoint: `261b28d488538964ccd73132ed568d73628f6411`. + +Validation: +- repository quality: **274 tests / 273 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +## Implementation checkpoint — Tranche 6A Instructor console foundation + +The first Instructor-console slice is implemented without adding lifecycle mutations yet. + +Foundation contract: +- `GET /api/classes/exercise` returns an Instructor-authorized `availableCaseStudies` list containing only protected Case Studies with explicit staged `simulation` definitions; +- discovery items are summaries only: id/name/description/supported modes, with no `simulation`, protected source `state`, future Student content, or Instructor facilitation content; +- `src/classroomExerciseInstructor.js` owns the in-memory Instructor exercise client and reads the current exercise/discovery state on class connect; +- the controller never exposes the raw Instructor capability through public state and persists no exercise/capability data; +- the existing Instructor dashboard owns the new `[feature:instructor-exercise-console]` shell; Tranche 6A exposes status/current case/staged availability plus Refresh only; +- disconnect clears the console immediately; transient read errors keep the shell recoverable through explicit refresh. + +Implementation checkpoint spans `2624f1e...` through `d20a303...`. + +## Implementation checkpoint — Tranche 6B staged Case Study selection and draft creation + +The console now creates the server-owned draft without adding lifecycle actions. + +Draft-setup contract: +- the selector is populated only from the Instructor-authorized `availableCaseStudies` discovery list; +- create sends only the selected `caseStudyId` in an authenticated POST body; +- a successful create hydrates the returned draft and protected Instructor definition in memory, while public controller state exposes only the sanitized Case Study summary; +- an idempotent `created: false` response is treated as the represented existing draft rather than an error; +- once an exercise exists, selector/create controls are disabled; +- a 409 never retries the stale create request; the client performs an authoritative GET and renders the current exercise; +- draft setup releases no Student content and does not modify Intake/collaboration state; +- exercise state remains server-owned and is re-fetched on Instructor class resume/reload; +- selector/label/option controls are explicitly local-only and summary-excluded so repository guards do not force Classroom exercise state into Intake persistence or Copy & Paste Summary; +- deterministic browser coverage uses only a synthetic staged case in the browser fixture. + +Implementation spans `6a69426...` through `5f3fb56...`, with synthetic fixture support at `c3440eb...`. + +Validation note carried from 6A: its repository quality gate passed, but the four-browser integration journey hit the old 30-second Playwright test timeout. Its immediately prior green runtime was 26.1 seconds, so the acceptance-specific budget is now 45 seconds rather than removing assertions or weakening behavior. + +## Implementation checkpoint — Tranche 6C Start/Pause/Resume and current-stage Instructor context + +Lifecycle/pacing contract: +- Start is exposed only for a draft with no current stage and PATCHes `{ action: "start", expectedRevision }`; +- Pause is exposed only for an active exercise and Resume only for a paused exercise; +- every lifecycle mutation uses the currently observed positive `exerciseRevision`; +- a 409 mutation conflict performs one authoritative GET and renders that state; stale lifecycle intent is never automatically replayed; +- after Start, the console renders only the current stage's title, Student objective, suggested minutes, and Instructor content referenced by that stage's `instructorContentIds`; +- current-stage facilitation remains Instructor-only and memory-only; +- Pause is described as class pacing, explicitly distinct from the Student editing freeze policy that belongs to debrief; +- exercise state remains server-owned and is restored by GET on same-device Instructor resume/reload; +- deterministic browser acceptance uses the synthetic staged case only and proves Start -> Pause -> Resume -> reload. + +Implementation spans `53ce2b7...` through `2888beb...`. + +## Implementation checkpoint — Tranche 6D optional evidence release and team readiness/progress + +Release/progress contract: +- optional release UI is derived only from the current stage's explicit `optionalReleaseIds` and the protected Instructor-authorized staged definition; future-stage optional Student content is not rendered as a release control; +- a new optional release PATCHes `{ action: "release-content", expectedRevision, contentId }` only while the represented exercise is active/current-stage work; +- an already-recorded release is idempotent and renders **Released** without a duplicate revision increment; +- a genuinely new stale release returns conflict and the client performs one authoritative GET; stale release intent is never automatically replayed; +- current-stage team progress is derived only from Instructor-authorized `workspaceState`; represented rows show Ready/Working while absence of a readiness row is reported honestly as no readiness signal yet; +- progress buttons reuse the existing Instructor `selectWorkspace()` observer/coaching flow rather than minting any new workspace authority or observer path; +- release/progress controls are local-only and summary-excluded; staged content, readiness, and capabilities remain outside Intake persistence/exports; +- deterministic browser acceptance uses only synthetic optional evidence/readiness and proves release -> Released, Ready progress, observer navigation, and server-backed state after reload; +- expansion of the Instructor console exposed a real nested-scroll usability bug in the rail. The inner workspace list no longer owns a competing scroll region, so the outer dashboard keeps workspace rows reachable/clickable without force-click workarounds. + +Implementation spans `1de00a1...` through `2c8145b...`. + +Validation on `2c8145b31f96403f59ff3b41d9396fd2a3ac86ed`: +- repository quality: **285 tests / 284 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +## Implementation checkpoint — Tranche 6E Begin Debrief, checkpoint review, and editing policy + +Debrief/edit-policy contract: +- **Begin debrief** is exposed only for an active exercise in current-stage work and PATCHes `{ action: "begin-debrief", expectedRevision }`; +- successful debrief entry consumes the server response after immutable checkpoint capture and renders only checkpoint workspace identity + captured workspace revision for the current stage; +- checkpoint snapshot bodies remain server-owned evidence: the browser controller sanitizes them away from public state and does not create an editable or persisted checkpoint copy; +- the console presents `studentEditingEnabled` independently from Pause/Resume so class pacing and Student write authorization remain visibly separate concepts; +- active debrief exposes **Freeze editing** when editing is allowed and **Allow editing** when editing is frozen; both use revision-safe `set-editing`; +- idempotent local policy requests do not issue unnecessary PATCHes, while a 409 on a real mutation performs one authoritative GET and never replays stale intent; +- the server-enforced HTTP 423 `classroom-editing-locked` boundary from Tranche 5 remains the actual authorization control; the Instructor toggle is not treated as client-side security; +- existing observer/coaching remains available through debrief, including while Student editing is frozen; +- real-browser acceptance proves work -> debrief checkpoint capture -> default frozen policy -> allow -> freeze -> reload with checkpoint/release/progress state restored. + +Instructor workspace/mobile UX added in the same bounded slice: +- the existing class/team rail has an explicit accessible Collapse/Open control; +- desktop collapse reclaims horizontal Intake space; +- at <=700px the rail defaults collapsed to a compact class header instead of occupying the mobile page, but remains discoverable and can be reopened normally; +- tests use the real Open class panel control before mobile team switching; no forced-click workaround is used; +- broader Notes/mobile chrome alignment remains a follow-on: prefer compact discoverable launchers/default-collapsed behavior rather than making either workspace silently disappear. + +Implementation spans `044e4f82...` through `61aa582...`. + +Validation on `61aa582e9faee9a7e500796634c5cc06e256ff14`: +- repository quality: **288 tests / 287 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +## Implementation checkpoint — Tranche 6F Advance/Complete and lifecycle completion + +Final Instructor lifecycle contract: +- **Advance to next stage** is rendered only when the represented exercise is active, in debrief, and the protected Instructor-authorized simulation has a next authored stage; +- the client sends only `{ action: "advance", expectedRevision }`; it never chooses or submits an arbitrary next stage ID; +- successful Advance accepts the server's authoritative next stage, returns to work, and renders that stage's Student objective/timing plus only its referenced Instructor facilitation content; +- advancing restores the represented Student editing policy for work and hides debrief/checkpoint controls until the next debrief; +- **Complete exercise** is rendered only when active debrief is on the final authored stage and sends `{ action: "complete", expectedRevision }`; +- completion renders as **Completed** even though the persisted terminal stage phase remains `debrief`; +- terminal completion hides Pause/Advance/Complete and Freeze/Allow mutations, preserves final-stage checkpoint review, and states explicitly that completion does not release additional Student, Instructor-only, exemplar/model, or complete protected Case Study material; +- all lifecycle 409s continue to authoritative-refresh and never replay stale intent; +- existing observer/coaching remains usable while stages advance and after exercise completion as long as the Classroom session remains valid; +- deterministic browser fixture now owns two synthetic stages only and preserves prior-stage checkpoints when the later stage enters debrief; +- real-browser acceptance proves Stage 1 work -> Stage 1 debrief -> Advance -> Stage 2 work -> Stage 2 debrief -> Complete -> reload. + +Implementation spans `e855df09...` through `e2f5534...`. + +Validation on `e2f5534aa0e85bccc2bee0e73275c9b77d000f3d`: +- repository quality: **290 tests / 289 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +## Implementation checkpoint — Tranche 7A Student current-stage case-reference foundation + +Student reference contract: +- `src/classroomExerciseStudent.js` owns staged Student presentation separately from assignment/workspace authority; +- the client accepts only the stable live Student class-session capability in memory and GETs `/api/classes/exercise/student`; it never uses the rotating workspace capability to authorize staged reads and never requests the complete protected Case Study payload; +- `classroomStudent.js` now provides a stable live-session lifecycle alongside the existing workspace lifecycle. Team reassignment rotates collaboration authority without disconnecting/reconnecting the staged exercise client; terminal session loss, Leave Class, destroy, or Student-role exit clears it; +- payload projection permits only Student case summary, represented exercise status/phase/revision/editing state, current Student-visible stage title/objective, and cumulative released Student-safe content. Unknown future-stage or Instructor-only response fields are discarded before public state or DOM rendering; +- the case-reference module persists neither bearer capability nor staged content to Intake/localStorage/sessionStorage/export/summary/URL state; +- the learner sees explicit Work, Paused, Debrief/editing-open, Debrief/editing-frozen, or Completed status. Frozen debrief keeps released case material reviewable and explains the temporary write restriction, while the Tranche 5 server 423 boundary remains the enforcement control; +- successful polling refreshes authoritative exercise revisions and released material automatically; same-device class resume reconstructs the reference from the server rather than local staged-content storage; +- desktop uses a bounded companion surface only when there is enough width; narrow/mobile defaults the reference collapsed behind an accessible **Open case reference** control; +- real mobile acceptance discovered that the pre-existing fixed Student team/Notes workspace dock could overlap and intercept the reference control. Student narrow layouts now place that dock in normal document flow; the browser journey opens the case reference with a normal click and no forced event/z-index bypass; +- deterministic browser payloads expose Stage 1 safe briefing/optional evidence but not Stage 2 content or Instructor facilitation; focused tests also inject forbidden-looking future/Instructor fields and prove they do not appear in DOM/public controller state; +- 7A intentionally adds no readiness mutation. + +Implementation spans `e307ca2...` through `91d7029...`. + +Validation on `91d7029bec5cddaf251d02301a4fd1ce97dc908c`: +- repository quality: **295 tests / 294 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +## Implementation checkpoint — Tranche 7B Student Ready / Resume Working + +Student readiness interaction contract: +- readiness controls exist only while the represented exercise is `active + work`, a current stage exists, and the Student has a current assignment; +- the browser uses only the stable Student class-session capability with `PUT /api/classes/exercise/student/ready` and sends exactly `{ ready: boolean }`; +- the client never submits a workspace identifier, workspace capability, or claimed workspace revision for readiness; +- Ready state is authoritative server state and displays `readyWorkspaceRevision`, which was observed by the server from the currently assigned collaboration workspace; +- **Resume working** clears readiness with `ready:false` while work remains active; +- Waiting, paused, debrief, and completed states expose no usable readiness action; +- assignment/phase conflicts return to one authoritative Student exercise read; stale Ready/Working intent is never replayed; +- readiness remains memory-only/local-only and is not merged into collaboration snapshot state, Intake export/summary, browser storage, or URLs. + +Implementation spans `e43b84c...` through `247479f...`, with deterministic browser support through `d311b7b...`. + +## Implementation checkpoint — Tranche 7C frozen-debrief Student Intake projection + +Frozen Student UX contract: +- when the Student-safe staged response reports `stagePhase=debrief`, `studentEditingEnabled=false`, `editFreezeEnforced=true`, and the exercise is not completed, the existing Intake surface is projected read-only/disabled; +- this projection is not an authorization boundary: the server-side `PUT /api/workspaces/session` HTTP **423 Locked** / `classroom-editing-locked` policy from Tranche 5 remains authoritative; +- projection records original disabled/readOnly/tabindex/ARIA/contenteditable/draggable state and restores it when editing reopens, the exercise advances/completes, staged context disappears, or the Student session disconnects; +- a paused frozen debrief stays read-only because Pause/Resume deliberately preserves the current editing policy; +- `#studentExperienceNotice` controls are excluded so **Leave class** remains available during a freeze; +- Notes are intentionally included because `notesWorkspace` is serialized by `collectAppState()` and therefore participates in the same collaboration snapshot mutation boundary; +- released case-reference material remains readable throughout the freeze; +- integrated browser acceptance exercises the real Instructor lifecycle: staged Start -> frozen Begin Debrief -> Student read-only projection -> Instructor Allow Editing -> Student projection restoration. + +Implementation spans `8d70e84...` through `50435c3...`. + +Validation status for the combined 7B/7C implementation is finalized on the documented head after repository/browser/security checks complete. Repository quality already passed on implementation head `50435c30e7d43f906eeff88d32265ba585fad079`. + +## Implementation checkpoint — Tranche 8A immutable checkpoint inspection versus current live work + +Checkpoint inspection contract: +- normal Instructor `GET /api/classes/exercise` carries only checkpoint metadata: workspace identity, stage, captured workspace revision, and capture time; snapshot bytes are intentionally excluded; +- explicit `GET /api/classes/exercise/checkpoint?workspaceId=` is Instructor-only, GET-only, current-exercise/current-stage debrief scoped, and definition-drift protected; +- the server resolves class/exercise/workspace ownership from Instructor authority and never accepts a Student workspace capability as checkpoint authority; +- the response returns exactly one immutable captured snapshot plus its captured collaboration revision; it never mints editable workspace authority and never writes or restores collaboration state; +- `classroomExerciseInstructor.js` fetches checkpoint bytes only after an explicit **Inspect checkpoint** action and passes them transiently to the Instructor class controller; the snapshot never enters exercise controller public state or browser persistence; +- `classroomInstructor.js` reuses the established read-only Intake projection for checkpoint evidence rather than maintaining a second renderer; +- checkpoint display is unmistakably historical: **Checkpoint at debrief start · Revision N** with **Immutable checkpoint · live updates paused**; +- entering checkpoint view pauses the live observer timer and current coaching projection; no Instructor coaching control is attached to historical checkpoint evidence; +- **View current live Intake** performs a fresh existing observer read for the same workspace and restores current revision/coaching context; +- choosing another workspace also exits checkpoint mode and opens that workspace's live observer; +- checkpoint inspection never overwrites, merges, restores, or saves Student work and never reveals exemplar/model/future-stage material. + +Implementation spans `0ce4b8d...` through `8530128...`; browser-test isolation/stabilization continues through `04587bf558d370b740f6d7f5c9b31b4dae8f4404`. + +Focused regression covers explicit Instructor snapshot retrieval, metadata-only normal payloads, Student authority rejection, no checkpoint snapshot in exercise controller public state, paused live observation while inspecting, and explicit checkpoint -> current-live restoration. The integrated browser proof is separated from the existing four-browser lifecycle test so timeout or checkpoint failures remain attributable; both integrated tests share a deterministic fixture and therefore run serially within their file while the broader Playwright suite remains parallel. + +Tranche 8 is complete: immutable checkpoint capture already existed, protected current-stage Instructor facilitation is already presented separately from Student content, and 8A now provides historical-vs-live debrief inspection without replacing Student work with an exemplar. + +Validation baseline: +- repository quality: **302 tests / 301 pass / 0 fail / 1 intentional skip**; +- final required Browser E2E, CodeQL, Dependency Review, and Template Manifest Guard evidence is taken from the final documented PR head. + +## Implementation checkpoint — Tranche 9A integrated staged security / late-join / reassignment acceptance + +Acceptance/security contract: +- the deterministic browser server now mirrors the production frozen Student collaboration-write boundary for staged exercises: a frozen debrief rejects the represented Student workspace PUT with HTTP **423 Locked** and stable `classroom-editing-locked` before any workspace mutation; +- browser acceptance reads the live workspace before and after that rejected mutation and proves both collaboration revision and snapshot remain byte-for-byte equivalent at the JSON object level; +- the deterministic browser protected-resource route mirrors the production staged bypass guard: a Student cannot POST for the complete synthetic staged Case Study while it is being delivered through the exercise; +- browser acceptance also performs a raw stable-session `GET /api/classes/exercise/student` and proves current released Student material is present while later-stage and Instructor-only facilitation content is absent; +- a late Student admitted after exercise Start is assigned normally and receives the represented current stage/release rather than draft state, future content, or Instructor content; +- active-stage reassignment rotates only collaboration workspace authority. The stable Student class-session continues to represent the same exercise stage, destination workspace Intake becomes authoritative, and the stale source workspace token remains invalid; +- readiness remains workspace-scoped. Browser acceptance marks the learner Ready on Team Alpha, reassigns the learner to Team Beta, and proves Team Beta begins Working rather than inheriting Team Alpha readiness; +- Student mobile acceptance expands the compact staged case-reference surface and passes the serious/critical axe scan with staged content visible; +- Instructor mobile acceptance opens the compact class rail, creates and starts the staged exercise, exposes current-stage protected facilitation/lifecycle controls, and passes the serious/critical axe scan; +- staged a11y acceptance corrected two contrast failures by using the existing darker `--accent-text` token for Instructor exercise primary lifecycle buttons and Ready progress text; the global product accent remains unchanged; +- checkpoint-vs-live acceptance derives the immutable checkpoint revision from the rendered server metadata and asserts the later live workspace is exactly the next collaboration revision, avoiding a deterministic serial-fixture assumption while preserving the comparison proof. + +Implementation/security acceptance spans `6391c39348d3e1a8c544f231833fdc71910b4363` through `89a5321a750606cff6fbf34ccf75ef40dc3def07`. Browser fixture/security additions span `523d2451a9b3327b3254c5f92086c8ae86935d53` through `89a5321a750606cff6fbf34ccf75ef40dc3def07`. + +Validation on `89a5321a750606cff6fbf34ccf75ef40dc3def07`: +- repository quality: **302 tests / 301 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **28 passed / 10 intentional project-scoped skips / 0 failed / 0 flaky** across 38 project cases; +- CI: green; +- CodeQL: green; +- Dependency Review: green; +- Template Manifest Guard: green. + +Tranche 9 is complete. The staged simulation now has browser-level proof for late join, reassignment/stale-authority cutoff, workspace-scoped readiness, progressive disclosure/full-payload denial, server freeze non-mutation, mobile Student/Instructor operation, accessibility, and historical-vs-live checkpoint comparison. + +## Implementation checkpoint — Tranche 10 final documentation, security, and merge-readiness audit + +Final audit scope/results: +- audited the full #313 branch from merged #312 base `b141d55050207e4cf7ce842a4452a220a5268968` through completed Tranche 9 head `b000ceab20d1c0bdc132cb0fede0aaa75c85fe17`, not only the latest commits; +- reconciled stale end-state prose in README, the Classroom API contract, and the Classroom roadmap; +- confirmed root/scoped AGENTS guidance matches the implemented separation between class-session authority, assignment/workspace edit authority, exercise orchestration, checkpoint evidence, and protected resources; +- confirmed Instructor exercise lifecycle/checkpoint reads are class-scoped and revision-safe; Student staged reads are stable-session scoped; Student collaboration writes remain workspace-authorized; +- confirmed progressive disclosure is server-side: future-stage content, Instructor facilitation, exemplar/model material, and complete Case Study `state` are omitted from staged Student responses; +- confirmed Student full protected Case Study retrieval is blocked whenever that class has a staged exercise record for the case, including after completion; +- confirmed freeze is a server authorization boundary, not a UI convention: frozen Classroom Student PUT is HTTP **423 `classroom-editing-locked`**, non-mutating, and atomically repeated in production SQL; Standalone and classes with no active staged policy remain writable; +- confirmed immutable checkpoints are historical facilitation evidence only: normal exercise reads expose metadata, explicit snapshot reads are Instructor-only/current-debrief scoped, and checkpoint rendering never restores or merges live Intake; +- confirmed late join/reassignment semantics preserve current class stage while collaboration authority/readiness follows the server-authoritative assignment; stale old-workspace authority is rejected; +- confirmed #313 schema initialization is additive/idempotent; exercise/release/readiness/checkpoint rows are class/workspace scoped, cascade with owning records, and inherit class retention; +- confirmed public/static boundaries remain intact: Standard-only public manifest, server-only protected manifest, authored template JSON excluded from Vercel source upload, generated `dist/` as the only public output; +- confirmed no production official Case Study has an authored staged `simulation`; synthetic staged definitions/content exist only in tests/browser fixtures until authoritative instructional material is supplied; +- confirmed Classroom exercise/session/checkpoint state and bearer capabilities remain outside Intake storage, templates, summary/export payloads, and URLs; +- confirmed PR #315 has no unresolved review threads/reviews and is mergeable; it remains draft only until the final documentation head re-passes required gates. + +Documentation reconciliation begins at `65390fca49f32a51e57e4c5d095eedd2e483b14e`. + +Validation baseline before documentation-only reconciliation: +- implementation/security head `b000ceab20d1c0bdc132cb0fede0aaa75c85fe17`; +- repository quality: **302 tests / 301 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **28 passed / 10 intentional project-scoped skips / 0 failed / 0 flaky** across 38 project cases; +- CI, CodeQL, Dependency Review, Template Manifest Guard: green. + +Tranche 10 is functionally complete. Remaining gate is repository validation of the documentation-reconciled head. + +## Closeout and next implementation action + +#313 / PR #315 completes the staged-simulation **engine and secure facilitation workflow**. It intentionally does not author guessed production case stages. + +Known remaining product gaps are now tracked separately: +- **#316** — protected staged case assets + rich Student rendering. Immediate blocker: the schema supports `image`, `table`, and `document-page`, while the current Student projection/rendering path keeps only `id/kind/title/body`. +- **#317** — source-faithful first production staged KT Case Study, after #316. +- **#318** — runtime use of `intakeTargetIds` plus stable identity/coaching for dynamic Possible Cause cards. +- **#319** — Instructor cross-team debrief comparison/progress view built on immutable checkpoints/live observation. +- **#320** — share/QR join convenience and compact mobile Notes/Class/Case chrome. + +After PR #315 merges, the exact next implementation action is **#316**. + +Do not begin production official-case staging as a shortcut. Preserve: +- server-side progressive disclosure; +- stable Student class-session authority for staged reads; +- assignment/workspace authority for Intake edits; +- no protected assets in public static output; +- no exercise/capability state in Intake persistence/export/summary/URLs; +- no Student access to future-stage/Instructor/exemplar content. + +#317 may inventory and map authoritative source material in parallel, but production-selectable staged content must wait for the secure rich-content path. diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 79bf91e..89f98d0 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -381,6 +381,211 @@ Continue #295 on draft PR #307 from the current branch head. - #312 browser acceptance now covers the complete live-class lifecycle without adding #313 staged simulation state. - Exact next action: perform the final #312 docs/security/merge-readiness review: reconcile API/architecture docs with the implemented one-code lifecycle, review the full `main -> #314` diff and unresolved review/security state, confirm additive rollback/legacy compatibility, and move #314 out of draft only if the final documented head remains green. Keep #313 as the next separate product slice. +## #312 merge boundary + +- #312 completed in PR #314. +- Squash merge on `main`: `b141d55050207e4cf7ce842a4452a220a5268968`. +- Final #312 head before merge: `d6a2f27b95639340ad46f6e596368f064671a08f`. +- Final gate: repository quality **252 tests / 251 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- #312 issue closed. +- #313 starts from the merge commit, not from the old #312 feature branch. + +## Active #313 implementation + +- Issue: #313 — Instructor-orchestrated case simulation and staged debrief. +- Branch: `feature/classroom-staged-simulation`. +- Base: #312 merge `b141d55050207e4cf7ce842a4452a220a5268968`. +- Canonical contract: `docs/classroom-staged-simulation.md`. +- Architecture checkpoint: `808bdfaa74e7e6a11a88173ee43c5c86d9a8da51`. +- First tranche is architecture/governance only; no exercise tables/routes/UI yet. +- Decided model: + - staged simulation is class-level orchestration layered over #312 assignment/workspaces; + - exercise lifecycle is separate from Intake/collaboration/coaching state; + - Student staged reads use the stable Student class-session capability, allowing safe current-stage access while Waiting; + - Student collaboration edits remain assignment-specific workspace-capability authorized; + - future-stage and Instructor-only content are withheld server-side; + - existing full protected Case Study `state` is source/exemplar material and cannot serve as the Student staged feed; + - a staged Case Study requires an explicit server-only `simulation` definition; never infer official stages from a completed Intake payload; + - while a staged exercise is active, the Student full-payload Case Study endpoint must not provide a bypass to the selected case's complete `state`; + - workspace readiness is team-scoped; + - beginning debrief captures immutable per-workspace checkpoints so pre-debrief reasoning can be discussed later without overwriting live Intake; + - optional edit freeze must be enforced on the Student collaboration write path, not only in the UI; + - late join enters the current class stage; reassignment preserves class stage while destination team Intake/readiness wins; + - Instructor lifecycle mutations use optimistic exercise revision so retries cannot double-advance. +- Production stage content is intentionally **not authored yet**. Use synthetic deterministic staged content for infrastructure tests until authoritative case pages/evidence/facilitation material is supplied and reviewed. +- Staged Case Study authoring-schema checkpoint: + - `46109491808bedfbf5e778d0979859b3cd96b56c` adds version-1 server-side simulation validation/normalization; + - `1f716470cfdf95a64c00a8a99a62735133feae61` integrates optional `simulation` into the protected Case Study manifest build only; + - `9322cecabe9a487e072dd279519b5f358f089ebd` / `1e22806b8a1e50e94150a13ecf3b7083f0747667` add synthetic schema and public-boundary regression coverage; + - `af6b6f21a8e4f25f014a8362857a1c15e252775b` restores #313 API guardrails alongside real tests. + - Standard Templates cannot carry `simulation`; Student and Instructor content are separate, stable-ID/cross-reference validated namespaces; unsafe arbitrary fields/URLs are rejected; existing non-staged Case Studies remain unchanged. + - no official production case has been staged or guessed. + - validation on `af6b6f21...`: repository quality **260 tests / 259 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; Template Manifest Guard and Dependency Review green. +- Staged exercise persistence/repository checkpoint: + - `ea964de7afc002ca307a903db7e1493d56837f13` adds additive exercise, optional-release, workspace-readiness, and immutable-checkpoint tables with class/workspace foreign-key scoping and one-open-exercise-per-class enforcement. + - `3e519b8f4ab136404ade550376af0c80e06574f1` adds real repository primitives for draft creation, optimistic lifecycle mutation, release replay/idempotency, Student-session/current-workspace readiness, class-scoped checkpoint capture/listing, and class-bounded expiry. + - `8595e1ec1072b365e82f759494ea0edb25001a4c` mirrors those semantics in the deterministic repository. + - `f7c1e580ca8fdc5e818e1f6974af374850f6d3a6` proves one-open-exercise, optimistic conflict, completion/history, release idempotency, readiness staying with teams through reassignment, Waiting rejection, immutable snapshot-isolated checkpoints, and cross-class rejection. + - `09ffaa6c...` through `c01dfff2f2bd1f9d7625901860210cc00eee7051` additionally pin simulation version + definition fingerprint on each exercise and keep those columns migration-safe so later APIs can fail closed on definition drift. + - pre-hardening validation on `f7c1e58...`: repository quality **265 tests / 264 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. + - no exercise HTTP route, Student collaboration write lock, or exercise UI exists yet. +- Staged exercise API / authorization checkpoint: + - Instructor `POST/GET/PATCH /api/classes/exercise` now owns class-scoped draft creation/read plus start/pause/resume/optional release/begin-debrief/advance/complete with optimistic exercise revision and immutable stage ordering. + - Student `GET /api/classes/exercise/student` uses the stable class-session capability and returns only current/cumulative Student-safe released content; future stage and Instructor-only material remain server-withheld. + - Student `PUT /api/classes/exercise/student/ready` resolves the current assignment server-side, records server-observed workspace revision, rejects Waiting, and fails closed on reassignment races. + - every read/mutation validates the pinned simulation version/fingerprint against the current protected definition. + - active/completed staged cases block Student full protected Case Study payload retrieval for that case; completion does not imply exemplar release. + - Instructor full protected access and unrelated protected resources retain their existing behavior. + - Tranche 4 deliberately returns `editFreezeEnforced: false`; no security claim is made until collaboration PUT enforcement lands. + - implementation/security-test checkpoint: `5afc0041db89348d97ca0986cab89491765d0e00`. + - validation: repository quality **273 tests / 272 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- Server-enforced Student editing-policy checkpoint: + - `9eea334b1a7ea791f3edf3d3330ac512b06bbb32` adds capability-kind-aware workspace write policy preflight; `6a8fb4f55b653ca5acc66954fcc8d28fe375ace4` resolves current Classroom exercise write policy; `1aec6a17647871ab23c36572ab339f2dd3e63350` wires the production session route. + - `96eab74f64838775f544990e495fe62a3319827f` / `4eb7f5e6b8a719add994c79acbff31c04a4ebcbb` apply authored debrief defaults, expose truthful enforcement state, and add revision-safe debrief `set-editing`. + - `507da02269336fb414d2ccc5390be4cbb26ed97c` hardens the real Neon `classroom-student` mutation with an atomic SQL freeze predicate so Instructor freeze racing a Student PUT fails closed. + - `f3304a2f470b8c96468de13c787cbe0378b8f8b9` makes Pause/Resume preserve the current edit policy instead of silently unlocking a frozen debrief. + - `d66eeeace6f9e41dade765b6b0331a69a40ebbb0` / `261b28d488538964ccd73132ed568d73628f6411` prove allowed work writes, 423 frozen-write rejection with zero snapshot/revision mutation, frozen GET availability, Standalone isolation, freeze persistence through Pause/Resume, and explicit unfreeze restoring writes. + - stable temporary-lock contract: HTTP **423 Locked**, `code: classroom-editing-locked`. + - validation on `261b28d...`: repository quality **274 tests / 273 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- Tranche 6A — Instructor exercise console foundation: + - implementation spans `2624f1e...` through `d20a303...`; + - `GET /api/classes/exercise` now returns `availableCaseStudies` containing **only** staged Case Study summaries; full `simulation` definitions and protected source `state` are not used for discovery; + - new `src/classroomExerciseInstructor.js` owns an in-memory Instructor exercise client and read-only console foundation; + - the existing Instructor dashboard now includes a `[feature:instructor-exercise-console]` section for current exercise status, staged availability, and explicit refresh; + - Instructor capability and exercise context remain outside Intake persistence, summaries, exports, URLs, and public manifests; + - no exercise mutations were added in 6A; selection/create and Start/Pause/Debrief controls remain intentionally deferred to the next slice; + - focused API/client tests cover staged-only discovery, absence of protected simulation/source material in the discovery list, capability non-persistence, draft rendering, disconnect cleanup, and transient refresh recovery. +- Tranche 6B — staged Case Study selection and draft creation: + - implementation spans `6a69426...` through `5f3fb56...`, with deterministic browser fixture support at `c3440eb...`; + - the console now exposes an accessible staged Case Study selector and **Create draft** action only when no exercise is open; + - POST sends only the selected server-discovered `caseStudyId`; successful create and idempotent existing-draft reuse both hydrate the represented server exercise without persisting it locally; + - once an exercise exists, selector/create are disabled so the UI cannot imply a second simultaneous exercise; + - a 409 create conflict is never blindly retried: the controller performs an authoritative GET and renders the current exercise, with a visible "current state reloaded" status; + - public controller state continues to sanitize the protected Case Study to summary fields and never exposes the Instructor capability or Instructor-only simulation content; + - deterministic browser acceptance now uses a synthetic staged case owned only by `scripts/serve-browser-tests.mjs`; no official production case content was staged or guessed; + - the live Instructor browser journey proves draft create, server-backed reload/resume, disabled duplicate-create control, and no local exercise persistence; + - summary/persistence guards explicitly mark the staged selector, label, placeholder, and dynamic options as local-only / summary-excluded rather than incorrectly wiring Classroom exercise state into Intake serialization; + - 6A final-head repository quality was green, but the existing four-browser integration acceptance exceeded its 30s global timeout (historical green runtime was already 26.1s). 6B raises only that test budget to 45s; no failing product assertion was suppressed. +- Tranche 6C — Start/Pause/Resume and current-stage Instructor context: + - implementation spans `53ce2b7...` through `2888beb...`; + - draft-only **Start exercise**, active-only **Pause exercise**, and paused-only **Resume exercise** controls use the current server `exerciseRevision`; + - lifecycle PATCH conflict behavior is fail-refresh: a 409 triggers authoritative GET and never replays stale Instructor intent; + - after Start, the console renders current stage title, Student objective, suggested timing, and only the current stage's Instructor-authorized facilitation blocks; + - Pause explicitly describes class pacing as distinct from Student editing freeze, preserving the later debrief-policy boundary; + - deterministic browser fixture now implements matching Start/Pause/Resume revision semantics against the synthetic staged case; + - focused controller coverage proves revision progression, stage/facilitation rendering, state-specific controls, and 409 refresh; real-browser Instructor acceptance proves Start -> Pause -> Resume -> reload; + - the unrelated four-browser acceptance timeout is raised narrowly from 30s to 45s after the previous run completed product assertions but timed out during teardown; no behavioral assertion was removed. +- Tranche 6D — optional evidence release and team readiness/progress: + - implementation spans `1de00a1...` through green implementation head `2c8145b...`; + - the console renders optional Student evidence only from the **current stage's** explicit `optionalReleaseIds`; future-stage optional content is not projected into the Instructor release UI; + - **Release to Students** PATCHes `release-content` with the currently observed `exerciseRevision`; a genuinely new stale release 409 refreshes authoritative state and never replays stale intent; + - an already-recorded release remains idempotent and renders **Released** without a second revision bump; + - current-stage `workspaceState` rows render Ready/Working progress and progress buttons reuse the existing Instructor `selectWorkspace()` observer/coaching path rather than creating a second observer; + - exercise releases/readiness/progress remain memory-only and local-only / summary-excluded; + - deterministic browser coverage uses only synthetic optional evidence/readiness and proves release, persisted release after reload, Ready progress, and one-click observer navigation; + - the prior four-browser failure was traced to nested scrolling in the expanded Instructor rail, not product authorization; the rail layout now lets the outer dashboard own scrolling so workspace buttons remain genuinely clickable without forced test clicks; + - validation on `2c8145b...`: repository quality **285 tests / 284 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- Tranche 6E — Begin Debrief, checkpoint review, Freeze/Allow Editing, and compact Instructor rail: + - implementation spans `044e4f82...` through green implementation head `61aa582...`; + - **Begin debrief** is exposed only for active current-stage work and PATCHes the current `exerciseRevision`; 409 conflicts authoritative-refresh and never replay stale intent; + - entering debrief renders the server-captured immutable checkpoint status per current-stage workspace as workspace label + captured collaboration revision; checkpoint snapshot bodies remain server-side/in-memory and are deliberately omitted from controller public state and the checkpoint UI; + - debrief shows the authoritative `studentEditingEnabled` state separately from Pause/Resume and exposes exactly one applicable **Freeze editing** or **Allow editing** action; + - `set-editing` is debrief-only and revision-safe; conflicts refresh without replay, while the existing HTTP 423 `classroom-editing-locked` write guard remains the actual Student enforcement boundary; + - deterministic browser acceptance proves work -> Begin Debrief -> immutable checkpoint capture -> stage-default freeze -> Allow Editing -> Freeze Editing -> reload while the existing observer remains active; + - the connected Instructor class/team rail now has explicit collapse/expand controls. At <=700px it defaults to a compact collapsed class header rather than consuming the full mobile viewport; the Instructor can reopen it normally, and desktop collapse also reclaims horizontal Intake space; + - mobile coaching/observer acceptance now explicitly reopens the compact class rail before switching teams, proving the collapsed state remains usable rather than relying on hidden/forced clicks; + - validation on `61aa582e9faee9a7e500796634c5cc06e256ff14`: repository quality **288 tests / 287 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- Mobile workspace UX follow-on: align the Notes workspace and Instructor class rail around the same compact mobile principle. Prefer an always-discoverable collapsed header/launcher over fully hiding either workspace; evaluate making Notes default-collapsed on narrow screens and reducing its collapsed chrome so it does not obstruct primary Intake work. +- Tranche 6F — Advance/Complete and Instructor-console lifecycle completion: + - implementation spans `e855df09...` through green implementation head `e2f5534...`; + - **Advance to next stage** is exposed only from active debrief when the protected Instructor definition has a next authored stage; it PATCHes the current `exerciseRevision` and follows the authored stage order rather than accepting a client-selected stage ID; + - successful Advance renders the next stage's title/objective/timing/current Instructor facilitation, returns the exercise to work, and reflects authoritative Student editing restored for work; + - **Complete exercise** is exposed only from active debrief on the final authored stage and also uses the current revision; + - Completed now takes precedence over the debrief phase label, hides further Advance/Complete and Freeze/Allow controls, retains final-stage checkpoint review, and explicitly states that completion does **not** release additional Student, Instructor-only, exemplar, or full protected Case Study material; + - lifecycle conflicts keep the same fail-refresh/no-replay behavior; focused coverage proves a stale Advance performs GET -> PATCH 409 -> authoritative GET with no mutation replay; + - the deterministic browser staged fixture now has two synthetic authored stages and preserves earlier-stage checkpoints while capturing final-stage checkpoints; + - the real Instructor journey proves Stage 1 work/debrief/freeze -> Advance -> Stage 2 work -> Stage 2 debrief -> Complete -> reload, while the existing observer remains available throughout; + - validation on `e2f5534aa0e85bccc2bee0e73275c9b77d000f3d`: repository quality **290 tests / 289 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- Mobile workspace UX follow-on remains open: align the Notes workspace and Instructor class rail around the same compact mobile principle. Prefer an always-discoverable collapsed header/launcher over fully hiding either workspace; evaluate making Notes default-collapsed on narrow screens and reducing its collapsed chrome so it does not obstruct primary Intake work. +- Tranche 7A — Student current-stage case-reference foundation: + - implementation spans `e307ca2...` through green implementation head `91d7029...`; + - new `src/classroomExerciseStudent.js` is a dedicated Student staged-exercise client rather than adding exercise authority to `classroomStudent.js`; + - the Student controller uses only the **stable live Student class-session capability** for `GET /api/classes/exercise/student`; assignment/workspace capabilities continue to authorize collaboration only and may rotate independently; + - `classroomStudent.js` now exposes separate session-level connect/disconnect hooks so the staged case reference survives Team A -> Team B reassignment but clears on live-session termination or Student-role exit; + - the browser controller sanitizes the response down to case summary, current stage/objective, phase/editing state, and cumulative released Student-safe content; unknown/future-stage and Instructor-only fields are discarded from DOM and public state; + - staged exercise credentials/content remain memory-only and never enter Intake persistence, localStorage/sessionStorage, summaries, exports, URLs, or public manifests; + - the Student surface reports Work, Paused, Debrief/editing-open, Debrief/editing-frozen, and Completed state; a frozen debrief explains that case material remains reviewable even while Intake writes are locked server-side; + - exercise polling follows the represented server revision and reload/resume restores the current Student-safe case reference without storing the protected material locally; + - the reference is compact/collapsible and defaults collapsed at <=700px; on very wide desktop it becomes a bounded left companion while Intake/Notes keep their existing space; + - real mobile acceptance exposed an existing fixed team/notes `.workspace-dock` overlapping the new reference toggle. Student narrow layouts now put that dock into normal document flow rather than floating over Intake; the acceptance test uses an ordinary click, not force/z-index workarounds; + - deterministic Student fixture responses intentionally omit complete `simulation`, future-stage metadata, and Instructor facilitation; browser acceptance proves Stage 1 released content appears while Stage 2 and Instructor-only text do not; + - validation on `91d7029bec5cddaf251d02301a4fd1ce97dc908c`: repository quality **295 tests / 294 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- Mobile workspace UX follow-on remains open beyond the overlap fix: converge the Student team/presence workspace and Notes workspace on compact discoverable headers/default-collapsed behavior so normal-flow mobile layout remains useful without excessive vertical footprint. +- Tranche 7B — Student Ready / Resume Working: + - implementation spans `e43b84c...` through `247479f...`, with browser fixture/acceptance through `d311b7b...`; + - readiness appears only during `active + work` while the Student has a current assignment; + - **Mark Ready** / **Resume working** use only the stable Student class-session capability with `PUT /api/classes/exercise/student/ready` and body `{ ready: boolean }`; no workspace ID/token or client-supplied collaboration revision is sent; + - Ready displays the server-captured collaboration revision; team readiness stays workspace-scoped across reassignment; + - assignment/phase `409` refreshes authoritative exercise state and never replays stale readiness intent; + - Waiting, paused, debrief, and completed states expose no readiness mutation; + - deterministic browser acceptance proves a Team Alpha edit advances the shared Intake revision, Ready captures that revision, Resume Working clears readiness, and both writes use the stable Student session credential. +- Tranche 7C — frozen-debrief Student Intake read-only projection: + - implementation spans `8d70e84...` through `50435c3...`; + - a non-completed debrief with `studentEditingEnabled=false` and `editFreezeEnforced=true` projects the existing Student Intake surface read-only/disabled while the Tranche 5 HTTP **423 Locked** server guard remains the actual authorization boundary; + - original disabled/readOnly/tabindex/ARIA/contenteditable/draggable state is restored exactly when editing reopens, the exercise advances/completes, staged state disappears, or the Student session disconnects; + - paused frozen debrief remains read-only because Pause/Resume preserves edit policy; + - **Leave class** remains usable during a freeze; + - Notes are intentionally frozen with Intake because `notesWorkspace` is part of `collectAppState()` and therefore the collaborative snapshot; + - focused tests prove freeze/unfreeze and paused-freeze/disconnect restoration; + - integrated browser acceptance starts the staged exercise through the real Instructor console, enters the authored frozen debrief, proves Student Intake read-only, then **Allow editing** restores it through normal Student polling. +- Combined 7B/7C validation: repository quality passed on implementation head `50435c30e7d43f906eeff88d32265ba585fad079`; final browser/security gate evidence will be recorded on the documented head. +- Tranche 8A — Instructor immutable checkpoint inspection versus current live work: + - implementation spans `0ce4b8d...` through `8530128...`, with browser isolation/test stabilization through `04587bf...`; + - normal Instructor exercise reads now expose checkpoint **metadata only**; snapshot bytes are omitted from the console payload/public state; + - new GET-only `/api/classes/exercise/checkpoint?workspaceId=` requires the represented Instructor capability, re-resolves the current exercise/stage/class, validates staged-definition identity, and returns one current-stage debrief checkpoint snapshot only after explicit Instructor inspection; + - Student/join/assignment/unrelated authority cannot use the checkpoint read, and the endpoint never mints Student workspace authority or mutates collaboration state; + - the checkpoint list exposes **Inspect checkpoint** per captured workspace/revision during debrief; + - `classroomExerciseInstructor.js` fetches the snapshot transiently and passes it directly to `classroomInstructor.js`; snapshot bytes never enter exercise controller public state, Intake persistence/export/summary, URLs, or resume state; + - the existing Instructor observer renderer displays the checkpoint read-only with **Checkpoint at debrief start · Revision N** and **Immutable checkpoint · live updates paused**; + - live observer polling and coaching projection are paused while the immutable checkpoint is represented, preventing current-review actions from being accidentally applied to historical evidence; + - **View current live Intake** explicitly returns to the existing observer endpoint and restores current workspace revision/coaching context; + - selecting another live workspace also exits checkpoint mode rather than carrying a historical snapshot across teams; + - focused API/controller tests cover metadata-only normal reads, explicit snapshot authorization, no public-state snapshot leak, checkpoint-to-live restoration, and Student credential rejection; + - integrated browser acceptance now has a dedicated two-browser checkpoint-vs-live journey instead of overloading the long four-browser lifecycle journey; the shared deterministic integrated fixture is serialized within that file to prevent parallel fixture reset races. +- Tranche 8 now satisfies the planned checkpoint/debrief-view scope: immutable capture was implemented in earlier server tranches, Instructor facilitation prompts already render current-stage protected guidance, and 8A adds safe checkpoint inspection/comparison without overwriting Student work. +- Validation baseline for the 8A implementation: repository quality **302 tests / 301 pass / 0 fail / 1 intentional skip**. Final required Browser E2E/CodeQL evidence is taken from the final documented PR head. +- Tranche 9A — integrated staged security / late-join / reassignment acceptance: + - acceptance hardening spans `6391c39...` through `89a5321...`; browser-fixture/security additions span `523d245...` through `89a5321...`; + - the deterministic browser fixture now mirrors the production frozen-write boundary: a Classroom Student `PUT /api/workspaces/session` during a frozen debrief returns **423** with `code: classroom-editing-locked` before mutation; + - browser acceptance reads the workspace immediately before/after that rejected PUT and proves both collaboration revision and snapshot remain unchanged; + - the browser fixture now mirrors the production staged full-payload boundary: the Student protected Case Study POST cannot retrieve the synthetic staged Case Study while that staged exercise is active; + - raw Student staged reads are asserted directly with the stable Student class-session capability and contain current released Student material while excluding later-stage and Instructor-only facilitation; + - a Student joining after exercise Start is proved to enter the current Stage 1 work context and receive only the current cumulative Student release, never draft/future/Instructor material; + - reassignment during active work preserves the class exercise stage and stable staged-session authority while replacing only workspace Intake/edit authority; the old workspace token remains rejected; + - readiness is explicitly proved workspace-scoped: Ready on Team Alpha does not follow the Student to Team Beta, where the Student returns to Working at the same exercise stage; + - mobile Student acceptance expands the compact case-reference surface and runs the serious/critical axe scan while staged content is actually visible; + - a focused mobile Instructor acceptance opens the compact class rail, creates/starts the staged exercise, verifies current-stage facilitation/lifecycle controls, and runs the serious/critical axe scan; + - staged accessibility testing exposed two insufficient `#007aff`/white text uses in the Instructor exercise UI. Primary lifecycle buttons and Ready progress text now use the existing darker `--accent-text` token rather than changing the global accent; + - the checkpoint-vs-live browser proof now derives its captured collaboration revision from authoritative rendered checkpoint metadata instead of assuming a fixed revision, eliminating a serial-fixture retry flake without weakening the historical-vs-live assertions. +- Tranche 9 is complete. Validation on implementation head `89a5321a750606cff6fbf34ccf75ef40dc3def07`: repository quality **302 tests / 301 pass / 0 fail / 1 intentional skip**; required Browser E2E **28 passed / 10 intentional project-scoped skips / 0 failed / 0 flaky**; CI, CodeQL, Dependency Review, and Template Manifest Guard all green. +- Tranche 10 — final documentation, security, and merge-readiness audit: + - full diff audited from #312 merge base `b141d55050207e4cf7ce842a4452a220a5268968` through completed Tranche 9 head `b000ceab20d1c0bdc132cb0fede0aaa75c85fe17`: **198 commits / 46 changed files / 10,793 additions / 30 deletions** before final documentation reconciliation; + - README drift corrected: #313 is no longer described as below the HTTP/UI layer; it now documents the implemented Instructor/Student staged lifecycle, server-enforced freeze, checkpoint evidence, and continued separation from live Intake; + - Classroom API drift corrected: exercise persistence is no longer described as having no HTTP routes; it is explicitly the storage layer beneath the implemented endpoints; + - roadmap drift corrected: #313 is no longer described as just starting; Tranches 1–9 are complete and the branch is in final audit; + - authorization audit passed: Instructor lifecycle/checkpoint authority remains class-scoped; Student staged reads use only the stable Student class-session capability; collaboration edits remain assignment/workspace-authorized; Student credentials do not enumerate class workspaces; + - progressive-disclosure audit passed: normal Student staged responses exclude future-stage, Instructor-only, exemplar/model, and complete protected Case Study state; Student full protected payload is blocked for a staged case even after exercise completion; + - lifecycle integrity audit passed: Instructor mutations remain optimistic-revision protected; Advance follows server-authored stage order; readiness uses server-observed workspace revision; reassignment preserves class stage while stale workspace authority is revoked; + - debrief integrity audit passed: checkpoints are immutable class/workspace/stage-scoped evidence, normal exercise reads expose metadata only, explicit checkpoint reads are GET-only Instructor-authorized, and historical snapshots never replace live collaboration state; + - freeze audit passed: `classroom-student` PUT returns HTTP 423 `classroom-editing-locked` when frozen, rejected writes do not mutate snapshot/revision, and production SQL repeats the freeze predicate atomically; Standalone/primary collaboration remains unaffected; + - schema/retention audit passed: #313 tables are additive/idempotent, exercise rows inherit class expiry, foreign keys cascade with class/workspace cleanup, one-open-exercise constraint is scoped per class, and rollback can disable the staged feature without destructive schema removal; + - deployment/public-boundary audit passed: `src/templates.manifest.js` remains Standard-only, `api/protected-case-studies.manifest.js` remains server-only, authored `templates/*.json` stays excluded from Vercel source upload, and Vercel serves only generated `dist/`; + - production-content audit passed: no authored production Case Study contains a staged `simulation` definition; synthetic staged material remains deterministic test-fixture content only; + - persistence audit passed: exercise/session/checkpoint/capability state remains outside `kt-intake-full-v2`, templates, summaries, exports, and URLs; + - PR #315 has no unresolved review threads or reviews, is mergeable, and remains draft until this final documentation head passes all required gates. +- Final audit documentation reconciliation spans `65390fc...` through the Tranche 10 ledger commits. The last fully green implementation/security head before documentation-only cleanup is `b000ceab20d1c0bdc132cb0fede0aaa75c85fe17`: repository quality **302 tests / 301 pass / 0 fail / 1 intentional skip**; required Browser E2E **28 passed / 10 intentional project-scoped skips / 0 failed / 0 flaky**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- Exact next action: validate the final Tranche 10 documentation head through all required gates. If green, synchronize PR #315 / issue #313 final status and mark PR #315 **ready for review**. Do **not** merge automatically. + ## Approved Classroom follow-on direction The current #288 program establishes the secure Classroom foundation, but it is **not** the final Instructor-led teaching experience. @@ -433,3 +638,45 @@ Migration / external settings: Known risk: Exact next action: ``` + + +## #313 closeout and post-merge handoff + +PR #315 final audited head before closeout: `8974519108d6735383b8358373d005188501a785`. + +Final validated evidence on that head: +- repository quality: **302 tests / 301 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **28 passed / 10 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green; +- PR #315 has no unresolved review threads and is mergeable. + +What #313 completes: +- secure class-level staged exercise lifecycle and persistence; +- Instructor orchestration, protected facilitation, Ready/progress, debrief freeze, checkpoints, Advance/Complete; +- Student current-stage case reference, Ready/Resume, frozen-debrief read-only projection, automatic stage transitions; +- late join/reassignment safety, stale-authority cutoff, progressive-disclosure/full-payload denial, mobile/a11y acceptance; +- checkpoint-vs-current-live facilitation without replacing Student work. + +What remains intentionally outside #313: +1. **#316 — protected staged assets/rich rendering (P0 blocker).** + - Schema accepts `image`, `table`, and `document-page`, but Student runtime currently retains/renders only `title + body`. + - Real protected case pages/assets therefore need a server-gated asset path and rich accessible renderer before official production cases can be staged faithfully. +2. **#317 — first production staged KT Case Study.** + - No production official Case Study currently carries a `simulation` definition. + - Staging must come from authoritative supplied participant/instructor sources and be explicitly reviewed; never infer it from completed Intake state. +3. **#318 — stage-linked Intake guidance + target identity.** + - `intakeTargetIds` exists in the authoring schema but is not used in Student/Instructor runtime. + - Dynamic Possible Cause coaching remains the explicit #294 deferred target-identity gap. +4. **#319 — class-level debrief comparison.** + - #313 supports one-team-at-a-time checkpoint/live inspection; it does not yet provide a cross-team selected-field/heatmap comparison surface for large-class debrief. +5. **#320 — join/mobile polish.** + - Human join code + Copy Code exists; QR/share link remains deferred. + - Notes/Class/Case mobile chrome still needs the broader compact-launcher alignment called out during #313. + +Cold-start priority after #315 merges: +1. read `AGENTS.md`, `docs/classroom-roadmap.md`, `docs/classroom-architecture.md`, `docs/classroom-api.md`, and `docs/classroom-staged-simulation.md`; +2. inspect #316 first; +3. preserve all #313 security boundaries while adding protected rich content; +4. do **not** add an official production `simulation` definition until #316 is complete and the source material for #317 is present/reviewed. + +The #288 foundation roadmap plus #312/#313 orchestration generation are complete after #315 merges. The next program phase is **production case enablement and facilitator scale**, beginning with #316. diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md index 0b11968..8c7e4af 100644 --- a/docs/commenting-guide.md +++ b/docs/commenting-guide.md @@ -48,7 +48,9 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist | `[feature:experience-role-switch]` | `index.html`, View menu | Reopens the experience chooser without resetting Intake data. | `src/experienceRoleController.js`, `src/menuBar.js` | `experienceRoleMenuBtn`, `data-experience-role-label`; local-only, summary-excluded. | | `[feature:student-class-entry]` | `index.html`, between role chooser and Instructor shell | Student class join/reconnect gate shown before the assigned Intake is available. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; all local-only/summary-excluded. | | `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; no persisted Intake fields. | -| `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, roster search/filter/list, Leave class; all class/session controls are local-only and summary-excluded. | +| `[feature:student-case-reference]` | `index.html`, between Classroom shells and the connected Intake | Student-only staged Case Study reference for the current exercise, using only Student-authorized released material. | `src/classroomExerciseStudent.js`, `src/classroomStudent.js` session lifecycle | `studentCaseReference`, title/status/current stage/objective, released-content list, frozen-debrief message, Collapse/Open control; stable Student class-session capability and staged content remain memory-only, future/Instructor content is not projected, mobile defaults collapsed, and the surface is summary/persistence-excluded. | +| `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail, including compact collapse/expand behavior. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, `instructorClassPanelToggle`, roster search/filter/list, Leave class; narrow screens default collapsed but retain an explicit Open class panel control; all class/session controls are local-only and summary-excluded. | +| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Instructor staged-exercise discovery, pacing, evidence release, debrief/editing policy, authored stage advancement/completion, checkpoint status, and team progress within the existing class rail. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, staged selector/Create draft, Start/Pause/Resume/Begin debrief/Advance/Complete, current stage/objective/timing/facilitation, optional evidence release, Ready/Working rows, checkpoint workspace/revision status, Freeze/Allow Editing, existing observer navigation, availability list, Refresh; terminal completion releases no additional Student/exemplar material, checkpoint snapshots are not projected into public controller state, and all exercise context is memory-only/summary-excluded. | | `[feature:instructor-observer]` | `index.html`, top of the temporarily revealed Intake wrap | Live read-only context for the selected Student/team workspace, including revision and recent activity. Dynamic coaching panels are appended beside registered targets inside this same observed Intake; they do not introduce a new structural HTML anchor. | `src/classroomInstructor.js`, `src/classroomCoaching.js`, `src/coachableFields.js` | `instructorObservationNotice`, observer metadata, plus `.classroom-coaching--instructor` / `.classroom-coaching--student` dynamic local-only panels. Observed Student state and coaching UI are excluded from Intake persistence/summary capture. | | `[feature:intake-mode-selector]` | `index.html` header title | Wraps the accessible intake mode selector used to choose between General, IT, Pharma, and Major Incident Management lenses before capture. | `src/intakeModes.js`, `src/intakeModeController.js` | `intakeModeSelect`; options use stable mode values and persist under `meta.intakeMode` in `kt-intake-full-v2`. | | `[feature:major-incident-role-legend]` | `index.html`, below the header | Provides the Major Incident-only four-phase legend mount; phase/primary-role badges decorate mapped workflow headings (including Decision and Risk gates) and every KT row, then hide without unmounting data when the mode changes. Icons and colour swatches are decorative because visible and accessible role text carries their meaning. | `src/majorIncidentRoles.js`, `src/intakeModes.js` | `majorIncidentRoleLegend`, `majorIncidentRoleLegendItems`; role presentation is not persisted, while the decorated controls retain their normal field contracts. | diff --git a/index.html b/index.html index 389a0f9..194d703 100644 --- a/index.html +++ b/index.html @@ -403,12 +403,97 @@

Class

+
+ + + + +
@@ -454,6 +539,35 @@

Class

+ + + +
+
    diff --git a/main.js b/main.js index 484ac97..9625034 100644 --- a/main.js +++ b/main.js @@ -63,6 +63,8 @@ import { initStudentClassroom } from './src/classroomStudent.js'; import { initInstructorClassroom } from './src/classroomInstructor.js'; import { initClassroomCoaching } from './src/classroomCoaching.js'; import { initClassroomCaseStudies } from './src/classroomCaseStudies.js'; +import { initInstructorExerciseConsole } from './src/classroomExerciseInstructor.js'; +import { initStudentExerciseReference } from './src/classroomExerciseStudent.js'; /** Active shared-session controller, initialized during boot. @type {object|null} */ let collaborationController = null; @@ -70,9 +72,15 @@ let studentClassroomController = null; let instructorClassroomController = null; let classroomCoachingController = null; let classroomCaseStudiesController = null; +let instructorExerciseConsoleController = null; +let studentExerciseReferenceController = null; /** Destroys collaboration resources during application or test teardown. @returns {void} */ export function destroyCollaboration() { + studentExerciseReferenceController?.destroy?.(); + studentExerciseReferenceController = null; + instructorExerciseConsoleController?.destroy?.(); + instructorExerciseConsoleController = null; classroomCaseStudiesController?.destroy?.(); classroomCaseStudiesController = null; classroomCoachingController?.destroy?.(); @@ -199,6 +207,15 @@ function boot() { initPreface({ onSave: saveAppState }); initCommsDrawer(); classroomCaseStudiesController = initClassroomCaseStudies({ windowRef: window }); + instructorExerciseConsoleController = initInstructorExerciseConsole({ + documentRef: document, + onSelectWorkspace: workspaceId => instructorClassroomController?.selectWorkspace?.(workspaceId), + onInspectCheckpoint: context => instructorClassroomController?.inspectCheckpoint?.(context) + }); + studentExerciseReferenceController = initStudentExerciseReference({ + documentRef: document, + windowRef: window + }); initTemplatesDrawer({ protectedCaseStudies: classroomCaseStudiesController }); initNotesWorkspace({ onSave: saveAppState, showToast }); initializeCommunications({ onSave: saveAppState, showToast }); @@ -245,6 +262,12 @@ function boot() { onClassDisconnected: () => { classroomCoachingController?.disconnectStudent?.(); classroomCaseStudiesController?.disconnect?.(); + }, + onSessionConnected: token => { + void studentExerciseReferenceController?.connectStudent?.(token); + }, + onSessionDisconnected: () => { + studentExerciseReferenceController?.disconnect?.(); } }); instructorClassroomController = initInstructorClassroom({ @@ -254,8 +277,14 @@ function boot() { toast: showToast, onObservation: context => classroomCoachingController?.showInstructorWorkspace?.(context), onObservationEnd: () => classroomCoachingController?.hideInstructorWorkspace?.(), - onClassConnected: token => { void classroomCaseStudiesController?.connectInstructor?.(token); }, - onClassDisconnected: () => classroomCaseStudiesController?.disconnect?.() + onClassConnected: token => { + void classroomCaseStudiesController?.connectInstructor?.(token); + void instructorExerciseConsoleController?.connectInstructor?.(token); + }, + onClassDisconnected: () => { + classroomCaseStudiesController?.disconnect?.(); + instructorExerciseConsoleController?.disconnect?.(); + } }); wireThemeToggle(); wireSummaryEvents(); diff --git a/scripts/build-templates-manifest.mjs b/scripts/build-templates-manifest.mjs index 76668de..c9e85c8 100755 --- a/scripts/build-templates-manifest.mjs +++ b/scripts/build-templates-manifest.mjs @@ -8,6 +8,10 @@ import { fileURLToPath } from 'node:url'; import { TEMPLATE_MODE_IDS } from '../src/templateModes.js'; import { TEMPLATE_KINDS } from '../src/templateKinds.js'; +import { + normalizeStagedSimulation, + validateSimulationForResource +} from './staged-simulation-schema.mjs'; const templatesDir = new URL('../templates/', import.meta.url); const publicManifestPath = new URL('../src/templates.manifest.js', import.meta.url); @@ -156,7 +160,7 @@ async function readTemplateFile(fileName) { if (!isRecord(data)) { throw new Error(`${fileName} must contain a JSON object`); } - const { id, name, description, supportedModes, templateKind, state } = data; + const { id, name, description, supportedModes, templateKind, state, simulation } = data; if (typeof id !== 'string' || !id.trim()) { errors.push('id must be a non-empty string'); } @@ -179,6 +183,10 @@ async function readTemplateFile(fileName) { } const stateErrors = validateSerializedAppState(state); errors.push(...stateErrors.map(message => `state invalid: ${message}`)); + + const simulationErrors = validateSimulationForResource({ templateKind, simulation }); + errors.push(...simulationErrors.map(message => `simulation invalid: ${message}`)); + if (errors.length) { throw new Error(`${fileName} failed validation:\n- ${errors.join('\n- ')}`); } @@ -188,7 +196,8 @@ async function readTemplateFile(fileName) { description: description.trim(), supportedModes: supportedModes.map(mode => mode.trim()), templateKind, - state + state, + ...(simulation === undefined ? {} : { simulation: normalizeStagedSimulation(simulation) }) }; } diff --git a/scripts/serve-browser-tests.mjs b/scripts/serve-browser-tests.mjs index 80121c5..46f120d 100644 --- a/scripts/serve-browser-tests.mjs +++ b/scripts/serve-browser-tests.mjs @@ -48,6 +48,178 @@ const STUDENT_LIVE_CLASS = Object.freeze({ title: 'Browser Student Live Classroom', expiresAt: CLASSROOM_EXPIRY }); +const BROWSER_STAGED_CASE = Object.freeze({ + id: 'browser-staged-simulation', + name: 'Browser Staged Simulation', + description: 'Synthetic staged Case Study used only by deterministic browser acceptance.', + supportedModes: ['full'], + simulation: { + version: 1, + studentContent: [ + { + id: 'browser-brief-1', + kind: 'narrative', + title: 'Initial browser briefing', + body: 'Synthetic Student-safe browser briefing.' + }, + { + id: 'browser-hint-1', + kind: 'evidence', + title: 'Optional browser evidence', + body: 'Synthetic optional Student evidence released only by the Instructor.' + }, + { + id: 'browser-brief-2', + kind: 'narrative', + title: 'Second browser briefing', + body: 'Synthetic Student-safe content authored for the second browser stage.' + } + ], + instructorContent: [ + { + id: 'browser-teach-1', + kind: 'facilitation', + title: 'Browser facilitation note', + body: 'Synthetic Instructor-only browser facilitation.' + }, + { + id: 'browser-teach-2', + kind: 'facilitation', + title: 'Second-stage browser facilitation', + body: 'Synthetic Instructor-only guidance for the final browser stage.' + } + ], + stages: [ + { + id: 'browser-stage-1', + title: 'Clarify the browser case', + studentObjective: 'Capture the initial situation in Intake.', + initialReleaseIds: ['browser-brief-1'], + optionalReleaseIds: ['browser-hint-1'], + intakeTargetIds: ['problem.one-line'], + suggestedMinutes: 5, + instructorContentIds: ['browser-teach-1'], + defaultDebriefEditPolicy: 'frozen' + }, + { + id: 'browser-stage-2', + title: 'Analyze the browser case', + studentObjective: 'Use the second-stage information to refine the analysis.', + initialReleaseIds: ['browser-brief-2'], + optionalReleaseIds: [], + intakeTargetIds: ['kt.where-location'], + suggestedMinutes: 4, + instructorContentIds: ['browser-teach-2'], + defaultDebriefEditPolicy: 'open' + } + ] + } +}); +function browserStudentReleasedContent(exercise, releases = []) { + const stageIndex = BROWSER_STAGED_CASE.simulation.stages.findIndex( + stage => stage.id === exercise?.currentStageId + ); + if (stageIndex < 0 || exercise?.status === 'draft') return []; + const blocks = new Map(BROWSER_STAGED_CASE.simulation.studentContent.map(item => [item.id, item])); + const releaseMap = new Map(); + releases.forEach(item => { + const items = releaseMap.get(item.stageId) || []; + items.push(item); + releaseMap.set(item.stageId, items); + }); + const result = []; + const seen = new Set(); + const append = (stageId, contentId, releaseType, releasedAt = null) => { + if (seen.has(contentId)) return; + const content = blocks.get(contentId); + if (!content) return; + seen.add(contentId); + result.push({ + stageId, + releaseType, + releasedAt, + content: structuredClone(content) + }); + }; + BROWSER_STAGED_CASE.simulation.stages.slice(0, stageIndex + 1).forEach(stage => { + stage.initialReleaseIds.forEach(contentId => append(stage.id, contentId, 'initial')); + const allowed = new Set(stage.optionalReleaseIds); + (releaseMap.get(stage.id) || []).forEach(item => { + if (allowed.has(item.contentId)) append(stage.id, item.contentId, 'optional', item.releasedAt || null); + }); + }); + return result; +} + +function browserStudentExercisePayload(session) { + const classContext = session.classContext || STUDENT_LIVE_CLASS; + const participant = { + id: session.participantId, + displayName: session.displayName + }; + const assignment = session.assignment ? structuredClone(session.assignment) : null; + let exercise; + let releases; + if (session.integrated) { + exercise = classroomExercises.get(INTEGRATED_INSTRUCTOR_TOKEN) || null; + releases = classroomExerciseReleases.get(INTEGRATED_INSTRUCTOR_TOKEN) || []; + } else { + exercise = { + id: 'browser-student-live-exercise', + caseStudyId: BROWSER_STAGED_CASE.id, + status: 'active', + stagePhase: 'work', + exerciseRevision: 2, + studentEditingEnabled: true, + currentStageId: BROWSER_STAGED_CASE.simulation.stages[0].id + }; + releases = [{ + stageId: BROWSER_STAGED_CASE.simulation.stages[0].id, + contentId: 'browser-hint-1', + releasedAt: '2099-12-31T23:31:00.000Z' + }]; + } + if (!exercise) return { class: classContext, participant, assignment, exercise: null }; + const stage = BROWSER_STAGED_CASE.simulation.stages.find( + item => item.id === exercise.currentStageId + ) || null; + const readinessKey = assignment && stage + ? `${assignment.id}:${stage.id}` + : ''; + const readiness = readinessKey + ? session.exerciseReadiness?.get(readinessKey) || null + : null; + return { + class: classContext, + participant, + assignment, + exercise: { + id: exercise.id, + caseStudyId: BROWSER_STAGED_CASE.id, + caseStudy: { + id: BROWSER_STAGED_CASE.id, + name: BROWSER_STAGED_CASE.name, + description: BROWSER_STAGED_CASE.description, + supportedModes: structuredClone(BROWSER_STAGED_CASE.supportedModes) + }, + status: exercise.status, + stagePhase: exercise.stagePhase, + exerciseRevision: exercise.exerciseRevision, + studentEditingEnabled: exercise.studentEditingEnabled !== false, + editFreezeEnforced: true, + currentStage: stage + ? { + id: stage.id, + title: stage.title, + studentObjective: stage.studentObjective + } + : null, + releasedContent: browserStudentReleasedContent(exercise, releases), + readiness: readiness ? structuredClone(readiness) : null + } + }; +} + let liveClassState = null; let liveInstructorWorkspaces = []; let liveInstructorParticipants = []; @@ -61,6 +233,9 @@ const studentLiveSessions = new Map(); const studentLiveAccessContexts = new Map(); const classroomWorkspaces = new Map(); const classroomCoachingFeedback = new Map(); +const classroomExercises = new Map(); +const classroomExerciseReleases = new Map(); +const classroomExerciseCheckpoints = new Map(); const CONTENT_TYPES = Object.freeze({ '.css': 'text/css; charset=utf-8', @@ -179,6 +354,28 @@ function getWorkspace(workspaceToken) { return classroomWorkspaces.get(workspaceToken) || null; } +function studentLiveSessionForWorkspaceToken(workspaceToken) { + for (const session of studentLiveSessions.values()) { + if (session?.activeWorkspaceToken === workspaceToken) return session; + } + return null; +} + +function frozenStudentExerciseForWorkspaceToken(workspaceToken) { + const session = studentLiveSessionForWorkspaceToken(workspaceToken); + if (!session) return null; + const exercise = browserStudentExercisePayload(session).exercise; + if ( + exercise + && exercise.status !== 'completed' + && exercise.stagePhase === 'debrief' + && exercise.studentEditingEnabled === false + ) { + return exercise; + } + return null; +} + function ensureWorkspace(workspaceToken) { let workspace = getWorkspace(workspaceToken); if (!workspace) { @@ -317,6 +514,45 @@ function managedInstructorParticipantRoster(token) { }; } +function browserExerciseWorkspaceState(token, exercise) { + if (!exercise?.currentStageId) return []; + const fixture = managedInstructorFixture(token); + const workspaces = fixture?.workspaces || instructorRoster().workspaces; + return workspaces.map((workspace, index) => { + const observed = fixture?.workspaceStates?.get(workspace.id); + const ready = index === 0; + return { + workspaceId: workspace.id, + workspaceKind: workspace.kind, + workspaceLabel: workspace.label, + stageId: exercise.currentStageId, + readyForDebrief: ready, + readyAt: ready ? '2099-12-31T23:30:00.000Z' : null, + readyWorkspaceRevision: ready ? (observed?.revision || 1) : null, + createdAt: '2099-12-31T23:29:00.000Z', + updatedAt: '2099-12-31T23:30:00.000Z' + }; + }); +} + +function captureBrowserExerciseCheckpoints(token, exercise) { + if (!exercise?.currentStageId) return []; + const fixture = managedInstructorFixture(token); + const workspaces = fixture?.workspaces || instructorRoster().workspaces; + return workspaces.map(workspace => { + const observed = fixture?.workspaceStates?.get(workspace.id); + return { + workspaceId: workspace.id, + workspaceKind: workspace.kind, + workspaceLabel: workspace.label, + stageId: exercise.currentStageId, + workspaceRevision: observed?.revision || 1, + snapshot: structuredClone(observed?.snapshot || freshClassroomSnapshot()), + capturedAt: '2099-12-31T23:35:00.000Z' + }; + }); +} + function managedInstructorObservation(token, workspaceId) { const fixture = managedInstructorFixture(token); if (!fixture) return null; @@ -466,7 +702,8 @@ async function handleClassroomApi(request, response, url) { integrated: true, statusReads: 0, accessCount: 0, - activeWorkspaceToken: '' + activeWorkspaceToken: '', + exerciseReadiness: new Map() }; integratedInstructorParticipants.push({ id: participantId, @@ -511,7 +748,8 @@ async function handleClassroomApi(request, response, url) { ]), statusReads: 0, accessCount: 0, - activeWorkspaceToken: '' + activeWorkspaceToken: '', + exerciseReadiness: new Map() }; } else { sendJson(response, 404, { error: 'Class not available.' }); @@ -547,6 +785,76 @@ async function handleClassroomApi(request, response, url) { return true; } + if (url.pathname === '/api/classes/exercise/student') { + if (request.method !== 'GET') { + sendJson(response, 405, { error: 'Method not allowed.' }); + return true; + } + const studentSessionToken = bearerToken(request); + const session = studentLiveSessions.get(studentSessionToken); + if (!session) { + sendJson(response, 404, { error: 'Student class session not found.' }); + return true; + } + sendJson(response, 200, browserStudentExercisePayload(session)); + return true; + } + + if (url.pathname === '/api/classes/exercise/student/ready') { + if (request.method !== 'PUT') { + sendJson(response, 405, { error: 'Method not allowed.' }); + return true; + } + const studentSessionToken = bearerToken(request); + const session = studentLiveSessions.get(studentSessionToken); + if (!session) { + sendJson(response, 404, { error: 'Student class session not found.' }); + return true; + } + const body = await readJson(request).catch(() => null); + if (typeof body?.ready !== 'boolean') { + sendJson(response, 400, { error: 'Invalid readiness state.' }); + return true; + } + + const current = browserStudentExercisePayload(session); + const exercise = current.exercise; + if (!exercise?.currentStage?.id || exercise.status !== 'active' || exercise.stagePhase !== 'work') { + sendJson(response, 409, { error: 'Readiness is not available in the current exercise phase.' }); + return true; + } + if (!session.assignment) { + sendJson(response, 409, { status: 'waiting', error: 'Assign a workspace before marking Ready.' }); + return true; + } + + const workspace = session.workspaceStates.get(session.assignment.id); + if (!workspace) { + sendJson(response, 404, { error: 'Assigned workspace not found.' }); + return true; + } + + const key = `${session.assignment.id}:${exercise.currentStage.id}`; + const previous = session.exerciseReadiness.get(key) || null; + const readiness = { + stageId: exercise.currentStage.id, + readyForDebrief: body.ready, + readyAt: body.ready ? '2099-12-31T23:32:00.000Z' : null, + readyWorkspaceRevision: body.ready ? workspace.revision : null + }; + session.exerciseReadiness.set(key, readiness); + sendJson(response, 200, { + class: current.class, + participant: current.participant, + assignment: structuredClone(session.assignment), + readiness: structuredClone(readiness), + changed: !previous + || previous.readyForDebrief !== readiness.readyForDebrief + || previous.readyWorkspaceRevision !== readiness.readyWorkspaceRevision + }); + return true; + } + if (url.pathname === '/api/classes/student/access') { if (request.method !== 'POST') { sendJson(response, 405, { error: 'Method not allowed.' }); @@ -606,6 +914,9 @@ async function handleClassroomApi(request, response, url) { integratedInstructorWorkspaces = []; integratedInstructorWorkspaceStates.clear(); integratedInstructorParticipants = []; + classroomExercises.delete(INTEGRATED_INSTRUCTOR_TOKEN); + classroomExerciseReleases.delete(INTEGRATED_INSTRUCTOR_TOKEN); + classroomExerciseCheckpoints.delete(INTEGRATED_INSTRUCTOR_TOKEN); sendJson(response, 201, { class: integratedInstructorClass(), instructorToken: INTEGRATED_INSTRUCTOR_TOKEN, @@ -623,6 +934,9 @@ async function handleClassroomApi(request, response, url) { }; liveInstructorWorkspaces = []; liveInstructorWorkspaceStates.clear(); + classroomExercises.delete(LIVE_INSTRUCTOR_TOKEN); + classroomExerciseReleases.delete(LIVE_INSTRUCTOR_TOKEN); + classroomExerciseCheckpoints.delete(LIVE_INSTRUCTOR_TOKEN); liveInstructorParticipants = [{ id: LIVE_PARTICIPANT_ID, displayName: 'Waiting Student', @@ -640,6 +954,358 @@ async function handleClassroomApi(request, response, url) { return true; } + if (url.pathname === '/api/classes/exercise/checkpoint') { + if (request.method !== 'GET') { + sendJson(response, 405, { error: 'Method not allowed.' }); + return true; + } + const instructorToken = bearerToken(request); + if (!activeInstructorCapability(instructorToken)) { + sendJson(response, 401, { error: 'Missing or invalid instructor authorization.' }); + return true; + } + const exercise = classroomExercises.get(instructorToken) || null; + if (!exercise) { + sendJson(response, 404, { error: 'Exercise checkpoint not found.' }); + return true; + } + if (exercise.stagePhase !== 'debrief' || !exercise.currentStageId) { + sendJson(response, 409, { error: 'Checkpoint inspection is available during debrief.' }); + return true; + } + const workspaceId = url.searchParams.get('workspaceId') || ''; + const checkpoint = (classroomExerciseCheckpoints.get(instructorToken) || []).find(item => ( + item.workspaceId === workspaceId && item.stageId === exercise.currentStageId + )); + if (!checkpoint) { + sendJson(response, 404, { error: 'Exercise checkpoint not found.' }); + return true; + } + const managedFixture = managedInstructorFixture(instructorToken); + const classContext = managedFixture?.classContext || instructorClass(); + sendJson(response, 200, { + class: classContext, + exercise: { + id: exercise.id, + currentStageId: exercise.currentStageId, + stagePhase: exercise.stagePhase, + exerciseRevision: exercise.exerciseRevision + }, + workspace: { + id: checkpoint.workspaceId, + kind: checkpoint.workspaceKind, + label: checkpoint.workspaceLabel + }, + checkpoint: { + stageId: checkpoint.stageId, + workspaceRevision: checkpoint.workspaceRevision, + capturedAt: checkpoint.capturedAt, + snapshot: structuredClone(checkpoint.snapshot) + } + }); + return true; + } + + if (url.pathname === '/api/classes/exercise') { + const instructorToken = bearerToken(request); + if (!activeInstructorCapability(instructorToken)) { + sendJson(response, 401, { error: 'Missing or invalid instructor authorization.' }); + return true; + } + + const managedFixture = managedInstructorFixture(instructorToken); + const classContext = managedFixture?.classContext || instructorClass(); + const availableCaseStudies = [{ + id: BROWSER_STAGED_CASE.id, + name: BROWSER_STAGED_CASE.name, + description: BROWSER_STAGED_CASE.description, + supportedModes: [...BROWSER_STAGED_CASE.supportedModes] + }]; + const exerciseReleases = () => structuredClone(classroomExerciseReleases.get(instructorToken) || []); + const exerciseProgress = exercise => structuredClone( + browserExerciseWorkspaceState(instructorToken, exercise) + ); + const exerciseCheckpoints = () => ( + classroomExerciseCheckpoints.get(instructorToken) || [] + ).map(item => ({ + workspaceId: item.workspaceId, + workspaceKind: item.workspaceKind, + workspaceLabel: item.workspaceLabel, + stageId: item.stageId, + workspaceRevision: item.workspaceRevision, + capturedAt: item.capturedAt + })); + + if (request.method === 'GET') { + const exercise = classroomExercises.get(instructorToken) || null; + sendJson(response, 200, exercise + ? { + class: classContext, + exercise: structuredClone(exercise), + caseStudy: structuredClone(BROWSER_STAGED_CASE), + releases: exerciseReleases(), + workspaceState: exerciseProgress(exercise), + checkpoints: exerciseCheckpoints(), + editFreezeEnforced: true, + availableCaseStudies + } + : { + class: classContext, + exercise: null, + availableCaseStudies + }); + return true; + } + + if (request.method === 'POST') { + const body = await readJson(request).catch(() => null); + const caseStudyId = typeof body?.caseStudyId === 'string' ? body.caseStudyId.trim() : ''; + if (caseStudyId !== BROWSER_STAGED_CASE.id) { + sendJson(response, 404, { error: 'Staged Case Study not found.' }); + return true; + } + + const existing = classroomExercises.get(instructorToken); + if (existing) { + if (existing.caseStudyId !== caseStudyId) { + sendJson(response, 409, { + error: 'Another exercise is already open for this class.', + exercise: structuredClone(existing) + }); + return true; + } + sendJson(response, 200, { + class: classContext, + exercise: structuredClone(existing), + caseStudy: structuredClone(BROWSER_STAGED_CASE), + releases: exerciseReleases(), + workspaceState: exerciseProgress(existing), + checkpoints: exerciseCheckpoints(), + editFreezeEnforced: true, + created: false + }); + return true; + } + + const exercise = { + id: `browser-exercise-${instructorToken.slice(-1)}`, + caseStudyId, + status: 'draft', + currentStageId: null, + stagePhase: 'work', + studentEditingEnabled: true, + exerciseRevision: 1, + simulationVersion: BROWSER_STAGED_CASE.simulation.version, + simulationFingerprint: 'b'.repeat(64) + }; + classroomExercises.set(instructorToken, exercise); + classroomExerciseReleases.set(instructorToken, []); + classroomExerciseCheckpoints.set(instructorToken, []); + sendJson(response, 201, { + class: classContext, + exercise: structuredClone(exercise), + caseStudy: structuredClone(BROWSER_STAGED_CASE), + releases: exerciseReleases(), + workspaceState: exerciseProgress(exercise), + checkpoints: exerciseCheckpoints(), + editFreezeEnforced: true, + created: true + }); + return true; + } + + if (request.method === 'PATCH') { + const body = await readJson(request).catch(() => null); + const exercise = classroomExercises.get(instructorToken) || null; + if (!exercise) { + sendJson(response, 404, { error: 'Exercise not found.' }); + return true; + } + + if (body?.action === 'release-content') { + const contentId = typeof body.contentId === 'string' ? body.contentId.trim() : ''; + const stage = BROWSER_STAGED_CASE.simulation.stages.find(item => item.id === exercise.currentStageId); + if ( + exercise.status !== 'active' + || exercise.stagePhase !== 'work' + || !stage + || !stage.optionalReleaseIds.includes(contentId) + ) { + sendJson(response, 400, { error: 'Invalid current-stage content release.' }); + return true; + } + + const releases = classroomExerciseReleases.get(instructorToken) || []; + const existingRelease = releases.find(item => ( + item.stageId === stage.id && item.contentId === contentId + )); + if (existingRelease) { + sendJson(response, 200, { + class: classContext, + exercise: structuredClone(exercise), + caseStudy: structuredClone(BROWSER_STAGED_CASE), + releases: exerciseReleases(), + workspaceState: exerciseProgress(exercise), + checkpoints: exerciseCheckpoints(), + editFreezeEnforced: true, + changed: false + }); + return true; + } + + if (!Number.isInteger(body.expectedRevision) || body.expectedRevision !== exercise.exerciseRevision) { + sendJson(response, 409, { + error: 'Exercise changed. Refresh and retry.', + exercise: structuredClone(exercise) + }); + return true; + } + + releases.push({ + stageId: stage.id, + contentId, + releasedAt: '2099-12-31T23:31:00.000Z' + }); + classroomExerciseReleases.set(instructorToken, releases); + exercise.exerciseRevision += 1; + classroomExercises.set(instructorToken, exercise); + sendJson(response, 200, { + class: classContext, + exercise: structuredClone(exercise), + caseStudy: structuredClone(BROWSER_STAGED_CASE), + releases: exerciseReleases(), + workspaceState: exerciseProgress(exercise), + checkpoints: exerciseCheckpoints(), + editFreezeEnforced: true, + changed: true + }); + return true; + } + + if (!Number.isInteger(body?.expectedRevision) || body.expectedRevision !== exercise.exerciseRevision) { + sendJson(response, 409, { + error: 'Exercise changed. Refresh and retry.', + exercise: structuredClone(exercise) + }); + return true; + } + + let capturedCount; + if (body.action === 'start') { + if (exercise.status !== 'draft' || exercise.currentStageId !== null) { + sendJson(response, 409, { + error: 'Exercise cannot be started from its current state.', + exercise: structuredClone(exercise) + }); + return true; + } + exercise.status = 'active'; + exercise.currentStageId = BROWSER_STAGED_CASE.simulation.stages[0].id; + exercise.stagePhase = 'work'; + exercise.studentEditingEnabled = true; + } else if (body.action === 'pause') { + if (exercise.status !== 'active') { + sendJson(response, 409, { + error: 'Exercise is not active.', + exercise: structuredClone(exercise) + }); + return true; + } + exercise.status = 'paused'; + } else if (body.action === 'resume') { + if (exercise.status !== 'paused') { + sendJson(response, 409, { + error: 'Exercise is not paused.', + exercise: structuredClone(exercise) + }); + return true; + } + exercise.status = 'active'; + } else if (body.action === 'begin-debrief') { + if (exercise.status !== 'active' || exercise.stagePhase !== 'work' || !exercise.currentStageId) { + sendJson(response, 409, { + error: 'Exercise is not ready to enter debrief.', + exercise: structuredClone(exercise) + }); + return true; + } + const stage = BROWSER_STAGED_CASE.simulation.stages.find(item => item.id === exercise.currentStageId); + const checkpoints = captureBrowserExerciseCheckpoints(instructorToken, exercise); + const priorCheckpoints = classroomExerciseCheckpoints.get(instructorToken) || []; + classroomExerciseCheckpoints.set(instructorToken, [ + ...priorCheckpoints.filter(item => item.stageId !== exercise.currentStageId), + ...checkpoints + ]); + capturedCount = checkpoints.length; + exercise.stagePhase = 'debrief'; + exercise.studentEditingEnabled = stage?.defaultDebriefEditPolicy !== 'frozen'; + } else if (body.action === 'set-editing') { + if ( + exercise.status !== 'active' + || exercise.stagePhase !== 'debrief' + || typeof body.enabled !== 'boolean' + ) { + sendJson(response, 400, { error: 'Invalid debrief editing policy.' }); + return true; + } + exercise.studentEditingEnabled = body.enabled; + } else if (body.action === 'advance') { + const currentIndex = BROWSER_STAGED_CASE.simulation.stages.findIndex( + item => item.id === exercise.currentStageId + ); + const nextStage = currentIndex >= 0 + ? BROWSER_STAGED_CASE.simulation.stages[currentIndex + 1] + : null; + if (exercise.status !== 'active' || exercise.stagePhase !== 'debrief' || !nextStage) { + sendJson(response, 409, { + error: 'Exercise is not ready to advance.', + exercise: structuredClone(exercise) + }); + return true; + } + exercise.currentStageId = nextStage.id; + exercise.stagePhase = 'work'; + exercise.studentEditingEnabled = true; + } else if (body.action === 'complete') { + const finalStage = BROWSER_STAGED_CASE.simulation.stages.at(-1); + if ( + exercise.status !== 'active' + || exercise.stagePhase !== 'debrief' + || exercise.currentStageId !== finalStage?.id + ) { + sendJson(response, 409, { + error: 'Exercise cannot be completed from its current state.', + exercise: structuredClone(exercise) + }); + return true; + } + exercise.status = 'completed'; + exercise.studentEditingEnabled = true; + } else { + sendJson(response, 400, { error: 'Invalid exercise action.' }); + return true; + } + + exercise.exerciseRevision += 1; + classroomExercises.set(instructorToken, exercise); + sendJson(response, 200, { + class: classContext, + exercise: structuredClone(exercise), + caseStudy: structuredClone(BROWSER_STAGED_CASE), + releases: exerciseReleases(), + workspaceState: exerciseProgress(exercise), + checkpoints: exerciseCheckpoints(), + editFreezeEnforced: true, + changed: true, + ...(Number.isInteger(capturedCount) ? { capturedCount } : {}) + }); + return true; + } + + sendJson(response, 405, { error: 'Method not allowed.' }); + return true; + } + if (url.pathname === '/api/classes/workspaces') { const instructorToken = bearerToken(request); if (!activeInstructorCapability(instructorToken)) { @@ -926,6 +1592,23 @@ async function handleClassroomApi(request, response, url) { } if (request.method === 'PUT') { + const frozenExercise = frozenStudentExerciseForWorkspaceToken(workspaceToken); + if (frozenExercise) { + sendJson(response, 423, { + error: 'Student editing is frozen during debrief.', + code: 'classroom-editing-locked', + exercise: { + id: frozenExercise.id, + status: frozenExercise.status, + stagePhase: frozenExercise.stagePhase, + exerciseRevision: frozenExercise.exerciseRevision, + studentEditingEnabled: false, + currentStageId: frozenExercise.currentStage?.id || null + } + }); + return true; + } + let body; try { body = await readJson(request); @@ -1030,6 +1713,18 @@ async function handleClassroomApi(request, response, url) { if (request.method === 'POST') { const body = await readJson(request).catch(() => null); const caseStudyId = typeof body?.caseStudyId === 'string' ? body.caseStudyId.trim() : ''; + + if (!isInstructorRequest) { + const session = studentLiveSessionForWorkspaceToken(token); + const stagedExercise = session ? browserStudentExercisePayload(session).exercise : null; + if (stagedExercise?.caseStudyId === caseStudyId && caseStudyId === BROWSER_STAGED_CASE.id) { + sendJson(response, 409, { + error: 'This Case Study is being delivered through the staged exercise.' + }); + return true; + } + } + const record = PROTECTED_CASE_STUDY_MANIFEST.find(item => item.id === caseStudyId); if (!record) { sendJson(response, 404, { error: 'Case Study not found.' }); diff --git a/scripts/staged-simulation-schema.mjs b/scripts/staged-simulation-schema.mjs new file mode 100644 index 0000000..8288053 --- /dev/null +++ b/scripts/staged-simulation-schema.mjs @@ -0,0 +1,358 @@ +/** + * @module stagedSimulationSchema + * @summary Validates and normalizes optional server-only staged Case Study definitions. + * @description + * Staged simulation metadata is authored only on protected Case Studies and is + * emitted only to the server-only protected manifest. This module deliberately + * keeps Student-release content structurally separate from Instructor-only + * facilitation/model content and rejects ambiguous or orphan references. + */ + +export const STAGED_SIMULATION_VERSION = 1; + +export const STUDENT_CONTENT_KINDS = Object.freeze([ + 'narrative', + 'prompt', + 'evidence', + 'image', + 'table', + 'document-page' +]); + +export const INSTRUCTOR_CONTENT_KINDS = Object.freeze([ + 'facilitation', + 'debrief', + 'exemplar' +]); + +export const DEBRIEF_EDIT_POLICIES = Object.freeze(['open', 'frozen']); + +const STABLE_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u; +const STUDENT_BLOCK_FIELDS = Object.freeze({ + narrative: Object.freeze(['id', 'kind', 'title', 'body']), + prompt: Object.freeze(['id', 'kind', 'title', 'body']), + evidence: Object.freeze(['id', 'kind', 'title', 'body']), + image: Object.freeze(['id', 'kind', 'title', 'assetId', 'alt']), + table: Object.freeze(['id', 'kind', 'title', 'headers', 'rows']), + 'document-page': Object.freeze(['id', 'kind', 'title', 'assetId', 'page', 'alt']) +}); +const INSTRUCTOR_BLOCK_FIELDS = Object.freeze(['id', 'kind', 'title', 'body']); +const STAGE_FIELDS = Object.freeze([ + 'id', + 'title', + 'studentObjective', + 'initialReleaseIds', + 'optionalReleaseIds', + 'intakeTargetIds', + 'suggestedMinutes', + 'instructorContentIds', + 'defaultDebriefEditPolicy' +]); +const ROOT_FIELDS = Object.freeze(['version', 'studentContent', 'instructorContent', 'stages']); + +function isRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function unique(values) { + return new Set(values).size === values.length; +} + +function nonEmptyString(value) { + return typeof value === 'string' && Boolean(value.trim()); +} + +function checkAllowedFields(value, allowed, ctx, errors) { + for (const field of Object.keys(value)) { + if (!allowed.includes(field)) errors.push(`${ctx}.${field} is not allowed in simulation version 1`); + } +} + +function checkStableId(value, ctx, errors) { + if (!nonEmptyString(value) || !STABLE_ID_PATTERN.test(value)) { + errors.push(`${ctx} must be a stable lowercase slug (letters, numbers, hyphens; max 64 characters)`); + return false; + } + return true; +} + +function checkStringArray(value, ctx, errors, { stableIds = false } = {}) { + if (!Array.isArray(value)) { + errors.push(`${ctx} must be an array`); + return []; + } + const normalized = []; + value.forEach((item, index) => { + if (!nonEmptyString(item)) { + errors.push(`${ctx}[${index}] must be a non-empty string`); + return; + } + const trimmed = item.trim(); + if (stableIds && !STABLE_ID_PATTERN.test(trimmed)) { + errors.push(`${ctx}[${index}] must be a stable lowercase slug`); + return; + } + normalized.push(trimmed); + }); + if (!unique(normalized)) errors.push(`${ctx} must not contain duplicate values`); + return normalized; +} + +function validateStudentContentBlock(block, index, errors) { + const ctx = `simulation.studentContent[${index}]`; + if (!isRecord(block)) { + errors.push(`${ctx} must be an object`); + return; + } + + const kind = typeof block.kind === 'string' ? block.kind.trim() : ''; + if (!STUDENT_CONTENT_KINDS.includes(kind)) { + errors.push(`${ctx}.kind must be one of: ${STUDENT_CONTENT_KINDS.join(', ')}`); + return; + } + checkAllowedFields(block, STUDENT_BLOCK_FIELDS[kind], ctx, errors); + checkStableId(block.id, `${ctx}.id`, errors); + if (!nonEmptyString(block.title)) errors.push(`${ctx}.title must be a non-empty string`); + + if (['narrative', 'prompt', 'evidence'].includes(kind)) { + if (!nonEmptyString(block.body)) errors.push(`${ctx}.body must be a non-empty string`); + return; + } + + if (kind === 'image') { + checkStableId(block.assetId, `${ctx}.assetId`, errors); + if (!nonEmptyString(block.alt)) errors.push(`${ctx}.alt must be a non-empty string`); + return; + } + + if (kind === 'document-page') { + checkStableId(block.assetId, `${ctx}.assetId`, errors); + if (!Number.isInteger(block.page) || block.page < 1) { + errors.push(`${ctx}.page must be a positive integer`); + } + if (!nonEmptyString(block.alt)) errors.push(`${ctx}.alt must be a non-empty string`); + return; + } + + if (!Array.isArray(block.headers) || block.headers.length === 0) { + errors.push(`${ctx}.headers must be a non-empty array`); + } else if (block.headers.some(header => !nonEmptyString(header))) { + errors.push(`${ctx}.headers must contain only non-empty strings`); + } + + if (!Array.isArray(block.rows) || block.rows.length === 0) { + errors.push(`${ctx}.rows must be a non-empty array`); + } else { + const width = Array.isArray(block.headers) ? block.headers.length : 0; + block.rows.forEach((row, rowIndex) => { + if (!Array.isArray(row) || row.length !== width || row.some(cell => typeof cell !== 'string')) { + errors.push(`${ctx}.rows[${rowIndex}] must contain exactly ${width} string cells`); + } + }); + } +} + +function validateInstructorContentBlock(block, index, errors) { + const ctx = `simulation.instructorContent[${index}]`; + if (!isRecord(block)) { + errors.push(`${ctx} must be an object`); + return; + } + checkAllowedFields(block, INSTRUCTOR_BLOCK_FIELDS, ctx, errors); + checkStableId(block.id, `${ctx}.id`, errors); + if (!INSTRUCTOR_CONTENT_KINDS.includes(block.kind)) { + errors.push(`${ctx}.kind must be one of: ${INSTRUCTOR_CONTENT_KINDS.join(', ')}`); + } + if (!nonEmptyString(block.title)) errors.push(`${ctx}.title must be a non-empty string`); + if (!nonEmptyString(block.body)) errors.push(`${ctx}.body must be a non-empty string`); +} + +function validateStage(stage, index, errors) { + const ctx = `simulation.stages[${index}]`; + if (!isRecord(stage)) { + errors.push(`${ctx} must be an object`); + return; + } + checkAllowedFields(stage, STAGE_FIELDS, ctx, errors); + checkStableId(stage.id, `${ctx}.id`, errors); + if (!nonEmptyString(stage.title)) errors.push(`${ctx}.title must be a non-empty string`); + if (!nonEmptyString(stage.studentObjective)) { + errors.push(`${ctx}.studentObjective must be a non-empty string`); + } + + checkStringArray(stage.initialReleaseIds, `${ctx}.initialReleaseIds`, errors, { stableIds: true }); + checkStringArray(stage.optionalReleaseIds, `${ctx}.optionalReleaseIds`, errors, { stableIds: true }); + checkStringArray(stage.intakeTargetIds, `${ctx}.intakeTargetIds`, errors); + checkStringArray(stage.instructorContentIds, `${ctx}.instructorContentIds`, errors, { stableIds: true }); + + if ( + stage.suggestedMinutes !== null + && stage.suggestedMinutes !== undefined + && (!Number.isInteger(stage.suggestedMinutes) || stage.suggestedMinutes < 1 || stage.suggestedMinutes > 480) + ) { + errors.push(`${ctx}.suggestedMinutes must be null or an integer from 1 to 480`); + } + + if (!DEBRIEF_EDIT_POLICIES.includes(stage.defaultDebriefEditPolicy)) { + errors.push(`${ctx}.defaultDebriefEditPolicy must be one of: ${DEBRIEF_EDIT_POLICIES.join(', ')}`); + } +} + +/** + * Validate an optional staged simulation definition. + * + * @param {unknown} simulation Candidate simulation definition. + * @returns {string[]} Validation errors; empty when valid. + */ +export function validateStagedSimulation(simulation) { + const errors = []; + if (!isRecord(simulation)) return ['simulation must be an object']; + + checkAllowedFields(simulation, ROOT_FIELDS, 'simulation', errors); + + if (simulation.version !== STAGED_SIMULATION_VERSION) { + errors.push(`simulation.version must equal ${STAGED_SIMULATION_VERSION}`); + } + + if (!Array.isArray(simulation.studentContent)) { + errors.push('simulation.studentContent must be an array'); + } else { + simulation.studentContent.forEach((block, index) => validateStudentContentBlock(block, index, errors)); + } + + if (!Array.isArray(simulation.instructorContent)) { + errors.push('simulation.instructorContent must be an array'); + } else { + simulation.instructorContent.forEach((block, index) => validateInstructorContentBlock(block, index, errors)); + } + + if (!Array.isArray(simulation.stages) || simulation.stages.length === 0) { + errors.push('simulation.stages must be a non-empty array'); + } else { + simulation.stages.forEach((stage, index) => validateStage(stage, index, errors)); + } + + if (errors.length) return errors; + + const studentIds = simulation.studentContent.map(block => block.id.trim()); + const instructorIds = simulation.instructorContent.map(block => block.id.trim()); + const stageIds = simulation.stages.map(stage => stage.id.trim()); + const allContentIds = [...studentIds, ...instructorIds]; + + if (!unique(studentIds)) errors.push('simulation.studentContent ids must be unique'); + if (!unique(instructorIds)) errors.push('simulation.instructorContent ids must be unique'); + if (!unique(allContentIds)) errors.push('Student and Instructor content ids must not collide'); + if (!unique(stageIds)) errors.push('simulation.stages ids must be unique'); + + const studentSet = new Set(studentIds); + const instructorSet = new Set(instructorIds); + const usedStudentIds = new Set(); + const usedInstructorIds = new Set(); + + simulation.stages.forEach((stage, index) => { + const initial = stage.initialReleaseIds.map(value => value.trim()); + const optional = stage.optionalReleaseIds.map(value => value.trim()); + const overlap = initial.filter(id => optional.includes(id)); + if (overlap.length) { + errors.push(`simulation.stages[${index}] must not list the same Student content as both initial and optional release: ${overlap.join(', ')}`); + } + + [...initial, ...optional].forEach(id => { + if (!studentSet.has(id)) { + errors.push(`simulation.stages[${index}] references unknown Student content id: ${id}`); + } else { + usedStudentIds.add(id); + } + }); + + stage.instructorContentIds.map(value => value.trim()).forEach(id => { + if (!instructorSet.has(id)) { + errors.push(`simulation.stages[${index}] references unknown Instructor content id: ${id}`); + } else { + usedInstructorIds.add(id); + } + }); + }); + + studentIds.filter(id => !usedStudentIds.has(id)).forEach(id => { + errors.push(`simulation.studentContent id is not referenced by any stage: ${id}`); + }); + instructorIds.filter(id => !usedInstructorIds.has(id)).forEach(id => { + errors.push(`simulation.instructorContent id is not referenced by any stage: ${id}`); + }); + + return errors; +} + +function normalizeStudentBlock(block) { + const common = { + id: block.id.trim(), + kind: block.kind.trim(), + title: block.title.trim() + }; + if (['narrative', 'prompt', 'evidence'].includes(common.kind)) { + return { ...common, body: block.body.trim() }; + } + if (common.kind === 'image') { + return { ...common, assetId: block.assetId.trim(), alt: block.alt.trim() }; + } + if (common.kind === 'document-page') { + return { + ...common, + assetId: block.assetId.trim(), + page: block.page, + alt: block.alt.trim() + }; + } + return { + ...common, + headers: block.headers.map(value => value.trim()), + rows: block.rows.map(row => row.map(value => value.trim())) + }; +} + +/** + * Normalize a simulation definition after validation. + * + * @param {object} simulation Valid simulation definition. + * @returns {object} Canonical version-1 server manifest shape. + */ +export function normalizeStagedSimulation(simulation) { + return { + version: STAGED_SIMULATION_VERSION, + studentContent: simulation.studentContent.map(normalizeStudentBlock), + instructorContent: simulation.instructorContent.map(block => ({ + id: block.id.trim(), + kind: block.kind.trim(), + title: block.title.trim(), + body: block.body.trim() + })), + stages: simulation.stages.map(stage => ({ + id: stage.id.trim(), + title: stage.title.trim(), + studentObjective: stage.studentObjective.trim(), + initialReleaseIds: stage.initialReleaseIds.map(value => value.trim()), + optionalReleaseIds: stage.optionalReleaseIds.map(value => value.trim()), + intakeTargetIds: stage.intakeTargetIds.map(value => value.trim()), + suggestedMinutes: stage.suggestedMinutes ?? null, + instructorContentIds: stage.instructorContentIds.map(value => value.trim()), + defaultDebriefEditPolicy: stage.defaultDebriefEditPolicy + })) + }; +} + +/** + * Validate whether a resource may carry staged simulation metadata. + * + * @param {object} options Validation context. + * @param {string} options.templateKind Resource kind. + * @param {unknown} options.simulation Optional simulation definition. + * @returns {string[]} Validation errors. + */ +export function validateSimulationForResource({ templateKind, simulation }) { + if (simulation === undefined) return []; + if (templateKind !== 'case-study') { + return ['simulation is allowed only on protected case-study resources']; + } + return validateStagedSimulation(simulation); +} diff --git a/src/AGENTS.md b/src/AGENTS.md index 2505e94..ea5d993 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -17,6 +17,14 @@ Keep feature modules independently initialisable, own only their feature-specifi ## Protected Classroom resources +`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads/writes `/api/classes/exercise`; draft selection/create may POST only an explicitly discovered staged `caseStudyId`. Start/Pause/Resume, current-stage `release-content`, Begin Debrief, debrief-only `set-editing`, Advance, and Complete mutations must send the currently observed `exerciseRevision`; a 409 must reload authoritative server state rather than replay stale intent. Advance may only follow the authored protected stage order and only from active debrief with a next stage; Complete may only occur from active final-stage debrief. Advancing returns the next stage to work with Student editing enabled. Completion is terminal presentation, must hide further edit/lifecycle actions, and must never imply that completion releases future, Instructor-only, exemplar, or full protected Case Study material. Release UI may render only the current stage's explicit `optionalReleaseIds`, never future-stage Student content. Team readiness/progress must come from the Instructor-authorized `workspaceState` payload and reuse the existing Instructor `selectWorkspace()` observer/coaching path. Debrief checkpoint UI may expose capture status, workspace identity, and captured workspace revision. Snapshot bytes may be fetched only after an explicit Instructor **Inspect checkpoint** action; `classroomExerciseInstructor.js` must pass that snapshot transiently to the existing Instructor observer renderer and must never retain it in controller public state, persistence, exports, summaries, or resume state. Checkpoint view must be read-only, pause live observer/coaching projection for that workspace, clearly identify the captured revision, and provide an explicit return to current live observation. It must never create an editable checkpoint copy or treat the snapshot as collaboration state. `studentEditingEnabled` is authoritative presentation only; the server-side 423 collaboration write guard remains the actual freeze boundary while an active staged policy applies. Current-stage Instructor facilitation may render only in Instructor experience. Exercise state, staged definitions, releases, readiness, checkpoints, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests. + +`classroomExerciseStudent.js` owns the Student staged-exercise case-reference client. It may receive only the stable live Student class-session capability, keep it in memory only, and GET `/api/classes/exercise/student`; it must never use the assignment/workspace capability as staged exercise authority and must never fetch the complete protected Case Study payload. Render only the sanitized case summary, current Student-visible stage/objective, authoritative exercise phase/edit-policy state, and cumulative released Student-safe content actually returned by the Student endpoint. Unknown response fields, future-stage metadata, Instructor-only material, exemplar/model content, and bearer capabilities must not enter DOM/public controller state. The reference must survive workspace reassignment because class-session authority is stable, but must clear immediately on class-session termination or role exit. Exercise content/state must never enter Intake persistence, localStorage/sessionStorage, summaries, exports, URLs, or public manifests. Mobile collapse/expand state is local presentation only; narrow layouts must not let the case reference or team/notes workspace obscure each other. + +`classroomStudent.js` distinguishes two lifecycles: workspace-level `onClassConnected/onClassDisconnected` hooks rotate with assignment authority, while live-session `onSessionConnected/onSessionDisconnected` hooks represent the stable Student class-session capability. Do not conflate those credentials; reassignment must rotate workspace authority without reconnecting the staged exercise session. + +`classroomInstructor.js` owns the connected Instructor class rail. Its collapse/expand state is local presentation state, not Intake/session authority. Narrow screens default to the compact collapsed class header, but the class controls must remain explicitly discoverable through an accessible Open/Collapse control; do not hide the Instructor workspace surface on mobile without an equivalent launcher. + `classroomCaseStudies.js` owns the authorized in-memory protected Case Study catalog/payload client. It receives active capabilities only through Student/Instructor lifecycle callbacks, keeps no localStorage/sessionStorage record, and clears catalog/context when the class role disconnects. Its public state must never expose a bearer capability. `templatesDrawer.js` may combine public Standard Template metadata with the current authorized in-memory Case Study catalog, but must fetch a protected payload only on demand and project it through `projectTemplateState()`. Standalone must work with no protected provider or classroom backend. diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js new file mode 100644 index 0000000..6129115 --- /dev/null +++ b/src/classroomExerciseInstructor.js @@ -0,0 +1,1178 @@ +/** + * @module classroomExerciseInstructor + * @summary Owns Instructor staged-exercise setup, pacing, release, debrief, progress, and console state. + * @description + * Receives the active Instructor capability from the class lifecycle, keeps it + * in memory only, and reads/writes the Instructor-authorized exercise endpoint. + * Tranche 6F completes the Instructor lifecycle with revision-safe advance + * and completion while preserving the debrief/checkpoint/edit-policy boundaries. + */ + +export const INSTRUCTOR_EXERCISE_ENDPOINT = '/api/classes/exercise'; +export const INSTRUCTOR_EXERCISE_CHECKPOINT_ENDPOINT = '/api/classes/exercise/checkpoint'; + +const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +function validCapability(value) { + return typeof value === 'string' && CAPABILITY_PATTERN.test(value.trim()); +} + +function sanitizeCaseStudySummary(value) { + if (!value || typeof value !== 'object') return null; + const id = typeof value.id === 'string' ? value.id.trim() : ''; + const name = typeof value.name === 'string' ? value.name.trim() : ''; + if (!id || !name) return null; + return { + id, + name, + description: typeof value.description === 'string' ? value.description : '', + supportedModes: Array.isArray(value.supportedModes) + ? value.supportedModes.filter(mode => typeof mode === 'string') + : [] + }; +} + +function sanitizeRelease(value) { + if (!value || typeof value !== 'object') return null; + const stageId = typeof value.stageId === 'string' ? value.stageId.trim() : ''; + const contentId = typeof value.contentId === 'string' ? value.contentId.trim() : ''; + if (!stageId || !contentId) return null; + return { + stageId, + contentId, + releasedAt: typeof value.releasedAt === 'string' ? value.releasedAt : null + }; +} + +function sanitizeWorkspaceState(value) { + if (!value || typeof value !== 'object') return null; + const workspaceId = typeof value.workspaceId === 'string' ? value.workspaceId.trim() : ''; + const workspaceLabel = typeof value.workspaceLabel === 'string' ? value.workspaceLabel.trim() : ''; + const stageId = typeof value.stageId === 'string' ? value.stageId.trim() : ''; + if (!workspaceId || !workspaceLabel || !stageId) return null; + return { + workspaceId, + workspaceKind: typeof value.workspaceKind === 'string' ? value.workspaceKind : '', + workspaceLabel, + stageId, + readyForDebrief: value.readyForDebrief === true, + readyAt: typeof value.readyAt === 'string' ? value.readyAt : null, + readyWorkspaceRevision: Number.isInteger(value.readyWorkspaceRevision) + ? value.readyWorkspaceRevision + : null + }; +} + +function sanitizeCheckpoint(value) { + if (!value || typeof value !== 'object') return null; + const workspaceId = typeof value.workspaceId === 'string' ? value.workspaceId.trim() : ''; + const workspaceLabel = typeof value.workspaceLabel === 'string' ? value.workspaceLabel.trim() : ''; + const stageId = typeof value.stageId === 'string' ? value.stageId.trim() : ''; + const workspaceRevision = Number(value.workspaceRevision); + if (!workspaceId || !workspaceLabel || !stageId || !Number.isInteger(workspaceRevision) || workspaceRevision < 1) { + return null; + } + return { + workspaceId, + workspaceKind: typeof value.workspaceKind === 'string' ? value.workspaceKind : '', + workspaceLabel, + stageId, + workspaceRevision, + capturedAt: typeof value.capturedAt === 'string' ? value.capturedAt : null + }; +} + +/** + * Create the Instructor exercise console controller. + * + * @param {object} [options] Dependency overrides. + * @returns {object} Exercise console controller. + */ +export function createInstructorExerciseConsoleController({ + fetchImpl = globalThis.fetch?.bind(globalThis), + documentRef = globalThis.document, + onSelectWorkspace = () => {}, + onInspectCheckpoint = () => {} +} = {}) { + let capability = ''; + let epoch = 0; + let destroyed = false; + let loading = false; + let lastError = ''; + let lastNotice = ''; + let classroom = null; + let exercise = null; + let caseStudy = null; + let availableCaseStudies = []; + let selectedCaseStudyId = ''; + let releases = []; + let workspaceState = []; + let checkpoints = []; + + const element = id => documentRef?.getElementById?.(id) || null; + + const currentPhaseLabel = () => { + if (!exercise) return ''; + if (exercise.status === 'draft') return 'Draft'; + if (exercise.status === 'paused') return 'Paused'; + if (exercise.status === 'completed') return 'Completed'; + if (exercise.stagePhase === 'debrief') return 'Debrief'; + return 'In progress'; + }; + + const renderAvailable = () => { + const list = element('instructorExerciseAvailableList'); + if (!list) return; + list.replaceChildren(); + if (!availableCaseStudies.length) { + const item = documentRef.createElement('li'); + item.className = 'instructor-exercise-console__empty'; + item.textContent = 'No staged Case Studies are authored for classroom simulation yet.'; + list.append(item); + return; + } + availableCaseStudies.forEach(item => { + const row = documentRef.createElement('li'); + const name = documentRef.createElement('strong'); + name.textContent = item.name; + row.append(name); + if (item.description) { + const description = documentRef.createElement('span'); + description.textContent = item.description; + row.append(description); + } + list.append(row); + }); + }; + + const renderSetup = () => { + const select = element('instructorExerciseCaseSelect'); + const createButton = element('instructorExerciseCreateBtn'); + const help = element('instructorExerciseSetupHelp'); + if (!select) return; + + const requestedSelection = exercise?.caseStudyId || selectedCaseStudyId; + select.replaceChildren(); + + const placeholder = documentRef.createElement('option'); // data-persistence="local-only" + placeholder.value = ''; + placeholder.textContent = availableCaseStudies.length + ? 'Select a staged Case Study' + : 'No staged Case Studies available'; + select.append(placeholder); + + availableCaseStudies.forEach(item => { + const option = documentRef.createElement('option'); // data-persistence="local-only" + option.value = item.id; + option.textContent = item.name; + select.append(option); + }); + + const selectionExists = availableCaseStudies.some(item => item.id === requestedSelection); + selectedCaseStudyId = selectionExists ? requestedSelection : ''; + select.value = selectedCaseStudyId; + select.disabled = loading || Boolean(exercise) || availableCaseStudies.length === 0; + + if (createButton) { + createButton.disabled = loading || Boolean(exercise) || !selectedCaseStudyId; + createButton.textContent = loading ? 'Working…' : 'Create draft'; + } + if (help) { + help.textContent = exercise + ? 'This class already has an open exercise. Lifecycle controls will be added in the next console slices.' + : 'Choose an explicitly staged Case Study. Creating a draft does not release anything to Students.'; + } + }; + + const currentStageContext = () => { + if (!exercise?.currentStageId || !caseStudy?.simulation) return null; + const stages = Array.isArray(caseStudy.simulation.stages) ? caseStudy.simulation.stages : []; + const stage = stages.find(item => item.id === exercise.currentStageId) || null; + if (!stage) return null; + const instructorBlocks = new Map( + (Array.isArray(caseStudy.simulation.instructorContent) ? caseStudy.simulation.instructorContent : []) + .map(item => [item.id, item]) + ); + const studentBlocks = new Map( + (Array.isArray(caseStudy.simulation.studentContent) ? caseStudy.simulation.studentContent : []) + .map(item => [item.id, item]) + ); + const stageReleases = new Map( + releases + .filter(item => item.stageId === stage.id) + .map(item => [item.contentId, item]) + ); + return { + stage, + instructorContent: (Array.isArray(stage.instructorContentIds) ? stage.instructorContentIds : []) + .map(id => instructorBlocks.get(id)) + .filter(Boolean), + optionalContent: (Array.isArray(stage.optionalReleaseIds) ? stage.optionalReleaseIds : []) + .map(id => { + const content = studentBlocks.get(id); + return content ? { content, release: stageReleases.get(id) || null } : null; + }) + .filter(Boolean) + }; + }; + + const renderLifecycle = () => { + const lifecycle = element('instructorExerciseLifecycle'); + const start = element('instructorExerciseStartBtn'); + const pause = element('instructorExercisePauseBtn'); + const resume = element('instructorExerciseResumeBtn'); + const beginDebrief = element('instructorExerciseBeginDebriefBtn'); + const advance = element('instructorExerciseAdvanceBtn'); + const complete = element('instructorExerciseCompleteBtn'); + const help = element('instructorExerciseLifecycleHelp'); + if (!lifecycle) return; + + lifecycle.hidden = !exercise; + const stages = Array.isArray(caseStudy?.simulation?.stages) ? caseStudy.simulation.stages : []; + const currentStageIndex = stages.findIndex(item => item.id === exercise?.currentStageId); + const isActiveDebrief = exercise?.status === 'active' + && exercise?.stagePhase === 'debrief' + && currentStageIndex >= 0; + const canStart = exercise?.status === 'draft' && !exercise.currentStageId; + const canPause = exercise?.status === 'active'; + const canResume = exercise?.status === 'paused'; + const canBeginDebrief = exercise?.status === 'active' && exercise?.stagePhase === 'work' && Boolean(exercise.currentStageId); + const canAdvance = isActiveDebrief && currentStageIndex < stages.length - 1; + const canComplete = isActiveDebrief && currentStageIndex === stages.length - 1; + + if (start) { + start.hidden = !canStart; + start.disabled = loading || !canStart; + } + if (pause) { + pause.hidden = !canPause; + pause.disabled = loading || !canPause; + } + if (resume) { + resume.hidden = !canResume; + resume.disabled = loading || !canResume; + } + if (beginDebrief) { + beginDebrief.hidden = !canBeginDebrief; + beginDebrief.disabled = loading || !canBeginDebrief; + } + if (advance) { + advance.hidden = !canAdvance; + advance.disabled = loading || !canAdvance; + } + if (complete) { + complete.hidden = !canComplete; + complete.disabled = loading || !canComplete; + } + if (help) { + help.textContent = exercise?.status === 'completed' + ? 'Exercise completed. Student work is preserved; completion does not release additional case or exemplar material.' + : exercise?.stagePhase === 'debrief' + ? 'Debrief preserves the captured team checkpoints. Pause still controls class pacing; editing policy is controlled below.' + : 'Pause controls class pacing only. Student editing freeze is a separate debrief control.'; + } + }; + + const renderStage = () => { + const panel = element('instructorExerciseStagePanel'); + if (!panel) return; + const context = currentStageContext(); + panel.hidden = !context; + if (!context) return; + + const { stage, instructorContent } = context; + if (element('instructorExerciseStageTitle')) { + element('instructorExerciseStageTitle').textContent = stage.title || 'Current stage'; + } + if (element('instructorExerciseStageObjective')) { + element('instructorExerciseStageObjective').textContent = stage.studentObjective || ''; + } + if (element('instructorExerciseStageTiming')) { + element('instructorExerciseStageTiming').textContent = Number.isFinite(stage.suggestedMinutes) + ? `Suggested time: ${stage.suggestedMinutes} min` + : ''; + } + + const facilitation = element('instructorExerciseFacilitation'); + const list = element('instructorExerciseFacilitationList'); + if (facilitation) facilitation.hidden = instructorContent.length === 0; + if (list) { + list.replaceChildren(); + instructorContent.forEach(item => { + const block = documentRef.createElement('article'); + const title = documentRef.createElement('strong'); + title.textContent = item.title || 'Instructor note'; + const body = documentRef.createElement('p'); + body.textContent = item.body || ''; + block.append(title, body); + list.append(block); + }); + } + }; + + const renderReleases = () => { + const panel = element('instructorExerciseReleasePanel'); + const list = element('instructorExerciseReleaseList'); + if (!panel || !list) return; + const context = currentStageContext(); + const optionalContent = context?.optionalContent || []; + panel.hidden = optionalContent.length === 0; + list.replaceChildren(); + if (!optionalContent.length) return; + + const canRelease = exercise?.status === 'active' && exercise?.stagePhase === 'work' && !loading; + optionalContent.forEach(item => { + const row = documentRef.createElement('article'); + row.setAttribute('data-persistence', 'local-only'); + row.setAttribute('data-summary', 'exclude'); + const title = documentRef.createElement('strong'); + title.textContent = item.content.title || 'Optional evidence'; + const body = documentRef.createElement('p'); + body.textContent = item.content.body || ''; + row.append(title, body); + + if (item.release) { + const state = documentRef.createElement('span'); + state.className = 'instructor-exercise-console__release-state'; + state.textContent = 'Released'; + row.append(state); + } else { + const button = documentRef.createElement('button'); // data-persistence="local-only" data-summary="exclude" + button.type = 'button'; + button.className = 'btn-secondary'; + button.setAttribute('data-persistence', 'local-only'); + button.setAttribute('data-summary', 'exclude'); + button.textContent = canRelease + ? 'Release to Students' + : exercise?.stagePhase === 'debrief' + ? 'Not released before debrief' + : 'Resume to release'; + button.disabled = !canRelease; + button.setAttribute('aria-label', `Release ${item.content.title || 'optional evidence'} to Students`); + button.addEventListener('click', () => { void releaseContent(item.content.id); }); + row.append(button); + } + list.append(row); + }); + }; + + const renderProgress = () => { + const panel = element('instructorExerciseProgressPanel'); + const list = element('instructorExerciseProgressList'); + const summary = element('instructorExerciseProgressSummary'); + if (!panel || !list) return; + const stageId = exercise?.currentStageId || ''; + panel.hidden = !stageId; + list.replaceChildren(); + if (!stageId) { + if (summary) summary.textContent = ''; + return; + } + + const rows = workspaceState.filter(item => item.stageId === stageId); + const readyCount = rows.filter(item => item.readyForDebrief).length; + const workingCount = rows.length - readyCount; + if (summary) summary.textContent = rows.length + ? `${readyCount} ready · ${workingCount} working` + : 'No readiness signals yet'; + + if (!rows.length) { + const empty = documentRef.createElement('p'); + empty.className = 'instructor-exercise-progress__empty'; + empty.textContent = 'Waiting for teams to report readiness.'; + list.append(empty); + return; + } + + const wrapper = documentRef.createElement('div'); + wrapper.className = 'instructor-exercise-console__progress-list'; + rows.forEach(item => { + const button = documentRef.createElement('button'); // data-persistence="local-only" data-summary="exclude" + button.type = 'button'; + button.className = 'instructor-exercise-progress__row'; + button.setAttribute('data-persistence', 'local-only'); + button.setAttribute('data-summary', 'exclude'); + button.setAttribute( + 'aria-label', + `Observe ${item.workspaceLabel}, ${item.readyForDebrief ? 'Ready' : 'Working'}` + ); + const label = documentRef.createElement('span'); + label.textContent = item.workspaceLabel; + const state = documentRef.createElement('span'); + state.className = 'instructor-exercise-progress__state' + + (item.readyForDebrief ? ' instructor-exercise-progress__state--ready' : ''); + state.textContent = item.readyForDebrief ? 'Ready' : 'Working'; + button.append(label, state); + button.addEventListener('click', () => { + Promise.resolve(onSelectWorkspace(item.workspaceId)).catch(() => {}); + }); + wrapper.append(button); + }); + list.append(wrapper); + }; + + const renderDebrief = () => { + const panel = element('instructorExerciseDebriefPanel'); + const editingStatus = element('instructorExerciseEditingStatus'); + const freezeButton = element('instructorExerciseFreezeBtn'); + const allowButton = element('instructorExerciseAllowEditingBtn'); + const summary = element('instructorExerciseCheckpointSummary'); + const list = element('instructorExerciseCheckpointList'); + if (!panel || !list) return; + + const isDebrief = exercise?.stagePhase === 'debrief' && Boolean(exercise?.currentStageId); + panel.hidden = !isDebrief; + list.replaceChildren(); + if (!isDebrief) { + if (editingStatus) editingStatus.textContent = 'Student editing status unavailable'; + if (summary) summary.textContent = '0 captured'; + return; + } + + const editingAllowed = exercise.studentEditingEnabled !== false; + const canChangeEditing = exercise.status === 'active' && !loading; + const completed = exercise.status === 'completed'; + if (editingStatus) { + editingStatus.textContent = completed + ? 'Exercise completed; staged editing policy no longer applies' + : editingAllowed + ? 'Student editing allowed during debrief' + : 'Student editing frozen during debrief'; + } + if (freezeButton) { + freezeButton.hidden = completed || !editingAllowed; + freezeButton.disabled = !canChangeEditing || !editingAllowed; + } + if (allowButton) { + allowButton.hidden = completed || editingAllowed; + allowButton.disabled = !canChangeEditing || editingAllowed; + } + + const stageCheckpoints = checkpoints.filter(item => item.stageId === exercise.currentStageId); + if (summary) { + summary.textContent = stageCheckpoints.length === 1 + ? '1 captured' + : `${stageCheckpoints.length} captured`; + } + if (!stageCheckpoints.length) { + const empty = documentRef.createElement('p'); + empty.className = 'instructor-exercise-checkpoint__empty'; + empty.textContent = 'No workspace checkpoint was captured for this stage.'; + list.append(empty); + return; + } + + const wrapper = documentRef.createElement('div'); + wrapper.className = 'instructor-exercise-checkpoint-list'; + stageCheckpoints.forEach(item => { + const row = documentRef.createElement('div'); + row.className = 'instructor-exercise-checkpoint__row'; + const label = documentRef.createElement('span'); + label.textContent = item.workspaceLabel; + const revision = documentRef.createElement('strong'); + revision.textContent = `Revision ${item.workspaceRevision}`; + const inspect = documentRef.createElement('button'); // data-persistence="local-only" data-summary="exclude" + inspect.type = 'button'; + inspect.className = 'btn-secondary instructor-exercise-checkpoint__inspect'; + inspect.setAttribute('data-persistence', 'local-only'); + inspect.setAttribute('data-summary', 'exclude'); + inspect.setAttribute( + 'aria-label', + `Inspect ${item.workspaceLabel} checkpoint, revision ${item.workspaceRevision}` + ); + inspect.textContent = 'Inspect checkpoint'; + inspect.hidden = exercise.status === 'completed'; + inspect.disabled = loading || exercise.status === 'completed'; + inspect.addEventListener('click', () => { + void inspectCheckpoint(item.workspaceId); + }); + row.append(label, revision, inspect); + wrapper.append(row); + }); + list.append(wrapper); + }; + + const render = () => { + const panel = element('instructorExerciseConsole'); + const status = element('instructorExerciseStatus'); + const caseName = element('instructorExerciseCaseName'); + const detail = element('instructorExerciseDetail'); + const refreshButton = element('instructorExerciseRefreshBtn'); + if (!panel) return; + + panel.hidden = !capability; + if (!capability) return; + if (refreshButton) refreshButton.disabled = loading; + + if (loading) { + if (status) status.textContent = 'Loading exercise…'; + } else if (lastError) { + if (status) status.textContent = lastError; + } else if (exercise) { + const phase = currentPhaseLabel(); + if (status) status.textContent = lastNotice ? `${phase} · ${lastNotice}` : phase; + } else { + const count = availableCaseStudies.length; + const availability = count === 1 + ? '1 staged Case Study available' + : `${count} staged Case Studies available`; + if (status) status.textContent = lastNotice ? `${availability} · ${lastNotice}` : availability; + } + + if (exercise && caseStudy) { + if (caseName) caseName.textContent = caseStudy.name || 'Current exercise'; + const stage = Array.isArray(caseStudy.simulation?.stages) + ? caseStudy.simulation.stages.find(item => item.id === exercise.currentStageId) + : null; + if (detail) detail.textContent = stage + ? exercise.status === 'completed' + ? `${stage.title} · Exercise completed. No additional Student material is released automatically.` + : `${stage.title} · ${stage.studentObjective}` + : 'Draft exercise — no stage has started and nothing has been released to Students.'; + } else { + if (caseName) caseName.textContent = 'No staged exercise selected'; + if (detail) detail.textContent = availableCaseStudies.length + ? 'Choose a staged Case Study below to create the class exercise draft.' + : 'The console is connected; no staged Case Study definition is currently available.'; + } + renderSetup(); + renderLifecycle(); + renderStage(); + renderReleases(); + renderProgress(); + renderDebrief(); + renderAvailable(); + }; + + const resetState = () => { + loading = false; + lastError = ''; + lastNotice = ''; + classroom = null; + exercise = null; + caseStudy = null; + availableCaseStudies = []; + selectedCaseStudyId = ''; + releases = []; + workspaceState = []; + checkpoints = []; + }; + + const applyPayload = body => { + classroom = body?.class && typeof body.class === 'object' ? body.class : null; + exercise = body?.exercise && typeof body.exercise === 'object' ? body.exercise : null; + caseStudy = body?.caseStudy && typeof body.caseStudy === 'object' ? body.caseStudy : null; + releases = Array.isArray(body?.releases) + ? body.releases.map(sanitizeRelease).filter(Boolean) + : exercise ? releases : []; + workspaceState = Array.isArray(body?.workspaceState) + ? body.workspaceState.map(sanitizeWorkspaceState).filter(Boolean) + : exercise ? workspaceState : []; + checkpoints = Array.isArray(body?.checkpoints) + ? body.checkpoints.map(sanitizeCheckpoint).filter(Boolean) + : exercise ? checkpoints : []; + if (Array.isArray(body?.availableCaseStudies)) { + availableCaseStudies = body.availableCaseStudies + .map(sanitizeCaseStudySummary) + .filter(Boolean); + } + if (exercise?.caseStudyId) selectedCaseStudyId = exercise.caseStudyId; + if (!exercise && selectedCaseStudyId && !availableCaseStudies.some(item => item.id === selectedCaseStudyId)) { + selectedCaseStudyId = ''; + } + }; + + const refresh = async () => { + if (destroyed || !capability || typeof fetchImpl !== 'function') return false; + const localCapability = capability; + const localEpoch = epoch; + loading = true; + lastError = ''; + lastNotice = ''; + render(); + try { + const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, { + method: 'GET', + headers: { Authorization: `Bearer ${localCapability}` } + }); + const body = await response.json().catch(() => ({})); + if (destroyed || localEpoch !== epoch || capability !== localCapability) return false; + if (!response.ok) { + lastError = response.status === 401 || response.status === 404 + ? 'Exercise access is no longer available.' + : 'Could not load the class exercise.'; + return false; + } + applyPayload(body); + return true; + } catch { + if (destroyed || localEpoch !== epoch) return false; + lastError = 'Could not load the class exercise.'; + return false; + } finally { + if (!destroyed && localEpoch === epoch) { + loading = false; + render(); + } + } + }; + + const inspectCheckpoint = async workspaceId => { + if ( + destroyed + || !capability + || loading + || exercise?.stagePhase !== 'debrief' + || !exercise?.currentStageId + || typeof fetchImpl !== 'function' + ) return false; + + const item = checkpoints.find(candidate => ( + candidate.workspaceId === workspaceId + && candidate.stageId === exercise.currentStageId + )); + if (!item) return false; + + const localCapability = capability; + const localEpoch = epoch; + const stageId = exercise.currentStageId; + loading = true; + lastError = ''; + lastNotice = ''; + render(); + + try { + const response = await fetchImpl( + `${INSTRUCTOR_EXERCISE_CHECKPOINT_ENDPOINT}?workspaceId=${encodeURIComponent(item.workspaceId)}`, + { + method: 'GET', + headers: { Authorization: `Bearer ${localCapability}` } + } + ); + const body = await response.json().catch(() => ({})); + if (destroyed || localEpoch !== epoch || capability !== localCapability) return false; + + if (response.status === 409) { + loading = false; + const reloaded = await refresh(); + if (reloaded && !destroyed && localEpoch === epoch) { + lastNotice = 'current state reloaded'; + render(); + } + return false; + } + + const validSnapshot = response.ok + && body?.workspace?.id === item.workspaceId + && body?.checkpoint?.stageId === stageId + && Number(body?.checkpoint?.workspaceRevision) === item.workspaceRevision + && body?.checkpoint?.snapshot + && typeof body.checkpoint.snapshot === 'object'; + if (!validSnapshot) { + lastError = response.status === 401 || response.status === 404 + ? 'Checkpoint is no longer available.' + : 'Could not inspect the captured checkpoint.'; + return false; + } + + const opened = await Promise.resolve(onInspectCheckpoint({ + workspace: { + id: body.workspace.id, + kind: body.workspace.kind, + label: body.workspace.label + }, + checkpoint: { + stageId: body.checkpoint.stageId, + workspaceRevision: body.checkpoint.workspaceRevision, + capturedAt: body.checkpoint.capturedAt || null, + snapshot: body.checkpoint.snapshot + } + })); + if (opened === false) { + lastError = 'Could not display the captured checkpoint.'; + return false; + } + lastNotice = 'checkpoint opened'; + return true; + } catch { + if (destroyed || localEpoch !== epoch) return false; + lastError = 'Could not inspect the captured checkpoint.'; + return false; + } finally { + if (!destroyed && localEpoch === epoch) { + loading = false; + render(); + } + } + }; + + const createDraft = async (caseStudyId = selectedCaseStudyId) => { + if ( + destroyed + || !capability + || exercise + || loading + || typeof fetchImpl !== 'function' + ) return false; + + const requestedId = typeof caseStudyId === 'string' ? caseStudyId.trim() : ''; + if (!availableCaseStudies.some(item => item.id === requestedId)) { + lastError = 'Choose a staged Case Study.'; + lastNotice = ''; + render(); + return false; + } + + const localCapability = capability; + const localEpoch = epoch; + selectedCaseStudyId = requestedId; + loading = true; + lastError = ''; + lastNotice = ''; + render(); + + try { + const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${localCapability}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ caseStudyId: requestedId }) + }); + const body = await response.json().catch(() => ({})); + if (destroyed || localEpoch !== epoch || capability !== localCapability) return false; + + if (response.status === 409) { + loading = false; + const reloaded = await refresh(); + if (reloaded && !destroyed && localEpoch === epoch) { + lastNotice = 'current state reloaded'; + render(); + } else if (!lastError) { + lastError = typeof body.error === 'string' ? body.error : 'Exercise changed. Refresh and retry.'; + render(); + } + return false; + } + + if (!response.ok) { + lastError = response.status === 401 || response.status === 404 + ? 'Exercise access is no longer available.' + : typeof body.error === 'string' && body.error + ? body.error + : 'Could not create the exercise draft.'; + return false; + } + + applyPayload(body); + lastNotice = body.created === false ? 'existing draft reused' : 'draft created'; + return true; + } catch { + if (destroyed || localEpoch !== epoch) return false; + lastError = 'Could not create the exercise draft.'; + return false; + } finally { + if (!destroyed && localEpoch === epoch) { + loading = false; + render(); + } + } + }; + + const mutateLifecycle = async action => { + if ( + destroyed + || !capability + || !exercise + || loading + || typeof fetchImpl !== 'function' + ) return false; + + const expectedRevision = Number(exercise.exerciseRevision); + if (!Number.isInteger(expectedRevision) || expectedRevision < 1) { + lastError = 'Exercise revision is unavailable. Refresh and retry.'; + lastNotice = ''; + render(); + return false; + } + + const lifecycleStages = Array.isArray(caseStudy?.simulation?.stages) + ? caseStudy.simulation.stages + : []; + const lifecycleStageIndex = lifecycleStages.findIndex(item => item.id === exercise.currentStageId); + const allowed = ( + (action === 'start' && exercise.status === 'draft' && !exercise.currentStageId) + || (action === 'pause' && exercise.status === 'active') + || (action === 'resume' && exercise.status === 'paused') + || ( + action === 'begin-debrief' + && exercise.status === 'active' + && exercise.stagePhase === 'work' + && Boolean(exercise.currentStageId) + ) + || ( + action === 'advance' + && exercise.status === 'active' + && exercise.stagePhase === 'debrief' + && lifecycleStageIndex >= 0 + && lifecycleStageIndex < lifecycleStages.length - 1 + ) + || ( + action === 'complete' + && exercise.status === 'active' + && exercise.stagePhase === 'debrief' + && lifecycleStages.length > 0 + && lifecycleStageIndex === lifecycleStages.length - 1 + ) + ); + if (!allowed) return false; + + const localCapability = capability; + const localEpoch = epoch; + loading = true; + lastError = ''; + lastNotice = ''; + render(); + + try { + const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${localCapability}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ action, expectedRevision }) + }); + const body = await response.json().catch(() => ({})); + if (destroyed || localEpoch !== epoch || capability !== localCapability) return false; + + if (response.status === 409) { + loading = false; + const reloaded = await refresh(); + if (reloaded && !destroyed && localEpoch === epoch) { + lastNotice = 'current state reloaded'; + render(); + } else if (!lastError) { + lastError = typeof body.error === 'string' ? body.error : 'Exercise changed. Refresh and retry.'; + render(); + } + return false; + } + + if (!response.ok) { + lastError = response.status === 401 || response.status === 404 + ? 'Exercise access is no longer available.' + : typeof body.error === 'string' && body.error + ? body.error + : 'Could not update the exercise.'; + return false; + } + + applyPayload(body); + lastNotice = action === 'start' + ? 'exercise started' + : action === 'pause' + ? 'exercise paused' + : action === 'resume' + ? 'exercise resumed' + : action === 'begin-debrief' + ? 'debrief started' + : action === 'advance' + ? 'advanced to next stage' + : 'exercise completed'; + return true; + } catch { + if (destroyed || localEpoch !== epoch) return false; + lastError = 'Could not update the exercise.'; + return false; + } finally { + if (!destroyed && localEpoch === epoch) { + loading = false; + render(); + } + } + }; + + const releaseContent = async contentId => { + if ( + destroyed + || !capability + || !exercise + || loading + || typeof fetchImpl !== 'function' + ) return false; + + const context = currentStageContext(); + const requestedId = typeof contentId === 'string' ? contentId.trim() : ''; + const optional = context?.optionalContent?.find(item => item.content.id === requestedId) || null; + if (!optional) return false; + if (optional.release) { + lastError = ''; + lastNotice = 'content already released'; + render(); + return true; + } + if (exercise.status !== 'active' || exercise.stagePhase !== 'work') return false; + + const expectedRevision = Number(exercise.exerciseRevision); + if (!Number.isInteger(expectedRevision) || expectedRevision < 1) { + lastError = 'Exercise revision is unavailable. Refresh and retry.'; + lastNotice = ''; + render(); + return false; + } + + const localCapability = capability; + const localEpoch = epoch; + loading = true; + lastError = ''; + lastNotice = ''; + render(); + + try { + const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${localCapability}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + action: 'release-content', + expectedRevision, + contentId: requestedId + }) + }); + const body = await response.json().catch(() => ({})); + if (destroyed || localEpoch !== epoch || capability !== localCapability) return false; + + if (response.status === 409) { + loading = false; + const reloaded = await refresh(); + if (reloaded && !destroyed && localEpoch === epoch) { + lastNotice = 'current state reloaded'; + render(); + } else if (!lastError) { + lastError = typeof body.error === 'string' ? body.error : 'Exercise changed. Refresh and retry.'; + render(); + } + return false; + } + + if (!response.ok) { + lastError = response.status === 401 || response.status === 404 + ? 'Exercise access is no longer available.' + : typeof body.error === 'string' && body.error + ? body.error + : 'Could not release the Student evidence.'; + return false; + } + + applyPayload(body); + lastNotice = body.changed === false ? 'content already released' : 'content released'; + return true; + } catch { + if (destroyed || localEpoch !== epoch) return false; + lastError = 'Could not release the Student evidence.'; + return false; + } finally { + if (!destroyed && localEpoch === epoch) { + loading = false; + render(); + } + } + }; + + const setEditing = async enabled => { + if ( + destroyed + || !capability + || !exercise + || loading + || typeof fetchImpl !== 'function' + || exercise.status !== 'active' + || exercise.stagePhase !== 'debrief' + || typeof enabled !== 'boolean' + ) return false; + + const currentEnabled = exercise.studentEditingEnabled !== false; + if (currentEnabled === enabled) { + lastError = ''; + lastNotice = enabled ? 'editing already allowed' : 'editing already frozen'; + render(); + return true; + } + + const expectedRevision = Number(exercise.exerciseRevision); + if (!Number.isInteger(expectedRevision) || expectedRevision < 1) { + lastError = 'Exercise revision is unavailable. Refresh and retry.'; + lastNotice = ''; + render(); + return false; + } + + const localCapability = capability; + const localEpoch = epoch; + loading = true; + lastError = ''; + lastNotice = ''; + render(); + + try { + const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${localCapability}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + action: 'set-editing', + expectedRevision, + enabled + }) + }); + const body = await response.json().catch(() => ({})); + if (destroyed || localEpoch !== epoch || capability !== localCapability) return false; + + if (response.status === 409) { + loading = false; + const reloaded = await refresh(); + if (reloaded && !destroyed && localEpoch === epoch) { + lastNotice = 'current state reloaded'; + render(); + } else if (!lastError) { + lastError = typeof body.error === 'string' ? body.error : 'Exercise changed. Refresh and retry.'; + render(); + } + return false; + } + + if (!response.ok) { + lastError = response.status === 401 || response.status === 404 + ? 'Exercise access is no longer available.' + : typeof body.error === 'string' && body.error + ? body.error + : 'Could not update Student editing.'; + return false; + } + + applyPayload(body); + lastNotice = enabled ? 'Student editing allowed' : 'Student editing frozen'; + return true; + } catch { + if (destroyed || localEpoch !== epoch) return false; + lastError = 'Could not update Student editing.'; + return false; + } finally { + if (!destroyed && localEpoch === epoch) { + loading = false; + render(); + } + } + }; + + const connectInstructor = async token => { + if (!validCapability(token) || destroyed) return false; + epoch += 1; + capability = token.trim(); + resetState(); + render(); + return refresh(); + }; + + const disconnect = () => { + epoch += 1; + capability = ''; + resetState(); + render(); + }; + + const handleRefresh = () => { void refresh(); }; + const handleSelect = event => { + selectedCaseStudyId = typeof event?.target?.value === 'string' ? event.target.value : ''; + lastError = ''; + lastNotice = ''; + render(); + }; + const handleSetupSubmit = event => { + event.preventDefault(); + void createDraft(); + }; + const handleStart = () => { void mutateLifecycle('start'); }; + const handlePause = () => { void mutateLifecycle('pause'); }; + const handleResume = () => { void mutateLifecycle('resume'); }; + const handleBeginDebrief = () => { void mutateLifecycle('begin-debrief'); }; + const handleAdvance = () => { void mutateLifecycle('advance'); }; + const handleComplete = () => { void mutateLifecycle('complete'); }; + const handleFreeze = () => { void setEditing(false); }; + const handleAllowEditing = () => { void setEditing(true); }; + + element('instructorExerciseRefreshBtn')?.addEventListener('click', handleRefresh); + element('instructorExerciseCaseSelect')?.addEventListener('change', handleSelect); + element('instructorExerciseSetupForm')?.addEventListener('submit', handleSetupSubmit); + element('instructorExerciseStartBtn')?.addEventListener('click', handleStart); + element('instructorExercisePauseBtn')?.addEventListener('click', handlePause); + element('instructorExerciseResumeBtn')?.addEventListener('click', handleResume); + element('instructorExerciseBeginDebriefBtn')?.addEventListener('click', handleBeginDebrief); + element('instructorExerciseAdvanceBtn')?.addEventListener('click', handleAdvance); + element('instructorExerciseCompleteBtn')?.addEventListener('click', handleComplete); + element('instructorExerciseFreezeBtn')?.addEventListener('click', handleFreeze); + element('instructorExerciseAllowEditingBtn')?.addEventListener('click', handleAllowEditing); + render(); + + return { + connectInstructor, + disconnect, + refresh, + createDraft, + start: () => mutateLifecycle('start'), + pause: () => mutateLifecycle('pause'), + resume: () => mutateLifecycle('resume'), + beginDebrief: () => mutateLifecycle('begin-debrief'), + advance: () => mutateLifecycle('advance'), + complete: () => mutateLifecycle('complete'), + setEditing, + releaseContent, + inspectCheckpoint, + getState: () => ({ + connected: Boolean(capability), + loading, + lastError, + lastNotice, + classroom, + exercise, + selectedCaseStudyId, + caseStudy: caseStudy ? sanitizeCaseStudySummary(caseStudy) : null, + availableCaseStudies: availableCaseStudies.map(item => ({ ...item })), + releases: releases.map(item => ({ ...item })), + workspaceState: workspaceState.map(item => ({ ...item })), + checkpoints: checkpoints.map(item => ({ ...item })) + }), + destroy: () => { + if (destroyed) return; + element('instructorExerciseRefreshBtn')?.removeEventListener('click', handleRefresh); + element('instructorExerciseCaseSelect')?.removeEventListener('change', handleSelect); + element('instructorExerciseSetupForm')?.removeEventListener('submit', handleSetupSubmit); + element('instructorExerciseStartBtn')?.removeEventListener('click', handleStart); + element('instructorExercisePauseBtn')?.removeEventListener('click', handlePause); + element('instructorExerciseResumeBtn')?.removeEventListener('click', handleResume); + element('instructorExerciseBeginDebriefBtn')?.removeEventListener('click', handleBeginDebrief); + element('instructorExerciseAdvanceBtn')?.removeEventListener('click', handleAdvance); + element('instructorExerciseCompleteBtn')?.removeEventListener('click', handleComplete); + element('instructorExerciseFreezeBtn')?.removeEventListener('click', handleFreeze); + element('instructorExerciseAllowEditingBtn')?.removeEventListener('click', handleAllowEditing); + disconnect(); + destroyed = true; + } + }; +} + +/** + * Initialize the Instructor staged exercise console. + * + * @param {Parameters[0]} options Dependencies. + * @returns {ReturnType} Controller. + */ +export function initInstructorExerciseConsole(options) { + return createInstructorExerciseConsoleController(options); +} diff --git a/src/classroomExerciseStudent.js b/src/classroomExerciseStudent.js new file mode 100644 index 0000000..da84b60 --- /dev/null +++ b/src/classroomExerciseStudent.js @@ -0,0 +1,627 @@ +/** + * @module classroomExerciseStudent + * @summary Owns the Student current-stage case reference for staged Classroom exercises. + * @description + * Receives only the stable Student class-session capability, keeps it in memory, + * reads the Student-authorized staged exercise endpoint, and renders only the + * current stage plus cumulative Student-safe released content. It never fetches + * the complete protected Case Study payload and never persists exercise state. + */ + +export const STUDENT_EXERCISE_ENDPOINT = '/api/classes/exercise/student'; +export const STUDENT_EXERCISE_READY_ENDPOINT = '/api/classes/exercise/student/ready'; +export const STUDENT_EXERCISE_POLL_MS = 2500; + +const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +function validCapability(value) { + return typeof value === 'string' && CAPABILITY_PATTERN.test(value.trim()); +} + +function sanitizeCaseStudy(value) { + if (!value || typeof value !== 'object') return null; + const id = typeof value.id === 'string' ? value.id.trim() : ''; + const name = typeof value.name === 'string' ? value.name.trim() : ''; + if (!id || !name) return null; + return { + id, + name, + description: typeof value.description === 'string' ? value.description : '' + }; +} + +function sanitizeStage(value) { + if (!value || typeof value !== 'object') return null; + const id = typeof value.id === 'string' ? value.id.trim() : ''; + const title = typeof value.title === 'string' ? value.title.trim() : ''; + if (!id || !title) return null; + return { + id, + title, + studentObjective: typeof value.studentObjective === 'string' ? value.studentObjective : '' + }; +} + +function sanitizeReleasedContent(value) { + if (!value || typeof value !== 'object') return null; + const stageId = typeof value.stageId === 'string' ? value.stageId.trim() : ''; + const content = value.content; + if (!stageId || !content || typeof content !== 'object') return null; + const id = typeof content.id === 'string' ? content.id.trim() : ''; + const title = typeof content.title === 'string' ? content.title.trim() : ''; + if (!id || !title) return null; + return { + stageId, + releaseType: value.releaseType === 'optional' ? 'optional' : 'initial', + releasedAt: typeof value.releasedAt === 'string' ? value.releasedAt : null, + content: { + id, + kind: typeof content.kind === 'string' ? content.kind : '', + title, + body: typeof content.body === 'string' ? content.body : '' + } + }; +} + +function sanitizeReadiness(value) { + if (!value || typeof value !== 'object') return null; + const stageId = typeof value.stageId === 'string' ? value.stageId.trim() : ''; + if (!stageId || typeof value.readyForDebrief !== 'boolean') return null; + const revision = value.readyWorkspaceRevision; + return { + stageId, + readyForDebrief: value.readyForDebrief, + readyAt: typeof value.readyAt === 'string' ? value.readyAt : null, + readyWorkspaceRevision: Number.isInteger(revision) && revision >= 0 ? revision : null + }; +} + +function sanitizeExercise(value) { + if (!value || typeof value !== 'object') return null; + const id = typeof value.id === 'string' ? value.id.trim() : ''; + const revision = Number(value.exerciseRevision); + if (!id || !Number.isInteger(revision) || revision < 1) return null; + const status = ['draft', 'active', 'paused', 'completed'].includes(value.status) + ? value.status + : ''; + const stagePhase = ['work', 'debrief'].includes(value.stagePhase) ? value.stagePhase : ''; + if (!status || !stagePhase) return null; + return { + id, + caseStudy: sanitizeCaseStudy(value.caseStudy), + status, + stagePhase, + exerciseRevision: revision, + studentEditingEnabled: value.studentEditingEnabled !== false, + editFreezeEnforced: value.editFreezeEnforced === true, + currentStage: sanitizeStage(value.currentStage), + releasedContent: Array.isArray(value.releasedContent) + ? value.releasedContent.map(sanitizeReleasedContent).filter(Boolean) + : [], + readiness: sanitizeReadiness(value.readiness) + }; +} + +function phaseLabel(exercise) { + if (!exercise) return ''; + if (exercise.status === 'completed') return 'Completed'; + if (exercise.status === 'draft') return 'Waiting to start'; + if (exercise.status === 'paused') { + if (exercise.stagePhase === 'debrief' && exercise.studentEditingEnabled === false) { + return 'Paused · debrief · editing frozen'; + } + return exercise.stagePhase === 'debrief' ? 'Paused · debrief' : 'Paused · work'; + } + if (exercise.stagePhase === 'debrief') { + return exercise.studentEditingEnabled === false + ? 'Debrief · editing frozen' + : 'Debrief · editing open'; + } + return 'Work'; +} + +/** + * Create the Student staged-exercise reference controller. + * + * @param {object} [options] Dependency overrides. + * @returns {object} Student case-reference controller. + */ +export function createStudentExerciseReferenceController({ + fetchImpl = globalThis.fetch?.bind(globalThis), + documentRef = globalThis.document, + windowRef = globalThis.window, + setTimeoutImpl = globalThis.setTimeout?.bind(globalThis), + clearTimeoutImpl = globalThis.clearTimeout?.bind(globalThis), + AbortControllerImpl = globalThis.AbortController, + pollMs = STUDENT_EXERCISE_POLL_MS +} = {}) { + let capability = ''; + let exercise = null; + let classroom = null; + let assignment = null; + let participant = null; + let loading = false; + let lastError = ''; + let readinessUpdating = false; + let readinessError = ''; + let destroyed = false; + let epoch = 0; + let timer = null; + let abortController = null; + const readonlyRecords = new Map(); + const mobileQuery = windowRef?.matchMedia?.('(max-width: 700px)') || null; + let expanded = !mobileQuery?.matches; + let expansionTouched = false; + + const element = id => documentRef?.getElementById?.(id) || null; + const intakeWrap = () => documentRef?.querySelector?.('.wrap[data-experience-surface="intake"]') || null; + + const restoreReadonlyProjection = () => { + readonlyRecords.forEach((record, control) => { + if (!control) return; + if (record.disabled !== undefined) control.disabled = record.disabled; + if (record.readOnly !== undefined) control.readOnly = record.readOnly; + if (record.tabIndex === null) control.removeAttribute?.('tabindex'); + else control.setAttribute?.('tabindex', record.tabIndex); + if (record.ariaDisabled === null) control.removeAttribute?.('aria-disabled'); + else control.setAttribute?.('aria-disabled', record.ariaDisabled); + if (record.ariaReadonly === null) control.removeAttribute?.('aria-readonly'); + else control.setAttribute?.('aria-readonly', record.ariaReadonly); + if (record.contentEditable === null) control.removeAttribute?.('contenteditable'); + else control.setAttribute?.('contenteditable', record.contentEditable); + if (record.draggable !== undefined) control.draggable = record.draggable; + control.removeAttribute?.('data-student-exercise-readonly-control'); + }); + readonlyRecords.clear(); + intakeWrap()?.classList?.remove('student-exercise-readonly'); + }; + + const projectReadonly = () => { + const wrap = intakeWrap(); + if (!wrap) return; + wrap.classList.add('student-exercise-readonly'); + const controls = wrap.querySelectorAll( + 'input, textarea, select, button, [contenteditable], [role="button"], [role="checkbox"], [role="switch"], [draggable="true"]' + ); + controls.forEach(control => { + if (control.closest?.('#studentExperienceNotice')) return; + if (!readonlyRecords.has(control)) { + readonlyRecords.set(control, { + disabled: 'disabled' in control ? control.disabled : undefined, + readOnly: 'readOnly' in control ? control.readOnly : undefined, + tabIndex: control.hasAttribute?.('tabindex') ? control.getAttribute('tabindex') : null, + ariaDisabled: control.hasAttribute?.('aria-disabled') ? control.getAttribute('aria-disabled') : null, + ariaReadonly: control.hasAttribute?.('aria-readonly') ? control.getAttribute('aria-readonly') : null, + contentEditable: control.hasAttribute?.('contenteditable') ? control.getAttribute('contenteditable') : null, + draggable: 'draggable' in control ? control.draggable : undefined + }); + } + const tag = control.tagName; + const type = String(control.getAttribute?.('type') || '').toLowerCase(); + if (tag === 'TEXTAREA' || (tag === 'INPUT' && !['checkbox', 'radio', 'button', 'submit', 'reset', 'file', 'range', 'color'].includes(type))) { + control.readOnly = true; + control.setAttribute('aria-readonly', 'true'); + } else if ('disabled' in control) { + control.disabled = true; + } else { + control.setAttribute('aria-disabled', 'true'); + control.setAttribute('tabindex', '-1'); + } + if (control.hasAttribute?.('contenteditable')) control.setAttribute('contenteditable', 'false'); + if ('draggable' in control) control.draggable = false; + control.setAttribute?.('data-student-exercise-readonly-control', ''); + }); + if (wrap.contains(documentRef.activeElement) && typeof documentRef.activeElement?.blur === 'function') { + documentRef.activeElement.blur(); + } + }; + + const syncReadonlyProjection = () => { + const frozenDebrief = Boolean( + exercise + && exercise.status !== 'completed' + && exercise.stagePhase === 'debrief' + && exercise.studentEditingEnabled === false + && exercise.editFreezeEnforced === true + ); + if (frozenDebrief) projectReadonly(); + else restoreReadonlyProjection(); + }; + + const stopTimer = () => { + if (timer !== null && typeof clearTimeoutImpl === 'function') clearTimeoutImpl(timer); + timer = null; + }; + + const abort = () => { + try { abortController?.abort?.(); } catch {} + abortController = null; + }; + + const schedule = (localEpoch = epoch) => { + stopTimer(); + if ( + destroyed + || !capability + || localEpoch !== epoch + || typeof setTimeoutImpl !== 'function' + ) return; + timer = setTimeoutImpl(() => { + timer = null; + void refresh(localEpoch); + }, pollMs); + }; + + const renderExpansion = () => { + const panel = element('studentCaseReference'); + const toggle = element('studentCaseReferenceToggle'); + panel?.classList?.toggle('is-collapsed', !expanded); + if (toggle) { + toggle.setAttribute('aria-expanded', String(expanded)); + toggle.textContent = expanded ? 'Collapse reference' : 'Open case reference'; + } + }; + + const setExpanded = (next, { user = false } = {}) => { + expanded = Boolean(next); + if (user) expansionTouched = true; + renderExpansion(); + return expanded; + }; + + const clearView = () => { + exercise = null; + classroom = null; + assignment = null; + participant = null; + lastError = ''; + readinessUpdating = false; + readinessError = ''; + const panel = element('studentCaseReference'); + if (panel) panel.hidden = true; + documentRef?.body?.classList?.remove('student-case-reference-visible'); + }; + + const renderContent = () => { + const list = element('studentCaseReferenceContent'); + if (!list) return; + list.replaceChildren(); + if (!exercise?.releasedContent?.length) { + const empty = documentRef.createElement('p'); + empty.className = 'student-case-reference__empty'; + empty.textContent = exercise?.status === 'draft' + ? 'Your instructor has not started the exercise yet.' + : 'No case material has been released for this stage yet.'; + list.append(empty); + return; + } + + exercise.releasedContent.forEach(item => { + const article = documentRef.createElement('article'); + article.className = 'student-case-reference__item'; + article.setAttribute('data-persistence', 'local-only'); + article.setAttribute('data-summary', 'exclude'); + const title = documentRef.createElement('strong'); + title.textContent = item.content.title; + const body = documentRef.createElement('p'); + body.textContent = item.content.body; + article.append(title, body); + list.append(article); + }); + }; + + const render = () => { + const panel = element('studentCaseReference'); + const title = element('studentCaseReferenceTitle'); + const status = element('studentCaseReferenceStatus'); + const stageTitle = element('studentCaseReferenceStageTitle'); + const objective = element('studentCaseReferenceObjective'); + const message = element('studentCaseReferenceMessage'); + const readinessPanel = element('studentCaseReferenceReadiness'); + const readinessStatus = element('studentCaseReferenceReadinessStatus'); + const readinessButton = element('studentCaseReferenceReadyBtn'); + if (!panel) return; + + const visible = Boolean(capability && exercise); + panel.hidden = !visible; + documentRef?.body?.classList?.toggle('student-case-reference-visible', visible); + renderExpansion(); + if (!visible) { + restoreReadonlyProjection(); + return; + } + + if (title) title.textContent = exercise.caseStudy?.name || 'Current exercise'; + if (status) { + status.textContent = loading + ? 'Refreshing current stage…' + : lastError || phaseLabel(exercise); + } + if (stageTitle) stageTitle.textContent = exercise.currentStage?.title || 'Waiting for current stage'; + if (objective) objective.textContent = exercise.currentStage?.studentObjective || ''; + + const readinessForCurrentStage = exercise.readiness?.stageId === exercise.currentStage?.id + ? exercise.readiness + : null; + const isReady = readinessForCurrentStage?.readyForDebrief === true; + const canManageReadiness = exercise.status === 'active' + && exercise.stagePhase === 'work' + && Boolean(exercise.currentStage) + && Boolean(assignment); + + if (readinessPanel) readinessPanel.hidden = !canManageReadiness; + if (readinessStatus) { + if (readinessUpdating) { + readinessStatus.textContent = 'Updating team status…'; + } else if (readinessError) { + readinessStatus.textContent = readinessError; + } else if (isReady) { + readinessStatus.textContent = Number.isInteger(readinessForCurrentStage.readyWorkspaceRevision) + ? `Ready for debrief · Intake revision ${readinessForCurrentStage.readyWorkspaceRevision}` + : 'Ready for debrief'; + } else { + readinessStatus.textContent = 'Working'; + } + } + if (readinessButton) { + readinessButton.hidden = !canManageReadiness; + readinessButton.disabled = readinessUpdating || !canManageReadiness; + readinessButton.setAttribute('aria-pressed', String(isReady)); + readinessButton.textContent = isReady ? 'Resume working' : 'Mark Ready'; + } + + if (message) { + const frozen = exercise.stagePhase === 'debrief' + && exercise.studentEditingEnabled === false + && exercise.status !== 'completed'; + message.hidden = !frozen; + message.textContent = frozen + ? 'Your instructor has frozen Student editing for this debrief. You can still review the released case material.' + : ''; + } + syncReadonlyProjection(); + renderContent(); + }; + + const applyPayload = body => { + classroom = body?.class && typeof body.class === 'object' ? { ...body.class } : null; + assignment = body?.assignment && typeof body.assignment === 'object' ? { ...body.assignment } : null; + participant = body?.participant && typeof body.participant === 'object' + ? { id: body.participant.id, displayName: body.participant.displayName } + : null; + exercise = sanitizeExercise(body?.exercise); + readinessError = ''; + }; + + async function refresh(requestEpoch = epoch) { + if ( + destroyed + || !capability + || requestEpoch !== epoch + || typeof fetchImpl !== 'function' + ) return false; + + const localCapability = capability; + loading = true; + lastError = ''; + render(); + abort(); + abortController = typeof AbortControllerImpl === 'function' ? new AbortControllerImpl() : null; + + try { + const response = await fetchImpl(STUDENT_EXERCISE_ENDPOINT, { + method: 'GET', + headers: { Authorization: `Bearer ${localCapability}` }, + signal: abortController?.signal + }); + const body = await response.json().catch(() => ({})); + abortController = null; + if (destroyed || requestEpoch !== epoch || capability !== localCapability) return false; + + if (response.status === 401 || response.status === 404) { + disconnect(); + return false; + } + if (response.status === 409) { + exercise = null; + lastError = 'The exercise definition changed. Waiting for the instructor to refresh the class.'; + render(); + schedule(requestEpoch); + return false; + } + if (!response.ok) { + lastError = 'Case reference is temporarily unavailable.'; + render(); + schedule(requestEpoch); + return false; + } + + applyPayload(body); + lastError = ''; + render(); + schedule(requestEpoch); + return true; + } catch (error) { + abortController = null; + if (destroyed || requestEpoch !== epoch || error?.name === 'AbortError') return false; + lastError = 'Case reference is temporarily unavailable.'; + render(); + schedule(requestEpoch); + return false; + } finally { + if (!destroyed && requestEpoch === epoch) { + loading = false; + render(); + } + } + } + + async function setReadiness(nextReady, requestEpoch = epoch) { + if ( + destroyed + || !capability + || requestEpoch !== epoch + || readinessUpdating + || typeof fetchImpl !== 'function' + || exercise?.status !== 'active' + || exercise?.stagePhase !== 'work' + || !exercise?.currentStage + || !assignment + ) return false; + + const localCapability = capability; + const desiredReady = Boolean(nextReady); + readinessUpdating = true; + readinessError = ''; + stopTimer(); + abort(); + render(); + abortController = typeof AbortControllerImpl === 'function' ? new AbortControllerImpl() : null; + + try { + const response = await fetchImpl(STUDENT_EXERCISE_READY_ENDPOINT, { + method: 'PUT', + headers: { + Authorization: `Bearer ${localCapability}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ready: desiredReady }), + signal: abortController?.signal + }); + const body = await response.json().catch(() => ({})); + abortController = null; + if (destroyed || requestEpoch !== epoch || capability !== localCapability) return false; + + if (response.status === 401 || response.status === 404) { + disconnect(); + return false; + } + if (response.status === 409) { + readinessError = 'Class or assignment changed. Refreshing team status…'; + render(); + await refresh(requestEpoch); + return false; + } + if (!response.ok) { + readinessError = 'Unable to update team readiness.'; + render(); + schedule(requestEpoch); + return false; + } + + assignment = body?.assignment && typeof body.assignment === 'object' + ? { ...body.assignment } + : assignment; + if (exercise) { + exercise = { + ...exercise, + readiness: sanitizeReadiness(body?.readiness) + }; + } + readinessError = ''; + render(); + schedule(requestEpoch); + return true; + } catch (error) { + abortController = null; + if (destroyed || requestEpoch !== epoch || error?.name === 'AbortError') return false; + readinessError = 'Unable to update team readiness.'; + render(); + schedule(requestEpoch); + return false; + } finally { + if (!destroyed && requestEpoch === epoch) { + readinessUpdating = false; + render(); + } + } + } + + const connectStudent = async token => { + if (destroyed || !validCapability(token)) return false; + epoch += 1; + stopTimer(); + abort(); + capability = token.trim(); + exercise = null; + lastError = ''; + render(); + return refresh(epoch); + }; + + const disconnect = () => { + epoch += 1; + stopTimer(); + abort(); + capability = ''; + restoreReadonlyProjection(); + clearView(); + renderExpansion(); + }; + + const handleToggle = () => setExpanded(!expanded, { user: true }); + const handleReadyToggle = () => { + const current = exercise?.readiness?.stageId === exercise?.currentStage?.id + ? exercise.readiness.readyForDebrief === true + : false; + void setReadiness(!current, epoch); + }; + const handleMediaChange = event => { + if (expansionTouched) return; + setExpanded(!event.matches); + }; + + element('studentCaseReferenceToggle')?.addEventListener('click', handleToggle); + element('studentCaseReferenceReadyBtn')?.addEventListener('click', handleReadyToggle); + mobileQuery?.addEventListener?.('change', handleMediaChange); + renderExpansion(); + + return { + connectStudent, + disconnect, + refresh: () => refresh(epoch), + setReadiness: ready => setReadiness(Boolean(ready), epoch), + setExpanded: next => setExpanded(next, { user: true }), + getState: () => ({ + connected: Boolean(capability), + loading, + lastError, + readinessUpdating, + readinessError, + expanded, + classroom, + assignment, + participant, + exercise: exercise + ? { + ...exercise, + caseStudy: exercise.caseStudy ? { ...exercise.caseStudy } : null, + currentStage: exercise.currentStage ? { ...exercise.currentStage } : null, + releasedContent: exercise.releasedContent.map(item => ({ + ...item, + content: { ...item.content } + })), + readiness: exercise.readiness ? { ...exercise.readiness } : null + } + : null + }), + destroy: () => { + if (destroyed) return; + element('studentCaseReferenceToggle')?.removeEventListener('click', handleToggle); + element('studentCaseReferenceReadyBtn')?.removeEventListener('click', handleReadyToggle); + mobileQuery?.removeEventListener?.('change', handleMediaChange); + disconnect(); + destroyed = true; + } + }; +} + +/** + * Initialize the Student staged-exercise case reference. + * + * @param {Parameters[0]} options Dependencies. + * @returns {ReturnType} Controller. + */ +export function initStudentExerciseReference(options) { + return createStudentExerciseReferenceController(options); +} diff --git a/src/classroomInstructor.js b/src/classroomInstructor.js index fff204a..f72a926 100644 --- a/src/classroomInstructor.js +++ b/src/classroomInstructor.js @@ -133,11 +133,15 @@ export function createInstructorClassroomController({ let observerEpoch = 0; let localRecovery = null; let latestObservation = null; + let checkpointView = null; let destroyed = false; let busy = false; let searchQuery = ''; let kindFilter = 'all'; let lastError = ''; + const mobileRailQuery = windowRef?.matchMedia?.('(max-width: 700px)') || null; + let railExpanded = !mobileRailQuery?.matches; + let railPreferenceTouched = false; const readonlyRecords = new Map(); const element = id => documentRef?.getElementById(id) || null; @@ -145,7 +149,29 @@ export function createInstructorClassroomController({ const setStatus = status => { if (documentRef?.body) documentRef.body.dataset.instructorClassStatus = status; }; - const setConnectedLayout = connected => documentRef?.body?.classList?.toggle('instructor-class-connected', connected); + const setConnectedLayout = connected => { + documentRef?.body?.classList?.toggle('instructor-class-connected', connected); + documentRef?.body?.classList?.toggle('instructor-rail-collapsed', Boolean(connected && !railExpanded)); + }; + const renderRailExpansion = () => { + const dashboard = element('instructorClassDashboard'); + const toggle = element('instructorClassPanelToggle'); + dashboard?.classList?.toggle('is-collapsed', !railExpanded); + if (toggle) { + toggle.setAttribute('aria-expanded', String(railExpanded)); + toggle.textContent = railExpanded ? 'Collapse class panel' : 'Open class panel'; + } + documentRef?.body?.classList?.toggle( + 'instructor-rail-collapsed', + Boolean(activeSession && !railExpanded) + ); + }; + const setRailExpanded = (expanded, { user = false } = {}) => { + railExpanded = Boolean(expanded); + if (user) railPreferenceTouched = true; + renderRailExpansion(); + return railExpanded; + }; const setError = message => { lastError = message || ''; const error = element('instructorClassError'); @@ -229,6 +255,7 @@ export function createInstructorClassroomController({ if (element('instructorClassEntryCard')) element('instructorClassEntryCard').hidden = true; if (element('instructorClassDashboard')) element('instructorClassDashboard').hidden = false; if (element('instructorClassTitle')) element('instructorClassTitle').textContent = activeSession.class.title; + renderRailExpansion(); renderExpiry(activeSession.class.expiresAt); const joinCode = formatInstructorJoinCode(activeSession.joinCode || activeSession.class?.joinCode || ''); if (element('instructorJoinCode')) element('instructorJoinCode').textContent = joinCode || 'Unavailable'; @@ -379,9 +406,11 @@ export function createInstructorClassroomController({ if (element('instructorObservedWorkspace')) element('instructorObservedWorkspace').textContent = body.workspace?.label || 'Selected workspace'; if (element('instructorObservedRevision')) element('instructorObservedRevision').textContent = `Revision ${body.workspace?.revision || '—'}`; if (element('instructorObserverStatus')) element('instructorObserverStatus').textContent = 'Live read-only view'; + if (element('instructorObservationLiveBtn')) element('instructorObservationLiveBtn').hidden = true; const list = element('instructorObservationParticipants'); if (list) { + list.setAttribute('aria-label', 'Recent student activity'); list.replaceChildren(); const participants = Array.isArray(body.participants) ? body.participants : []; participants.forEach(participant => { @@ -402,6 +431,31 @@ export function createInstructorClassroomController({ } }; + const renderCheckpointObservation = checkpoint => { + if (element('instructorObservationNotice')) element('instructorObservationNotice').hidden = false; + if (element('instructorObservedWorkspace')) { + element('instructorObservedWorkspace').textContent = checkpoint.workspaceLabel || 'Selected workspace'; + } + if (element('instructorObservedRevision')) { + element('instructorObservedRevision').textContent = + `Checkpoint at debrief start · Revision ${checkpoint.workspaceRevision}`; + } + if (element('instructorObserverStatus')) { + element('instructorObserverStatus').textContent = 'Immutable checkpoint · live updates paused'; + } + if (element('instructorObservationLiveBtn')) element('instructorObservationLiveBtn').hidden = false; + + const list = element('instructorObservationParticipants'); + if (list) { + list.setAttribute('aria-label', 'Checkpoint inspection status'); + list.replaceChildren(); + const item = documentRef.createElement('li'); + item.className = 'instructor-observation-person instructor-observation-person--muted'; + item.textContent = 'Captured before debrief discussion'; + list.append(item); + } + }; + const markObserverStale = message => { if (element('instructorObserverStatus')) element('instructorObserverStatus').textContent = message || 'Reconnecting…'; }; @@ -496,6 +550,8 @@ export function createInstructorClassroomController({ try { apply?.(snapshot); } catch {} } latestObservation = null; + checkpointView = null; + if (element('instructorObservationLiveBtn')) element('instructorObservationLiveBtn').hidden = true; if (element('instructorObservationNotice')) element('instructorObservationNotice').hidden = true; const wrap = intakeWrap(); if (wrap && getActiveExperienceRole() === EXPERIENCE_ROLE_IDS.INSTRUCTOR) { @@ -505,6 +561,8 @@ export function createInstructorClassroomController({ }; const showObservedIntake = body => { + checkpointView = null; + if (element('instructorObservationLiveBtn')) element('instructorObservationLiveBtn').hidden = true; const revisionChanged = latestObservation?.workspace?.id !== body.workspace?.id || latestObservation?.workspace?.revision !== body.workspace?.revision; latestObservation = body; @@ -594,7 +652,13 @@ export function createInstructorClassroomController({ const scheduleObserver = epoch => { clearTimer(observerTimer); - if (!activeSession || !selectedWorkspaceId || destroyed || typeof setTimeoutImpl !== 'function') return; + if ( + !activeSession + || !selectedWorkspaceId + || checkpointView + || destroyed + || typeof setTimeoutImpl !== 'function' + ) return; observerTimer = setTimeoutImpl(() => { observerTimer = null; void observeWorkspace(epoch); @@ -616,7 +680,7 @@ export function createInstructorClassroomController({ }; const observeWorkspace = async epoch => { - if (!activeSession || !selectedWorkspaceId || destroyed) return false; + if (!activeSession || !selectedWorkspaceId || checkpointView || destroyed) return false; const requestedId = selectedWorkspaceId; abort(observerAbort); observerAbort = typeof AbortControllerImpl === 'function' ? new AbortControllerImpl() : null; @@ -654,8 +718,58 @@ export function createInstructorClassroomController({ } }; + async function inspectCheckpoint({ workspace, checkpoint } = {}) { + const workspaceId = typeof workspace?.id === 'string' ? workspace.id : ''; + const represented = workspaces.find(item => item.id === workspaceId); + const revision = Number(checkpoint?.workspaceRevision); + if ( + !activeSession + || !represented + || !checkpoint?.snapshot + || typeof checkpoint.snapshot !== 'object' + || !Number.isInteger(revision) + || revision < 1 + ) return false; + + selectedWorkspaceId = workspaceId; + saveSession(); + observerEpoch += 1; + abort(observerAbort); + observerAbort = null; + clearTimer(observerTimer); + observerTimer = null; + latestObservation = null; + checkpointView = { + workspaceId, + workspaceLabel: represented.label, + stageId: typeof checkpoint.stageId === 'string' ? checkpoint.stageId : '', + workspaceRevision: revision, + capturedAt: typeof checkpoint.capturedAt === 'string' ? checkpoint.capturedAt : null + }; + onObservationEnd(); + applyObservedSnapshot(checkpoint.snapshot); + const wrap = intakeWrap(); + if (wrap) { + wrap.hidden = false; + wrap.setAttribute('aria-hidden', 'false'); + } + setStatus('observing-checkpoint'); + renderCheckpointObservation(checkpointView); + renderRoster(); + return true; + } + + async function returnToLiveObservation() { + if (!checkpointView || !selectedWorkspaceId) return false; + checkpointView = null; + if (element('instructorObservationLiveBtn')) element('instructorObservationLiveBtn').hidden = true; + return selectWorkspace(selectedWorkspaceId); + } + async function selectWorkspace(workspaceId) { if (!activeSession || !workspaces.some(item => item.id === workspaceId)) return false; + checkpointView = null; + if (element('instructorObservationLiveBtn')) element('instructorObservationLiveBtn').hidden = true; selectedWorkspaceId = workspaceId; onObservationEnd(); saveSession(); @@ -1034,8 +1148,14 @@ export function createInstructorClassroomController({ ); }; const handleCopyJoinCode = () => { void copyJoinCode(); }; + const handleRailToggle = () => { setRailExpanded(!railExpanded, { user: true }); }; + const handleRailMediaChange = event => { + if (railPreferenceTouched) return; + setRailExpanded(!event.matches); + }; const handleRetry = () => { void resume(); }; const handleLeave = () => leaveClass(); + const handleReturnToLive = () => { void returnToLiveObservation(); }; const handleSearch = event => { searchQuery = event?.target?.value || ''; renderRoster(); @@ -1064,8 +1184,11 @@ export function createInstructorClassroomController({ element('instructorClassForm')?.addEventListener('submit', handleSubmit); element('instructorWorkspaceCreateForm')?.addEventListener('submit', handleWorkspaceCreate); element('instructorCopyJoinCodeBtn')?.addEventListener('click', handleCopyJoinCode); + element('instructorClassPanelToggle')?.addEventListener('click', handleRailToggle); + mobileRailQuery?.addEventListener?.('change', handleRailMediaChange); element('instructorClassRetryBtn')?.addEventListener('click', handleRetry); element('instructorClassLeaveBtn')?.addEventListener('click', handleLeave); + element('instructorObservationLiveBtn')?.addEventListener('click', handleReturnToLive); element('instructorWorkspaceSearch')?.addEventListener('input', handleSearch); element('instructorWorkspaceFilter')?.addEventListener('change', handleFilter); element('instructorWorkspaceList')?.addEventListener('keydown', handleRosterKeydown); @@ -1085,8 +1208,11 @@ export function createInstructorClassroomController({ element('instructorClassForm')?.removeEventListener('submit', handleSubmit); element('instructorWorkspaceCreateForm')?.removeEventListener('submit', handleWorkspaceCreate); element('instructorCopyJoinCodeBtn')?.removeEventListener('click', handleCopyJoinCode); + element('instructorClassPanelToggle')?.removeEventListener('click', handleRailToggle); + mobileRailQuery?.removeEventListener?.('change', handleRailMediaChange); element('instructorClassRetryBtn')?.removeEventListener('click', handleRetry); element('instructorClassLeaveBtn')?.removeEventListener('click', handleLeave); + element('instructorObservationLiveBtn')?.removeEventListener('click', handleReturnToLive); element('instructorWorkspaceSearch')?.removeEventListener('input', handleSearch); element('instructorWorkspaceFilter')?.removeEventListener('change', handleFilter); element('instructorWorkspaceList')?.removeEventListener('keydown', handleRosterKeydown); @@ -1104,10 +1230,15 @@ export function createInstructorClassroomController({ resume, refreshRoster, selectWorkspace, + inspectCheckpoint, + returnToLiveObservation, leaveClass, + setRailExpanded: expanded => setRailExpanded(expanded, { user: true }), getState: () => ({ activeSession, workspaces, participants, selectedWorkspaceId, observerEpoch, - latestObservation, busy, searchQuery, kindFilter, lastError + latestObservation, + checkpointView: checkpointView ? { ...checkpointView } : null, + busy, searchQuery, kindFilter, lastError, railExpanded }) }; } diff --git a/src/classroomStudent.js b/src/classroomStudent.js index 40ba2ae..df1107e 100644 --- a/src/classroomStudent.js +++ b/src/classroomStudent.js @@ -182,6 +182,8 @@ export function createStudentClassroomController({ toast = () => {}, onClassConnected = () => {}, onClassDisconnected = () => {}, + onSessionConnected = () => {}, + onSessionDisconnected = () => {}, setTimeoutImpl = globalThis.setTimeout?.bind(globalThis), clearTimeoutImpl = globalThis.clearTimeout?.bind(globalThis), AbortControllerImpl = globalThis.AbortController @@ -421,6 +423,7 @@ export function createStudentClassroomController({ liveEpoch += 1; stopLivePolling(); disconnectWorkspace(); + onSessionDisconnected(); clearStudentSession(storage); activeSession = null; restoreLocalRecovery(); @@ -686,6 +689,7 @@ export function createStudentClassroomController({ const epoch = liveEpoch; stopLivePolling(); persistStudentSession(storage, session); + onSessionConnected(session.studentSessionToken); renderHolding(session, { status: 'connecting', title: 'Joining your class', @@ -867,6 +871,7 @@ export function createStudentClassroomController({ liveEpoch += 1; stopLivePolling(); disconnectWorkspace(); + onSessionDisconnected(); clearStudentSession(storage); activeSession = null; const restored = restore ? restoreLocalRecovery() : false; @@ -899,6 +904,7 @@ export function createStudentClassroomController({ liveEpoch += 1; stopLivePolling(); disconnectWorkspace(); + if (activeSession?.mode === 'live') onSessionDisconnected(); restoreLocalRecovery({ clear: false }); }; @@ -918,6 +924,7 @@ export function createStudentClassroomController({ liveEpoch += 1; stopLivePolling(); onClassDisconnected(); + onSessionDisconnected(); element('studentClassJoinForm')?.removeEventListener('submit', handleJoinSubmit); element('studentClassRetryBtn')?.removeEventListener('click', handleRetry); element('studentClassLeaveBtn')?.removeEventListener('click', handleLeave); diff --git a/styles.css b/styles.css index b95897c..cd19d15 100644 --- a/styles.css +++ b/styles.css @@ -473,10 +473,70 @@ body.experience-role-gate-open{overflow:hidden;} .student-class-context__label{color:var(--muted);} .student-class-context__meta strong{font-size:12px;} .student-class-context small{display:block;margin-top:9px;color:var(--muted);font-size:11px;} +.student-case-reference{ + max-width:1240px; + margin:0 auto 14px; + padding:14px 16px; + border:1px solid color-mix(in srgb,var(--accent) 24%,var(--line)); + border-radius:16px; + background:var(--panel-raised); + box-shadow:var(--shadow); +} +.student-case-reference__header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;} +.student-case-reference__header>div{min-width:0;} +.student-case-reference__header strong{display:block;color:var(--ink-strong);font-size:15px;line-height:1.3;} +.student-case-reference__header [role="status"]{display:block;margin-top:3px;color:var(--muted);font-size:11px;line-height:1.35;} +.student-case-reference__toggle{ + flex:0 0 auto; + border:1px solid var(--control-border); + border-radius:9px; + background:var(--control-bg); + color:var(--control-ink); + padding:6px 8px; + font:inherit; + font-size:11px; + font-weight:750; + cursor:pointer; +} +.student-case-reference__toggle:hover{background:var(--control-hover);border-color:var(--accent);} +.student-case-reference__toggle:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring);} +.student-case-reference__body{margin-top:12px;padding-top:12px;border-top:1px solid var(--line);} +.student-case-reference.is-collapsed{padding-top:10px;padding-bottom:10px;} +.student-case-reference.is-collapsed .student-case-reference__body{display:none;} +.student-case-reference__stage>span{display:block;color:var(--muted);font-size:9px;font-weight:850;letter-spacing:.07em;text-transform:uppercase;} +.student-case-reference__stage>strong{display:block;margin-top:3px;color:var(--ink-strong);font-size:14px;} +.student-case-reference__stage>p{margin:4px 0 0;color:var(--muted);font-size:12px;line-height:1.5;} +.student-case-reference__content{display:flex;flex-direction:column;gap:8px;margin-top:12px;} +.student-case-reference__item{padding:10px;border:1px solid var(--line);border-radius:11px;background:var(--panel);} +.student-case-reference__item strong{display:block;color:var(--ink-strong);font-size:12px;} +.student-case-reference__item p{margin:4px 0 0;color:var(--ink);font-size:11px;line-height:1.5;overflow-wrap:anywhere;} +.student-case-reference__readiness{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;padding:10px;border:1px solid var(--line);border-radius:11px;background:color-mix(in srgb,var(--accent) 5%,var(--panel));} +.student-case-reference__readiness[hidden]{display:none;} +.student-case-reference__readiness>div{min-width:0;} +.student-case-reference__readiness span{display:block;color:var(--muted);font-size:9px;font-weight:850;letter-spacing:.07em;text-transform:uppercase;} +.student-case-reference__readiness strong{display:block;margin-top:3px;color:var(--ink-strong);font-size:11px;line-height:1.4;} +.student-case-reference__ready-btn{flex:0 0 auto;font-size:11px;padding:7px 10px;} +.student-case-reference__ready-btn[aria-pressed="true"]{border-color:color-mix(in srgb,var(--accent) 55%,var(--control-border));background:color-mix(in srgb,var(--accent) 12%,var(--control-bg));} +.student-case-reference__empty,.student-case-reference__message{margin:10px 0 0;color:var(--muted);font-size:11px;line-height:1.5;} +.student-case-reference__message{padding:8px 9px;border-radius:9px;background:color-mix(in srgb,var(--accent) 7%,var(--panel));color:var(--ink);} body[data-experience-role="student"][data-student-class-status="connected"] #studentClassEntryShell{display:none;} +body[data-experience-role="student"]:not([data-student-class-status="connected"]) #studentCaseReference{display:none;} body[data-experience-role="student"]:not([data-student-class-status="connected"]) [data-experience-surface="intake"]{display:none;} body[data-experience-role="student"]:not([data-student-class-status="connected"]) [data-experience-surface="intake-control"]{display:none;} @media(max-width:820px){ + .student-case-reference{margin:8px 12px 12px;padding:11px 12px;border-radius:14px;} + .student-case-reference__header{align-items:center;} + .student-case-reference.is-collapsed .student-case-reference__header [role="status"]{max-width:52vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .student-case-reference__toggle{padding:6px 7px;font-size:10px;white-space:nowrap;} + .student-case-reference__readiness{align-items:stretch;flex-direction:column;} + .student-case-reference__ready-btn{width:100%;} + body[data-experience-role="student"] .workspace-dock{ + position:static; + width:auto; + max-height:none; + margin:16px 12px 24px; + overflow:visible; + } .student-class-form__grid{grid-template-columns:1fr;} .student-class-entry{margin:20px auto 32px;padding:0 12px;} .student-class-entry__card{padding:26px 22px;} @@ -3163,6 +3223,11 @@ body.dialog-open{overflow:hidden;} .instructor-dashboard__header h2{margin:0;color:var(--ink-strong);font-size:22px;letter-spacing:-.02em;} .instructor-dashboard__header p{margin:6px 0 0;color:var(--muted);font-size:12px;} .instructor-dashboard__actions{display:flex;gap:8px;flex-wrap:wrap;} +.instructor-dashboard.is-collapsed{min-height:0;} +.instructor-dashboard.is-collapsed>:not(.instructor-dashboard__header){display:none !important;} +.instructor-dashboard.is-collapsed .instructor-dashboard__header{padding-bottom:0;border-bottom:0;align-items:center;} +.instructor-dashboard.is-collapsed .instructor-dashboard__header>div:first-child>:not(h2){display:none;} +.instructor-dashboard.is-collapsed .instructor-dashboard__actions>:not(#instructorClassPanelToggle){display:none;} .instructor-live-management{margin:16px 0;padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--panel-muted);} .instructor-live-management__heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;} .instructor-live-management__heading>div{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;} @@ -3197,25 +3262,41 @@ body.dialog-open{overflow:hidden;} .instructor-observation-notice__main>strong{display:block;color:var(--ink-strong);font-size:15px;} .instructor-observation-notice__meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:7px;color:var(--muted);font-size:11px;} .instructor-observation-notice__meta span{padding:4px 7px;border:1px solid var(--line);border-radius:999px;background:var(--panel);} +.instructor-observation-live-btn{margin-top:9px;padding:6px 9px;font-size:10px;} .instructor-observation-participants{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px;max-width:55%;margin:0;padding:0;list-style:none;} .instructor-observation-person{padding:5px 8px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:var(--ink);font-size:11px;} .instructor-observation-person--muted{color:var(--muted);} .instructor-observer-readonly [data-instructor-readonly-control]{cursor:default;} .instructor-observer-readonly button:disabled,.instructor-observer-readonly select:disabled,.instructor-observer-readonly input:disabled{opacity:1;} .instructor-observer-readonly input[readonly],.instructor-observer-readonly textarea[readonly]{cursor:text;} +.student-exercise-readonly [data-student-exercise-readonly-control]{cursor:default;} +.student-exercise-readonly button:disabled,.student-exercise-readonly select:disabled,.student-exercise-readonly input:disabled{opacity:.72;} +.student-exercise-readonly input[readonly],.student-exercise-readonly textarea[readonly]{cursor:text;} body[data-experience-role="instructor"].instructor-class-connected .instructor-shell{position:fixed;left:16px;top:132px;bottom:16px;width:330px;max-width:none;margin:0;padding:0;z-index:35;} body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .experience-shell__card{height:100%;padding:18px;border-radius:18px;overflow:hidden;} -body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .instructor-dashboard{overflow:hidden;} +body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .instructor-dashboard{overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;} +body[data-experience-role="instructor"].instructor-class-connected .instructor-workspace-list{flex-shrink:0;overflow:visible;padding-bottom:4px;} body[data-experience-role="instructor"].instructor-class-connected .wrap{max-width:none;margin-left:370px;margin-right:24px;} +body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .instructor-shell{bottom:auto;width:190px;} +body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .instructor-shell .experience-shell__card{height:auto;} +body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .wrap{margin-left:230px;} @media(max-width:980px){ - body[data-experience-role="instructor"].instructor-class-connected .instructor-shell{position:static;width:auto;max-width:1100px;margin:20px auto;padding:0 12px;} + body[data-experience-role="instructor"].instructor-class-connected .instructor-shell, + body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .instructor-shell{position:static;width:auto;max-width:1100px;margin:20px auto;padding:0 12px;} body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .experience-shell__card{height:auto;max-height:none;overflow:visible;} body[data-experience-role="instructor"].instructor-class-connected .instructor-workspace-list{max-height:360px;} - body[data-experience-role="instructor"].instructor-class-connected .wrap{margin-left:auto;margin-right:auto;} + body[data-experience-role="instructor"].instructor-class-connected .wrap, + body[data-experience-role="instructor"].instructor-class-connected.instructor-rail-collapsed .wrap{margin-left:auto;margin-right:auto;} } @media(max-width:700px){ + body[data-experience-role="instructor"].instructor-class-connected .instructor-shell{margin:10px auto 8px;padding:0 10px;} + body[data-experience-role="instructor"].instructor-class-connected .instructor-shell .experience-shell__card{padding:12px;border-radius:14px;} .instructor-dashboard__header,.instructor-observation-notice{flex-direction:column;align-items:stretch;} .instructor-dashboard__actions{flex-direction:column;} + .instructor-dashboard.is-collapsed .instructor-dashboard__header{flex-direction:row;align-items:center;gap:10px;} + .instructor-dashboard.is-collapsed .instructor-dashboard__header h2{font-size:17px;} + .instructor-dashboard.is-collapsed .instructor-dashboard__actions{flex-direction:row;flex:0 0 auto;} + .instructor-dashboard.is-collapsed #instructorClassPanelToggle{padding:7px 9px;font-size:10px;white-space:nowrap;} .instructor-roster-controls{grid-template-columns:1fr;} .instructor-workspace-create{grid-template-columns:1fr;} .instructor-participant-heading{align-items:flex-start;flex-direction:column;} @@ -3224,6 +3305,78 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid } +/* Instructor staged exercise console */ +.instructor-exercise-console{margin:16px 0 0;padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--panel);} +.instructor-exercise-console__heading{display:flex;align-items:center;justify-content:space-between;gap:10px;} +.instructor-exercise-console__heading>div{display:flex;align-items:baseline;gap:8px;min-width:0;flex-wrap:wrap;} +.instructor-exercise-console__heading strong{color:var(--ink-strong);font-size:13px;} +.instructor-exercise-console__heading span{color:var(--muted);font-size:10px;} +.instructor-exercise-console__heading .btn-secondary{padding:6px 9px;font-size:10px;} +.instructor-exercise-console__summary{margin-top:10px;padding:9px 10px;border-radius:11px;background:var(--panel-muted);} +.instructor-exercise-console__summary>strong{display:block;color:var(--ink-strong);font-size:12px;} +.instructor-exercise-console__summary p{margin:4px 0 0;color:var(--muted);font-size:10px;line-height:1.45;} +.instructor-exercise-console__setup{margin-top:10px;} +.instructor-exercise-console__setup>label{display:block;margin-bottom:5px;color:var(--ink-strong);font-size:10px;font-weight:750;} +.instructor-exercise-console__setup-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;} +.instructor-exercise-console__setup-row select{min-width:0;font-size:10px;} +.instructor-exercise-console__setup-row .btn-secondary{padding:7px 9px;font-size:10px;white-space:nowrap;} +.instructor-exercise-console__setup small{display:block;margin-top:5px;color:var(--muted);font-size:9px;line-height:1.4;} +.instructor-exercise-console__lifecycle{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:10px;} +.instructor-exercise-console__lifecycle .btn{background:var(--accent-text);border-color:var(--accent-text);} +.instructor-exercise-console__lifecycle small{flex-basis:100%;color:var(--muted);font-size:9px;line-height:1.4;} +.instructor-exercise-console__stage{margin-top:10px;padding:10px;border:1px solid var(--line);border-radius:11px;background:var(--panel-muted);} +.instructor-exercise-console__stage-heading{display:flex;flex-direction:column;gap:2px;} +.instructor-exercise-console__stage-heading span,.instructor-exercise-console__facilitation>span{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;} +.instructor-exercise-console__stage-heading strong{color:var(--ink-strong);font-size:12px;} +.instructor-exercise-console__stage>p{margin:6px 0 0;color:var(--ink);font-size:10px;line-height:1.45;} +.instructor-exercise-console__stage-meta{margin-top:6px;color:var(--muted);font-size:9px;} +.instructor-exercise-console__facilitation{margin-top:9px;padding-top:9px;border-top:1px solid var(--line);} +.instructor-exercise-console__facilitation article{margin-top:6px;} +.instructor-exercise-console__facilitation article strong{display:block;color:var(--ink-strong);font-size:10px;} +.instructor-exercise-console__facilitation article p{margin:3px 0 0;color:var(--ink);font-size:9px;line-height:1.4;} +.instructor-exercise-console__release{margin-top:9px;padding-top:9px;border-top:1px solid var(--line);} +.instructor-exercise-console__release>span{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;} +.instructor-exercise-console__release article{margin-top:6px;padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--panel);} +.instructor-exercise-console__release article strong{display:block;color:var(--ink-strong);font-size:10px;} +.instructor-exercise-console__release article p{margin:3px 0 6px;color:var(--ink);font-size:9px;line-height:1.4;} +.instructor-exercise-console__release-state{color:var(--muted);font-size:9px;font-weight:750;} +.instructor-exercise-console__progress{margin-top:10px;padding:10px;border:1px solid var(--line);border-radius:11px;background:var(--panel-muted);} +.instructor-exercise-console__progress-heading{display:flex;align-items:baseline;justify-content:space-between;gap:8px;} +.instructor-exercise-console__progress-heading strong{color:var(--ink-strong);font-size:11px;} +.instructor-exercise-console__progress-heading span{color:var(--muted);font-size:9px;} +.instructor-exercise-console__progress-list{display:flex;flex-direction:column;gap:5px;margin-top:7px;} +.instructor-exercise-progress__row{appearance:none;width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--ink);text-align:left;cursor:pointer;} +.instructor-exercise-progress__row:hover{border-color:color-mix(in srgb,var(--accent) 45%,var(--line));} +.instructor-exercise-progress__row:focus-visible{outline:none;box-shadow:0 0 0 3px var(--focus-ring);} +.instructor-exercise-progress__row>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px;} +.instructor-exercise-progress__state{flex:0 0 auto;font-size:9px;font-weight:800;} +.instructor-exercise-progress__state--ready{color:var(--accent-text);} +.instructor-exercise-progress__empty{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.4;} +.instructor-exercise-console__debrief{margin-top:10px;padding:10px;border:1px solid color-mix(in srgb,var(--accent) 30%,var(--line));border-radius:11px;background:color-mix(in srgb,var(--accent) 5%,var(--panel));} +.instructor-exercise-console__debrief-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;} +.instructor-exercise-console__debrief-heading>div:first-child{min-width:0;} +.instructor-exercise-console__debrief-heading strong{display:block;color:var(--ink-strong);font-size:11px;} +.instructor-exercise-console__debrief-heading span{display:block;margin-top:2px;color:var(--muted);font-size:9px;line-height:1.35;} +.instructor-exercise-console__editing-actions{display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end;} +.instructor-exercise-console__editing-actions .btn,.instructor-exercise-console__editing-actions .btn-secondary{padding:6px 8px;font-size:9px;white-space:nowrap;} +.instructor-exercise-console__checkpoints{margin-top:9px;padding-top:9px;border-top:1px solid var(--line);} +.instructor-exercise-console__checkpoint-heading{display:flex;align-items:baseline;justify-content:space-between;gap:8px;} +.instructor-exercise-console__checkpoint-heading span{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;} +.instructor-exercise-console__checkpoint-heading strong{color:var(--ink-strong);font-size:9px;} +.instructor-exercise-checkpoint-list{display:flex;flex-direction:column;gap:5px;margin-top:7px;} +.instructor-exercise-checkpoint__row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--panel);} +.instructor-exercise-checkpoint__row span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink);font-size:10px;} +.instructor-exercise-checkpoint__row strong{flex:0 0 auto;color:var(--muted);font-size:9px;} +.instructor-exercise-checkpoint__inspect{flex:0 0 auto;padding:5px 7px;font-size:9px;white-space:nowrap;} +.instructor-exercise-checkpoint__empty{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.4;} +.instructor-exercise-console__available{margin-top:9px;} +.instructor-exercise-console__available>span{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;} +.instructor-exercise-console__available ul{display:flex;flex-direction:column;gap:5px;max-height:92px;overflow:auto;margin:6px 0 0;padding:0;list-style:none;} +.instructor-exercise-console__available li{padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-muted);} +.instructor-exercise-console__available li strong{display:block;color:var(--ink-strong);font-size:11px;} +.instructor-exercise-console__available li span{display:block;margin-top:2px;color:var(--muted);font-size:9px;line-height:1.35;} +.instructor-exercise-console__available .instructor-exercise-console__empty{color:var(--muted);font-size:10px;line-height:1.4;} + /* Classroom coaching feedback */ .classroom-coaching{margin-top:10px;padding:10px 12px;border:1px solid var(--line);border-radius:12px;background:var(--panel-muted);font-size:12px;line-height:1.45;} .classroom-coaching__heading{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:7px;} @@ -3248,3 +3401,24 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid .classroom-coaching--student .classroom-coaching__heading{margin-bottom:4px;} .classroom-coaching--student .classroom-coaching__heading strong{font-size:11px;color:var(--ink-strong);} th .classroom-coaching{margin-top:8px;text-align:left;font-weight:400;} + +@media(max-width:700px){ + .instructor-exercise-console__debrief-heading{flex-direction:column;} + .instructor-exercise-console__editing-actions{justify-content:flex-start;} +} + +@media(min-width:1600px){ + body[data-experience-role="student"].student-case-reference-visible .student-case-reference{ + position:fixed; + top:24px; + left:24px; + z-index:68; + width:280px; + max-height:calc(100vh - 48px); + overflow:auto; + margin:0; + } + body[data-experience-role="student"].student-case-reference-visible .wrap{ + margin-left:340px; + } +} diff --git a/templates/AGENTS.md b/templates/AGENTS.md index c29a578..66e4ed8 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -39,6 +39,23 @@ Role policy remains separate from confidentiality: The rotating Case Study mode password is pedagogy only. Authorization comes from the active classroom capability. + + +## Optional staged simulation definition (#313) + +A protected `case-study` may later add a server-only `simulation` object validated by `scripts/build-templates-manifest.mjs`. Standard Templates must not use this field. + +Keep three concepts separate: +- `state` — complete protected source/exemplar Intake payload; +- `simulation.studentContent` — only content blocks eligible for controlled Student release; +- `simulation.instructorContent` — facilitation/debrief/model material that Student endpoints must never return. + +`simulation.stages` references stable content IDs. Stage/content IDs must be unique and cross-references valid. Future-stage Student content must remain server-gated, not preloaded into browser assets. + +Do not infer or fabricate official stage boundaries from a completed `state`. Until authoritative case pages/evidence/facilitation material is supplied and reviewed, use synthetic staged definitions only in deterministic tests/fixtures rather than production authored Case Studies. + +Read `docs/classroom-staged-simulation.md` for the full lifecycle and authorization contract before adding or editing staged content. + ## Editing Workflow 1. Edit/add the authored JSON under `templates/`. 2. Run `npm run build:templates`. diff --git a/tests/classroom-exercise-api.unit.test.mjs b/tests/classroom-exercise-api.unit.test.mjs new file mode 100644 index 0000000..0a488c7 --- /dev/null +++ b/tests/classroom-exercise-api.unit.test.mjs @@ -0,0 +1,796 @@ +/** + * Focused #313 exercise API / progressive-disclosure authorization tests. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + classExerciseCheckpointHandler, + classExerciseHandler, + classStudentExerciseHandler, + classStudentExerciseReadyHandler, + fingerprintStagedSimulation +} from '../api/_classroomExercise.js'; +import { createClassroomStudentWritePolicy } from '../api/_classroomWritePolicy.js'; +import { studentCaseStudiesHandler } from '../api/_protectedCaseStudies.js'; +import { workspaceHandler } from '../api/_workspace.js'; +import { + createClassroomRepository, + createWorkspaceRepository, + response, + testTokenHash +} from './helpers/classroom-test-repositories.mjs'; + +const CLASS_A_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const WORKSPACE_A_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const WORKSPACE_B_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const EXERCISE_ID = '11111111-1111-4111-8111-111111111111'; +const PARTICIPANT_A = '22222222-2222-4222-8222-222222222222'; +const PARTICIPANT_B = '33333333-3333-4333-8333-333333333333'; + +const STAGED_CASE = Object.freeze({ + id: 'synthetic-staged-case', + name: 'Synthetic Staged Case', + description: 'Deterministic staged Case Study for server authorization tests.', + templateKind: 'case-study', + supportedModes: ['full'], + state: { pre: { oneLine: 'COMPLETE SOURCE / EXEMPLAR — NEVER STAGED TO STUDENT' } }, + simulation: { + version: 1, + studentContent: [ + { id: 'brief-1', kind: 'narrative', title: 'Initial briefing', body: 'Student Stage 1 briefing.' }, + { id: 'hint-1', kind: 'evidence', title: 'Optional hint', body: 'Optional Student evidence.' }, + { id: 'evidence-1', kind: 'evidence', title: 'Second evidence', body: 'Second optional Student fact.' }, + { id: 'brief-2', kind: 'narrative', title: 'Stage 2 briefing', body: 'Student Stage 2 briefing.' } + ], + instructorContent: [ + { id: 'teach-1', kind: 'facilitation', title: 'Stage 1 teaching', body: 'INSTRUCTOR ONLY STAGE 1.' }, + { id: 'answer-2', kind: 'exemplar', title: 'Stage 2 exemplar', body: 'INSTRUCTOR ONLY MODEL ANSWER.' } + ], + stages: [ + { + id: 'stage-1', + title: 'Clarify the situation', + studentObjective: 'Understand the initial situation.', + initialReleaseIds: ['brief-1'], + optionalReleaseIds: ['hint-1', 'evidence-1'], + intakeTargetIds: ['problem.one-line'], + suggestedMinutes: 10, + instructorContentIds: ['teach-1'], + defaultDebriefEditPolicy: 'frozen' + }, + { + id: 'stage-2', + title: 'Analyze new evidence', + studentObjective: 'Integrate the newly released evidence.', + initialReleaseIds: ['brief-2'], + optionalReleaseIds: [], + intakeTargetIds: ['problem.one-line'], + suggestedMinutes: 8, + instructorContentIds: ['answer-2'], + defaultDebriefEditPolicy: 'open' + } + ] + } +}); + +const NON_STAGED_CASE = Object.freeze({ + id: 'ordinary-case', + name: 'Ordinary Case', + description: 'Protected but not staged.', + templateKind: 'case-study', + supportedModes: ['full'], + state: { pre: { oneLine: 'Ordinary full protected payload' } } +}); + +const MANIFEST = Object.freeze([STAGED_CASE, NON_STAGED_CASE]); + +async function setupClass({ withSecondWorkspace = false } = {}) { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + + const workspaceA = await workspaceRepo.create( + testTokenHash('P'), + { pre: { oneLine: 'Team A before debrief' } }, + 30, + 'Team A' + ); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_A_ID, + workspaceId: workspaceA.id, + kind: 'group', + label: 'Team A', + claimHash: testTokenHash('X') + }); + + let workspaceB = null; + if (withSecondWorkspace) { + workspaceB = await workspaceRepo.create( + testTokenHash('Q'), + { pre: { oneLine: 'Team B before debrief' } }, + 30, + 'Team B' + ); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_B_ID, + workspaceId: workspaceB.id, + kind: 'group', + label: 'Team B', + claimHash: testTokenHash('Y') + }); + } + + return { classrooms, workspaceRepo, workspaceA, workspaceB }; +} + +async function admit(classrooms, { + participantId = PARTICIPANT_A, + name = 'Alex', + session = 'S' +} = {}) { + return classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId, + displayName: name, + sessionHash: testTokenHash(session) + }); +} + +function instructorHandler(classrooms, workspaceRepo) { + return classExerciseHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + manifest: MANIFEST, + idFactory: () => EXERCISE_ID + }); +} + +function instructorCheckpointHandler(classrooms) { + return classExerciseCheckpointHandler({ + getRepository: async () => classrooms, + manifest: MANIFEST + }); +} + +async function createExercise(handler) { + const created = response(); + await handler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, created); + assert.equal(created.statusCode, 201); + return created; +} + +async function patchExercise(handler, body) { + const res = response(); + await handler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body + }, res); + return res; +} + +test('staged definition fingerprint is canonical across object key order', () => { + const reordered = { + stages: STAGED_CASE.simulation.stages, + instructorContent: STAGED_CASE.simulation.instructorContent, + studentContent: STAGED_CASE.simulation.studentContent, + version: 1 + }; + assert.equal( + fingerprintStagedSimulation(STAGED_CASE.simulation), + fingerprintStagedSimulation(reordered) + ); + assert.match(fingerprintStagedSimulation(STAGED_CASE.simulation), /^[a-f0-9]{64}$/u); +}); + +test('Instructor exercise GET discovers staged Case Studies without exposing simulation definitions', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + const handler = instructorHandler(classrooms, workspaceRepo); + const res = response(); + await handler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, res); + + assert.equal(res.statusCode, 200); + assert.equal(res.body.exercise, null); + assert.deepEqual(res.body.availableCaseStudies.map(item => item.id), [STAGED_CASE.id]); + assert.equal(res.body.availableCaseStudies[0].name, STAGED_CASE.name); + assert.equal('simulation' in res.body.availableCaseStudies[0], false); + assert.equal('state' in res.body.availableCaseStudies[0], false); + assert.equal(JSON.stringify(res.body.availableCaseStudies).includes('INSTRUCTOR ONLY'), false); + assert.equal(JSON.stringify(res.body.availableCaseStudies).includes('COMPLETE SOURCE'), false); +}); + +test('Instructor exercise creation is class-authorized, staged-only, idempotent, and definition-pinned', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + const handler = instructorHandler(classrooms, workspaceRepo); + + const nonStaged = response(); + await handler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { caseStudyId: NON_STAGED_CASE.id } + }, nonStaged); + assert.equal(nonStaged.statusCode, 404); + + const created = await createExercise(handler); + assert.equal(created.body.created, true); + assert.equal(created.body.exercise.exerciseRevision, 1); + assert.equal(created.body.exercise.simulationVersion, 1); + assert.equal( + created.body.exercise.simulationFingerprint, + fingerprintStagedSimulation(STAGED_CASE.simulation) + ); + assert.equal(created.body.caseStudy.simulation.instructorContent[0].body, 'INSTRUCTOR ONLY STAGE 1.'); + + const replay = response(); + await handler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, replay); + assert.equal(replay.statusCode, 200); + assert.equal(replay.body.created, false); + assert.equal(classrooms.exercises.length, 1); + + const studentCredential = response(); + await handler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, studentCredential); + assert.equal(studentCredential.statusCode, 404); + + const humanJoinCode = response(); + await handler({ + method: 'GET', + headers: { authorization: 'Bearer K7FM-P4Q2' } + }, humanJoinCode); + assert.equal(humanJoinCode.statusCode, 400); +}); + +test('Instructor lifecycle is revision-safe, optional release is idempotent, and debrief checkpoint transition is atomic', async () => { + const { classrooms, workspaceRepo } = await setupClass({ withSecondWorkspace: true }); + const handler = instructorHandler(classrooms, workspaceRepo); + await createExercise(handler); + + const started = await patchExercise(handler, { action: 'start', expectedRevision: 1 }); + assert.equal(started.statusCode, 200); + assert.equal(started.body.exercise.currentStageId, 'stage-1'); + assert.equal(started.body.exercise.exerciseRevision, 2); + + const released = await patchExercise(handler, { + action: 'release-content', + expectedRevision: 2, + contentId: 'hint-1' + }); + assert.equal(released.statusCode, 200); + assert.equal(released.body.changed, true); + assert.equal(released.body.exercise.exerciseRevision, 3); + assert.deepEqual(released.body.releases.map(item => item.contentId), ['hint-1']); + + const releaseReplay = await patchExercise(handler, { + action: 'release-content', + expectedRevision: 2, + contentId: 'hint-1' + }); + assert.equal(releaseReplay.statusCode, 200); + assert.equal(releaseReplay.body.changed, false); + assert.equal(releaseReplay.body.exercise.exerciseRevision, 3); + + const staleDifferentRelease = await patchExercise(handler, { + action: 'release-content', + expectedRevision: 2, + contentId: 'evidence-1' + }); + assert.equal(staleDifferentRelease.statusCode, 409); + assert.equal(staleDifferentRelease.body.exercise.exerciseRevision, 3); + + const debrief = await patchExercise(handler, { + action: 'begin-debrief', + expectedRevision: 3 + }); + assert.equal(debrief.statusCode, 200); + assert.equal(debrief.body.exercise.stagePhase, 'debrief'); + assert.equal(debrief.body.exercise.exerciseRevision, 4); + assert.equal(debrief.body.capturedCount, 2); + assert.equal(debrief.body.checkpoints.length, 2); + assert.equal(debrief.body.checkpoints.some(item => Object.hasOwn(item, 'snapshot')), false); + assert.equal(debrief.body.editFreezeEnforced, true); + assert.equal(debrief.body.exercise.studentEditingEnabled, false); + + await workspaceRepo.update( + testTokenHash('P'), + { pre: { oneLine: 'Team A changed after checkpoint' } }, + 1 + ); + const checkpointRead = response(); + await instructorCheckpointHandler(classrooms)({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + query: { workspaceId: WORKSPACE_A_ID } + }, checkpointRead); + assert.equal(checkpointRead.statusCode, 200); + assert.equal(checkpointRead.body.workspace.id, WORKSPACE_A_ID); + assert.equal(checkpointRead.body.checkpoint.workspaceRevision, 1); + assert.equal(checkpointRead.body.checkpoint.snapshot.pre.oneLine, 'Team A before debrief'); + + const studentCheckpointRead = response(); + await instructorCheckpointHandler(classrooms)({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) }, + query: { workspaceId: WORKSPACE_A_ID } + }, studentCheckpointRead); + assert.equal(studentCheckpointRead.statusCode, 404); + + const invalidCheckpointRead = response(); + await instructorCheckpointHandler(classrooms)({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + query: { workspaceId: 'not-a-workspace' } + }, invalidCheckpointRead); + assert.equal(invalidCheckpointRead.statusCode, 400); + + const staleDebriefReplay = await patchExercise(handler, { + action: 'begin-debrief', + expectedRevision: 3 + }); + assert.equal(staleDebriefReplay.statusCode, 409); + + const advanced = await patchExercise(handler, { action: 'advance', expectedRevision: 4 }); + assert.equal(advanced.statusCode, 200); + assert.equal(advanced.body.exercise.currentStageId, 'stage-2'); + assert.equal(advanced.body.exercise.stagePhase, 'work'); + assert.equal(advanced.body.exercise.exerciseRevision, 5); + + const cannotCompleteWork = await patchExercise(handler, { action: 'complete', expectedRevision: 5 }); + assert.equal(cannotCompleteWork.statusCode, 409); + + const stage2Debrief = await patchExercise(handler, { + action: 'begin-debrief', + expectedRevision: 5 + }); + assert.equal(stage2Debrief.statusCode, 200); + assert.equal(stage2Debrief.body.exercise.exerciseRevision, 6); + + const completed = await patchExercise(handler, { action: 'complete', expectedRevision: 6 }); + assert.equal(completed.statusCode, 200); + assert.equal(completed.body.exercise.status, 'completed'); + assert.equal(completed.body.exercise.exerciseRevision, 7); + + const current = response(); + await handler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, current); + assert.equal(current.statusCode, 200); + assert.equal(current.body.exercise, null); +}); + +test('Student staged read exposes only cumulative released Student content and never future or Instructor material', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + const instructor = instructorHandler(classrooms, workspaceRepo); + await createExercise(instructor); + + const student = classStudentExerciseHandler({ + getRepository: async () => classrooms, + manifest: MANIFEST + }); + + const draft = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, draft); + assert.equal(draft.statusCode, 200); + assert.equal(draft.body.assignment, null); + assert.equal(draft.body.exercise.currentStage, null); + assert.deepEqual(draft.body.exercise.releasedContent, []); + + await patchExercise(instructor, { action: 'start', expectedRevision: 1 }); + + const stage1 = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, stage1); + assert.equal(stage1.statusCode, 200); + assert.equal(stage1.body.exercise.currentStage.id, 'stage-1'); + assert.deepEqual( + stage1.body.exercise.releasedContent.map(item => item.content.id), + ['brief-1'] + ); + const stage1Json = JSON.stringify(stage1.body); + assert.equal(stage1Json.includes('Stage 2 briefing'), false); + assert.equal(stage1Json.includes('INSTRUCTOR ONLY'), false); + assert.equal(stage1Json.includes('MODEL ANSWER'), false); + assert.equal(stage1Json.includes('COMPLETE SOURCE'), false); + + await patchExercise(instructor, { + action: 'release-content', + expectedRevision: 2, + contentId: 'hint-1' + }); + const withHint = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, withHint); + assert.deepEqual( + withHint.body.exercise.releasedContent.map(item => item.content.id), + ['brief-1', 'hint-1'] + ); + + await patchExercise(instructor, { action: 'begin-debrief', expectedRevision: 3 }); + await patchExercise(instructor, { action: 'advance', expectedRevision: 4 }); + + const stage2 = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, stage2); + assert.equal(stage2.body.exercise.currentStage.id, 'stage-2'); + assert.deepEqual( + stage2.body.exercise.releasedContent.map(item => item.content.id), + ['brief-1', 'hint-1', 'brief-2'] + ); + assert.equal(stage2.body.exercise.editFreezeEnforced, true); + assert.equal(stage2.body.exercise.studentEditingEnabled, true); + + const instructorCredential = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, instructorCredential); + assert.equal(instructorCredential.statusCode, 404); +}); + +test('server-enforced debrief freeze blocks Classroom Student PUT without mutating Intake while Standalone remains writable', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + await classrooms.issueParticipantWorkspaceAccess({ + sessionHash: testTokenHash('S'), + accessHash: testTokenHash('W'), + workspaceRepository: workspaceRepo + }); + + await workspaceRepo.create( + testTokenHash('Z'), + { pre: { oneLine: 'Standalone before' } }, + 30, + 'Standalone' + ); + + const instructor = instructorHandler(classrooms, workspaceRepo); + await createExercise(instructor); + await patchExercise(instructor, { action: 'start', expectedRevision: 1 }); + + const session = workspaceHandler({ + getRepository: async () => workspaceRepo, + getWritePolicy: createClassroomStudentWritePolicy({ + getRepository: async () => classrooms + }) + }); + + const beforeDebrief = response(); + await session({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 1, + snapshot: { pre: { oneLine: 'Team A during work' } } + } + }, beforeDebrief); + assert.equal(beforeDebrief.statusCode, 200); + assert.equal(beforeDebrief.body.revision, 2); + + const debrief = await patchExercise(instructor, { + action: 'begin-debrief', + expectedRevision: 2 + }); + assert.equal(debrief.statusCode, 200); + assert.equal(debrief.body.exercise.studentEditingEnabled, false); + assert.equal(debrief.body.editFreezeEnforced, true); + + const frozenWrite = response(); + await session({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 2, + snapshot: { pre: { oneLine: 'MUST NOT SAVE' } } + } + }, frozenWrite); + assert.equal(frozenWrite.statusCode, 423); + assert.equal(frozenWrite.body.code, 'classroom-editing-locked'); + + const afterFrozenWrite = await workspaceRepo.load(testTokenHash('W')); + assert.equal(afterFrozenWrite.revision, 2); + assert.equal(afterFrozenWrite.snapshot.pre.oneLine, 'Team A during work'); + + const frozenRead = response(); + await session({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + query: {} + }, frozenRead); + assert.equal(frozenRead.statusCode, 200); + assert.equal(frozenRead.body.revision, 2); + assert.equal(frozenRead.body.snapshot.pre.oneLine, 'Team A during work'); + + const standaloneWrite = response(); + await session({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'Z'.repeat(43) }, + body: { + revision: 1, + snapshot: { pre: { oneLine: 'Standalone changed while class is frozen' } } + } + }, standaloneWrite); + assert.equal(standaloneWrite.statusCode, 200); + assert.equal(standaloneWrite.body.revision, 2); + + const paused = await patchExercise(instructor, { + action: 'pause', + expectedRevision: 3 + }); + assert.equal(paused.statusCode, 200); + assert.equal(paused.body.exercise.studentEditingEnabled, false); + assert.equal(paused.body.exercise.exerciseRevision, 4); + + const resumed = await patchExercise(instructor, { + action: 'resume', + expectedRevision: 4 + }); + assert.equal(resumed.statusCode, 200); + assert.equal(resumed.body.exercise.studentEditingEnabled, false); + assert.equal(resumed.body.exercise.exerciseRevision, 5); + + const unfreeze = await patchExercise(instructor, { + action: 'set-editing', + expectedRevision: 5, + enabled: true + }); + assert.equal(unfreeze.statusCode, 200); + assert.equal(unfreeze.body.exercise.studentEditingEnabled, true); + assert.equal(unfreeze.body.exercise.exerciseRevision, 6); + + const afterUnfreeze = response(); + await session({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 2, + snapshot: { pre: { oneLine: 'Team A after unfreeze' } } + } + }, afterUnfreeze); + assert.equal(afterUnfreeze.statusCode, 200); + assert.equal(afterUnfreeze.body.revision, 3); + + const finalTeamA = await workspaceRepo.load(testTokenHash('W')); + assert.equal(finalTeamA.snapshot.pre.oneLine, 'Team A after unfreeze'); +}); + +test('Student Ready records the server-observed current workspace revision and Waiting cannot mark Ready', async () => { + const { classrooms, workspaceRepo, workspaceA } = await setupClass(); + await admit(classrooms); + await admit(classrooms, { + participantId: PARTICIPANT_B, + name: 'Blair', + session: 'T' + }); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + + const instructor = instructorHandler(classrooms, workspaceRepo); + await createExercise(instructor); + await patchExercise(instructor, { action: 'start', expectedRevision: 1 }); + + const readyHandler = classStudentExerciseReadyHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + manifest: MANIFEST + }); + + const ready = response(); + await readyHandler({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) }, + body: { ready: true, workspaceRevision: 999999 } + }, ready); + assert.equal(ready.statusCode, 200); + assert.equal(ready.body.readiness.readyWorkspaceRevision, 1); + assert.equal(ready.body.assignment.id, WORKSPACE_A_ID); + + await workspaceRepo.update( + testTokenHash('P'), + { pre: { oneLine: 'New Team A work' } }, + workspaceA.revision + ); + + const refreshed = response(); + await readyHandler({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) }, + body: { ready: true } + }, refreshed); + assert.equal(refreshed.statusCode, 200); + assert.equal(refreshed.body.readiness.readyWorkspaceRevision, 2); + + const waiting = response(); + await readyHandler({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'T'.repeat(43) }, + body: { ready: true } + }, waiting); + assert.equal(waiting.statusCode, 409); + assert.equal(waiting.body.status, 'waiting'); +}); + +test('readiness race guard refuses to attach an old workspace revision after reassignment', async () => { + const { classrooms, workspaceRepo, workspaceA } = await setupClass({ withSecondWorkspace: true }); + await admit(classrooms); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_ID, + caseStudyId: STAGED_CASE.id, + simulationVersion: 1, + simulationFingerprint: fingerprintStagedSimulation(STAGED_CASE.simulation) + }); + await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_ID, + expectedRevision: 1, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + + const beforeMove = await classrooms.getParticipantBySession(testTokenHash('S')); + const observedA = await workspaceRepo.observeById(beforeMove.internal.workspaceId); + assert.equal(observedA.revision, workspaceA.revision); + + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_B_ID, + workspaceRepository: workspaceRepo + }); + + const stale = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: observedA.revision, + expectedWorkspaceInternalId: beforeMove.internal.workspaceId + }); + assert.equal(stale.status, 'conflict'); + + const state = await classrooms.listExerciseWorkspaceStateForInstructor( + testTokenHash('A'), + EXERCISE_ID + ); + assert.deepEqual(state.workspaceState, []); +}); + +test('definition drift fails closed for Instructor and Student staged reads', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + const instructor = instructorHandler(classrooms, workspaceRepo); + await createExercise(instructor); + classrooms.exercises[0].simulationFingerprint = 'b'.repeat(64); + + const instructorRead = response(); + await instructor({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, instructorRead); + assert.equal(instructorRead.statusCode, 409); + + const student = classStudentExerciseHandler({ + getRepository: async () => classrooms, + manifest: MANIFEST + }); + const studentRead = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, studentRead); + assert.equal(studentRead.statusCode, 409); +}); + +test('Student full Case Study payload cannot bypass a staged run, including after completion', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + await classrooms.issueParticipantWorkspaceAccess({ + sessionHash: testTokenHash('S'), + accessHash: testTokenHash('W'), + workspaceRepository: workspaceRepo + }); + + const protectedHandler = studentCaseStudiesHandler({ + getRepository: async () => classrooms, + manifest: MANIFEST + }); + + const beforeExercise = response(); + await protectedHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, beforeExercise); + assert.equal(beforeExercise.statusCode, 200); + assert.equal(beforeExercise.body.caseStudy.state.pre.oneLine.includes('COMPLETE SOURCE'), true); + + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_ID, + caseStudyId: STAGED_CASE.id, + simulationVersion: 1, + simulationFingerprint: fingerprintStagedSimulation(STAGED_CASE.simulation) + }); + + const duringExercise = response(); + await protectedHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, duringExercise); + assert.equal(duringExercise.statusCode, 409); + assert.equal(JSON.stringify(duringExercise.body).includes('COMPLETE SOURCE'), false); + + const unrelated = response(); + await protectedHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { caseStudyId: NON_STAGED_CASE.id } + }, unrelated); + assert.equal(unrelated.statusCode, 200); + + await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_ID, + expectedRevision: 1, + status: 'completed', + currentStageId: null, + stagePhase: 'work', + studentEditingEnabled: true + }); + + const afterCompletion = response(); + await protectedHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, afterCompletion); + assert.equal(afterCompletion.statusCode, 409); +}); diff --git a/tests/classroom-exercise-repository.unit.test.mjs b/tests/classroom-exercise-repository.unit.test.mjs new file mode 100644 index 0000000..2c8fa6c --- /dev/null +++ b/tests/classroom-exercise-repository.unit.test.mjs @@ -0,0 +1,461 @@ +/** + * Repository-level contract tests for #313 staged exercise persistence. + * + * These intentionally stop below HTTP/UI. They prove the deterministic repository + * semantics that the later exercise handlers will orchestrate. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createClassroomRepository, + createWorkspaceRepository, + testTokenHash +} from './helpers/classroom-test-repositories.mjs'; + +const CLASS_A_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const CLASS_B_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const EXERCISE_A_ID = '11111111-1111-4111-8111-111111111111'; +const EXERCISE_A2_ID = '22222222-2222-4222-8222-222222222222'; +const EXERCISE_B_ID = '33333333-3333-4333-8333-333333333333'; +const WORKSPACE_A_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const WORKSPACE_B_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const WORKSPACE_C_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; +const PARTICIPANT_A = '44444444-4444-4444-8444-444444444444'; +const SIMULATION_FINGERPRINT = 'a'.repeat(64); + +async function classPair(classrooms) { + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + await classrooms.createClass({ + publicId: CLASS_B_ID, + title: 'Class B', + instructorHash: testTokenHash('B'), + studentJoinHash: testTokenHash('D'), + studentJoinCode: 'M8RNQ5W3' + }); +} + +async function addWorkspace(classrooms, workspaceRepo, { + instructor = 'A', + publicId = WORKSPACE_A_ID, + capability = 'P', + claim = 'X', + label = 'Team A' +} = {}) { + const workspace = await workspaceRepo.create( + testTokenHash(capability), + { pre: { oneLine: label + ' Intake' } }, + 30, + label + ); + await classrooms.addWorkspace(testTokenHash(instructor), { + publicId, + workspaceId: workspace.id, + kind: 'group', + label, + claimHash: testTokenHash(claim) + }); + return workspace; +} + +test('exercise creation permits one open exercise per class and inherits class retention', async () => { + const classrooms = createClassroomRepository(); + await classPair(classrooms); + + const createdA = await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT + }); + assert.equal(createdA.status, 'created'); + assert.equal(createdA.exercise.status, 'draft'); + assert.equal(createdA.exercise.simulationVersion, 1); + assert.equal(createdA.exercise.simulationFingerprint, SIMULATION_FINGERPRINT); + assert.equal(createdA.exercise.exerciseRevision, 1); + assert.equal(createdA.exercise.currentStageId, null); + assert.equal(createdA.exercise.expiresAt, createdA.classroom.expiresAt); + + const duplicateOpen = await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A2_ID, + caseStudyId: 'another-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT + }); + assert.equal(duplicateOpen.status, 'exists'); + assert.equal(duplicateOpen.exercise.id, EXERCISE_A_ID); + assert.equal(classrooms.exercises.length, 1); + + const createdB = await classrooms.createExercise(testTokenHash('B'), { + publicId: EXERCISE_B_ID, + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT + }); + assert.equal(createdB.status, 'created'); + assert.equal(createdB.classroom.id, CLASS_B_ID); + assert.equal(classrooms.exercises.length, 2); + + assert.equal(await classrooms.getExerciseForInstructor(testTokenHash('B'), EXERCISE_A_ID), null); + + await classrooms.revokeClass(testTokenHash('A')); + assert.equal(await classrooms.getCurrentExerciseForInstructor(testTokenHash('A')), null); +}); + +test('exercise lifecycle mutation is optimistic and completion permits a later exercise', async () => { + const classrooms = createClassroomRepository(); + await classPair(classrooms); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT + }); + + const started = await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 1, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + assert.equal(started.status, 'updated'); + assert.equal(started.exercise.exerciseRevision, 2); + assert.equal(started.exercise.startedAt, 'started'); + + const stale = await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 1, + status: 'paused', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + assert.equal(stale.status, 'conflict'); + assert.equal(stale.exercise.status, 'active'); + assert.equal(stale.exercise.exerciseRevision, 2); + + const debrief = await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 2, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'debrief', + studentEditingEnabled: false + }); + assert.equal(debrief.exercise.exerciseRevision, 3); + assert.equal(debrief.exercise.stagePhase, 'debrief'); + assert.equal(debrief.exercise.studentEditingEnabled, false); + + const completed = await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 3, + status: 'completed', + currentStageId: 'stage-1', + stagePhase: 'debrief', + studentEditingEnabled: false + }); + assert.equal(completed.exercise.exerciseRevision, 4); + assert.equal(completed.exercise.completedAt, 'completed'); + assert.equal(await classrooms.getCurrentExerciseForInstructor(testTokenHash('A')), null); + + const next = await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A2_ID, + caseStudyId: 'next-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT + }); + assert.equal(next.status, 'created'); + assert.equal(next.exercise.id, EXERCISE_A2_ID); +}); + +test('optional release is idempotent and only a new release advances exercise revision', async () => { + const classrooms = createClassroomRepository(); + await classPair(classrooms); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT + }); + await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 1, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + + const first = await classrooms.releaseExerciseContent(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 2, + stageId: 'stage-1', + contentId: 'hint-1' + }); + assert.equal(first.status, 'updated'); + assert.equal(first.exercise.exerciseRevision, 3); + + const replay = await classrooms.releaseExerciseContent(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 2, + stageId: 'stage-1', + contentId: 'hint-1' + }); + assert.equal(replay.status, 'unchanged'); + assert.equal(replay.exercise.exerciseRevision, 3); + + const staleDifferentRelease = await classrooms.releaseExerciseContent(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 2, + stageId: 'stage-1', + contentId: 'evidence-2' + }); + assert.equal(staleDifferentRelease.status, 'conflict'); + assert.equal(staleDifferentRelease.exercise.exerciseRevision, 3); + + const second = await classrooms.releaseExerciseContent(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 3, + stageId: 'stage-1', + contentId: 'evidence-2' + }); + assert.equal(second.status, 'updated'); + assert.equal(second.exercise.exerciseRevision, 4); + + const listed = await classrooms.listExerciseReleasesForInstructor( + testTokenHash('A'), + EXERCISE_A_ID + ); + assert.deepEqual( + listed.releases.map(({ stageId, contentId }) => ({ stageId, contentId })), + [ + { stageId: 'stage-1', contentId: 'evidence-2' }, + { stageId: 'stage-1', contentId: 'hint-1' } + ] + ); + assert.equal(await classrooms.listExerciseReleasesForInstructor(testTokenHash('B'), EXERCISE_A_ID), null); +}); + +test('readiness belongs to the current workspace and reassignment never carries old-team readiness', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classPair(classrooms); + await addWorkspace(classrooms, workspaceRepo, { + publicId: WORKSPACE_A_ID, + capability: 'P', + claim: 'X', + label: 'Team A' + }); + await addWorkspace(classrooms, workspaceRepo, { + publicId: WORKSPACE_B_ID, + capability: 'Q', + claim: 'Y', + label: 'Team B' + }); + + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT + }); + await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 1, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + + const readyA = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: 7 + }); + assert.equal(readyA.status, 'updated'); + assert.equal(readyA.workspace.id, WORKSPACE_A_ID); + assert.equal(readyA.readiness.readyWorkspaceRevision, 7); + + const replay = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: 7 + }); + assert.equal(replay.status, 'unchanged'); + + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_B_ID, + workspaceRepository: workspaceRepo + }); + + const beforeReadyB = await classrooms.listExerciseWorkspaceStateForInstructor( + testTokenHash('A'), + EXERCISE_A_ID + ); + assert.deepEqual(beforeReadyB.workspaceState.map(state => state.workspaceId), [WORKSPACE_A_ID]); + + const readyB = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: 3 + }); + assert.equal(readyB.workspace.id, WORKSPACE_B_ID); + + const both = await classrooms.listExerciseWorkspaceStateForInstructor( + testTokenHash('A'), + EXERCISE_A_ID + ); + assert.deepEqual( + both.workspaceState.map(state => ({ + workspaceId: state.workspaceId, + revision: state.readyWorkspaceRevision + })), + [ + { workspaceId: WORKSPACE_A_ID, revision: 7 }, + { workspaceId: WORKSPACE_B_ID, revision: 3 } + ] + ); + + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: null, + workspaceRepository: workspaceRepo + }); + const waiting = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: 4 + }); + assert.equal(waiting.status, 'waiting'); +}); + +test('debrief checkpoints are class-scoped, immutable, idempotent, and snapshot-isolated', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classPair(classrooms); + await addWorkspace(classrooms, workspaceRepo, { + publicId: WORKSPACE_A_ID, + capability: 'P', + claim: 'X', + label: 'Team A' + }); + await addWorkspace(classrooms, workspaceRepo, { + instructor: 'B', + publicId: WORKSPACE_C_ID, + capability: 'R', + claim: 'Z', + label: 'Other Class Team' + }); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT + }); + + const sourceSnapshot = { + pre: { oneLine: 'Team A before debrief' }, + kt: { rows: [{ id: 'what', is: 'Observed fact' }] } + }; + const captured = await classrooms.captureExerciseCheckpoint(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + workspacePublicId: WORKSPACE_A_ID, + workspaceRevision: 9, + snapshot: sourceSnapshot + }); + assert.equal(captured.status, 'captured'); + assert.equal(captured.checkpoint.workspaceRevision, 9); + + sourceSnapshot.pre.oneLine = 'Mutated after capture'; + + const replay = await classrooms.captureExerciseCheckpoint(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + workspacePublicId: WORKSPACE_A_ID, + workspaceRevision: 10, + snapshot: { pre: { oneLine: 'Different later state' } } + }); + assert.equal(replay.status, 'unchanged'); + assert.equal(replay.checkpoint.workspaceRevision, 9); + assert.equal(replay.checkpoint.snapshot.pre.oneLine, 'Team A before debrief'); + + const crossClassWorkspace = await classrooms.captureExerciseCheckpoint(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + workspacePublicId: WORKSPACE_C_ID, + workspaceRevision: 1, + snapshot: {} + }); + assert.equal(crossClassWorkspace, null); + + const wrongInstructor = await classrooms.captureExerciseCheckpoint(testTokenHash('B'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + workspacePublicId: WORKSPACE_A_ID, + workspaceRevision: 9, + snapshot: {} + }); + assert.equal(wrongInstructor, null); + + const listed = await classrooms.listExerciseCheckpointsForInstructor( + testTokenHash('A'), + EXERCISE_A_ID, + 'stage-1' + ); + assert.equal(listed.checkpoints.length, 1); + assert.equal(listed.checkpoints[0].workspaceId, WORKSPACE_A_ID); + assert.equal(listed.checkpoints[0].workspaceRevision, 9); + assert.equal(Object.hasOwn(listed.checkpoints[0], 'snapshot'), false); + + const inspected = await classrooms.getExerciseCheckpointForInstructor( + testTokenHash('A'), + EXERCISE_A_ID, + 'stage-1', + WORKSPACE_A_ID + ); + assert.equal(inspected.workspace.id, WORKSPACE_A_ID); + assert.equal(inspected.checkpoint.workspaceRevision, 9); + assert.equal(inspected.checkpoint.snapshot.pre.oneLine, 'Team A before debrief'); + + const crossClassRead = await classrooms.getExerciseCheckpointForInstructor( + testTokenHash('A'), + EXERCISE_A_ID, + 'stage-1', + WORKSPACE_C_ID + ); + assert.equal(crossClassRead, null); + + const wrongInstructorRead = await classrooms.getExerciseCheckpointForInstructor( + testTokenHash('B'), + EXERCISE_A_ID, + 'stage-1', + WORKSPACE_A_ID + ); + assert.equal(wrongInstructorRead, null); +}); diff --git a/tests/classroomExerciseInstructor.feature.test.mjs b/tests/classroomExerciseInstructor.feature.test.mjs new file mode 100644 index 0000000..629bf66 --- /dev/null +++ b/tests/classroomExerciseInstructor.feature.test.mjs @@ -0,0 +1,1172 @@ +/** + * Feature coverage for the Tranche 6 Instructor staged-exercise console. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, test } from 'node:test'; +import { JSDOM } from 'jsdom'; + +import { + INSTRUCTOR_EXERCISE_CHECKPOINT_ENDPOINT, + INSTRUCTOR_EXERCISE_ENDPOINT, + createInstructorExerciseConsoleController +} from '../src/classroomExerciseInstructor.js'; + +const INDEX_HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const TOKEN = 'i'.repeat(43); +let dom = null; + +function response(status, body) { + return { ok: status >= 200 && status < 300, status, json: async () => body }; +} + +function discoveryBody() { + return { + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: null, + availableCaseStudies: [ + { id: 'staged-a', name: 'Staged Case A', description: 'First staged simulation.', supportedModes: ['full'] }, + { id: 'staged-b', name: 'Staged Case B', description: 'Second staged simulation.', supportedModes: ['intake'] } + ] + }; +} + +function draftBody({ created = true, caseStudyId = 'staged-a', name = 'Staged Case A' } = {}) { + return { + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { + id: 'exercise-1', + caseStudyId, + status: 'draft', + stagePhase: 'work', + exerciseRevision: 1, + currentStageId: null + }, + caseStudy: { + id: caseStudyId, + name, + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + stages: [{ id: 'stage-1', title: 'Hidden until started', studentObjective: 'Objective' }], + instructorContent: [{ body: 'Instructor-only content remains in memory.' }] + } + }, + created + }; +} + +afterEach(() => { + dom?.window.close(); + dom = null; +}); + +test('Instructor exercise console discovers staged summaries and enables explicit selection without persisting capability', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (url, options) => { + requests.push([url, options]); + return response(200, discoveryBody()); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), true); + assert.equal(requests.length, 1); + assert.equal(requests[0][0], INSTRUCTOR_EXERCISE_ENDPOINT); + assert.equal(requests[0][1].method, 'GET'); + assert.equal(requests[0][1].headers.Authorization, 'Bearer ' + TOKEN); + assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, false); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, '2 staged Case Studies available'); + assert.equal(dom.window.document.querySelectorAll('#instructorExerciseAvailableList li').length, 2); + + const select = dom.window.document.getElementById('instructorExerciseCaseSelect'); + const createButton = dom.window.document.getElementById('instructorExerciseCreateBtn'); + assert.equal(select.options.length, 3); + assert.equal(createButton.disabled, true); + + select.value = 'staged-a'; + select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + assert.equal(controller.getState().selectedCaseStudyId, 'staged-a'); + assert.equal(createButton.disabled, false); + + assert.equal(dom.window.localStorage.length, 0); + assert.equal(dom.window.document.body.textContent.includes(TOKEN), false); + assert.equal('capability' in controller.getState(), false); + + controller.disconnect(); + assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, true); + assert.deepEqual(controller.getState().availableCaseStudies, []); + controller.destroy(); +}); + +test('Instructor creates a selected staged draft without releasing Student content or exposing full simulation through public state', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (url, options) => { + requests.push([url, options]); + if (options.method === 'GET') return response(200, discoveryBody()); + return response(201, draftBody({ created: true })); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), true); + const select = dom.window.document.getElementById('instructorExerciseCaseSelect'); + select.value = 'staged-a'; + select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + assert.equal(await controller.createDraft(), true); + assert.equal(requests.length, 2); + assert.equal(requests[1][1].method, 'POST'); + assert.equal(requests[1][1].headers.Authorization, 'Bearer ' + TOKEN); + assert.equal(requests[1][1].headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(requests[1][1].body), { caseStudyId: 'staged-a' }); + + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft · draft created'); + assert.equal(dom.window.document.getElementById('instructorExerciseCaseName').textContent, 'Staged Case A'); + assert.equal( + dom.window.document.getElementById('instructorExerciseDetail').textContent, + 'Draft exercise — no stage has started and nothing has been released to Students.' + ); + assert.equal(dom.window.document.getElementById('instructorExerciseCaseSelect').disabled, true); + assert.equal(dom.window.document.getElementById('instructorExerciseCreateBtn').disabled, true); + assert.equal(controller.getState().exercise.exerciseRevision, 1); + assert.equal(JSON.stringify(controller.getState()).includes('Instructor-only content'), false); + assert.equal(dom.window.localStorage.length, 0); + controller.destroy(); +}); + +test('Instructor accepts an idempotent existing-draft response as the represented current exercise', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => options.method === 'GET' + ? response(200, discoveryBody()) + : response(200, draftBody({ created: false })) + }); + + await controller.connectInstructor(TOKEN); + const select = dom.window.document.getElementById('instructorExerciseCaseSelect'); + select.value = 'staged-a'; + select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + assert.equal(await controller.createDraft(), true); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft · existing draft reused'); + assert.equal(controller.getState().exercise.caseStudyId, 'staged-a'); + controller.destroy(); +}); + +test('Instructor draft conflict reloads authoritative current exercise instead of retrying a stale create', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let getCount = 0; + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => { + requests.push(options.method); + if (options.method === 'POST') { + return response(409, { + error: 'Another exercise is already open for this class.', + exercise: { id: 'exercise-race', caseStudyId: 'staged-b', exerciseRevision: 2 } + }); + } + getCount += 1; + if (getCount === 1) return response(200, discoveryBody()); + return response(200, { + ...discoveryBody(), + ...draftBody({ created: false, caseStudyId: 'staged-b', name: 'Staged Case B' }), + availableCaseStudies: discoveryBody().availableCaseStudies + }); + } + }); + + await controller.connectInstructor(TOKEN); + const select = dom.window.document.getElementById('instructorExerciseCaseSelect'); + select.value = 'staged-a'; + select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + assert.equal(await controller.createDraft(), false); + assert.deepEqual(requests, ['GET', 'POST', 'GET']); + assert.equal(controller.getState().exercise.caseStudyId, 'staged-b'); + assert.equal(controller.getState().selectedCaseStudyId, 'staged-b'); + assert.equal(dom.window.document.getElementById('instructorExerciseCaseName').textContent, 'Staged Case B'); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft · current state reloaded'); + controller.destroy(); +}); + +test('Instructor lifecycle sends optimistic revisions and renders current-stage facilitation context', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let exerciseState = { + id: 'exercise-1', + caseStudyId: 'staged-a', + status: 'draft', + stagePhase: 'work', + exerciseRevision: 1, + currentStageId: null + }; + const caseStudy = { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + stages: [{ + id: 'stage-1', + title: 'Clarify the situation', + studentObjective: 'Frame the initial problem.', + suggestedMinutes: 7, + instructorContentIds: ['teach-1'] + }], + instructorContent: [{ + id: 'teach-1', + kind: 'facilitation', + title: 'Facilitation cue', + body: 'Keep teams focused on the deviation.' + }] + } + }; + + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => { + requests.push({ + method: options.method, + body: options.body ? JSON.parse(options.body) : null + }); + if (options.method === 'GET') { + return response(200, { + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { ...exerciseState }, + caseStudy, + availableCaseStudies: discoveryBody().availableCaseStudies + }); + } + if (options.method === 'PATCH') { + const body = JSON.parse(options.body); + assert.equal(body.expectedRevision, exerciseState.exerciseRevision); + if (body.action === 'start') { + exerciseState = { ...exerciseState, status: 'active', currentStageId: 'stage-1', exerciseRevision: 2 }; + } else if (body.action === 'pause') { + exerciseState = { ...exerciseState, status: 'paused', exerciseRevision: 3 }; + } else if (body.action === 'resume') { + exerciseState = { ...exerciseState, status: 'active', exerciseRevision: 4 }; + } + return response(200, { + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { ...exerciseState }, + caseStudy, + releases: [], + workspaceState: [], + checkpoints: [], + editFreezeEnforced: true, + changed: true + }); + } + return response(405, {}); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), true); + assert.equal(dom.window.document.getElementById('instructorExerciseStartBtn').hidden, false); + + assert.equal(await controller.start(), true); + assert.deepEqual(requests.at(-1), { + method: 'PATCH', + body: { action: 'start', expectedRevision: 1 } + }); + assert.equal(controller.getState().exercise.exerciseRevision, 2); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · exercise started'); + assert.equal(dom.window.document.getElementById('instructorExerciseStageTitle').textContent, 'Clarify the situation'); + assert.equal(dom.window.document.getElementById('instructorExerciseStageObjective').textContent, 'Frame the initial problem.'); + assert.equal(dom.window.document.getElementById('instructorExerciseStageTiming').textContent, 'Suggested time: 7 min'); + assert.equal(dom.window.document.getElementById('instructorExerciseFacilitationList').textContent.includes('Facilitation cue'), true); + assert.equal(dom.window.document.getElementById('instructorExerciseFacilitationList').textContent.includes('Keep teams focused on the deviation.'), true); + assert.equal(dom.window.document.getElementById('instructorExercisePauseBtn').hidden, false); + + assert.equal(await controller.pause(), true); + assert.deepEqual(requests.at(-1), { + method: 'PATCH', + body: { action: 'pause', expectedRevision: 2 } + }); + assert.equal(controller.getState().exercise.status, 'paused'); + assert.equal(dom.window.document.getElementById('instructorExerciseResumeBtn').hidden, false); + + assert.equal(await controller.resume(), true); + assert.deepEqual(requests.at(-1), { + method: 'PATCH', + body: { action: 'resume', expectedRevision: 3 } + }); + assert.equal(controller.getState().exercise.status, 'active'); + assert.equal(controller.getState().exercise.exerciseRevision, 4); + controller.destroy(); +}); + +test('Instructor lifecycle conflict refreshes authoritative state without replaying stale intent', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let getCount = 0; + const draft = draftBody({ created: false }); + + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => { + requests.push(options.method); + if (options.method === 'GET') { + getCount += 1; + if (getCount === 1) { + return response(200, { + ...draft, + availableCaseStudies: discoveryBody().availableCaseStudies + }); + } + return response(200, { + ...draft, + exercise: { + ...draft.exercise, + status: 'active', + currentStageId: 'stage-1', + exerciseRevision: 2 + }, + availableCaseStudies: discoveryBody().availableCaseStudies + }); + } + if (options.method === 'PATCH') { + return response(409, { + error: 'Exercise changed. Refresh and retry.', + exercise: { + ...draft.exercise, + status: 'active', + currentStageId: 'stage-1', + exerciseRevision: 2 + } + }); + } + return response(405, {}); + } + }); + + await controller.connectInstructor(TOKEN); + assert.equal(await controller.start(), false); + assert.deepEqual(requests, ['GET', 'PATCH', 'GET']); + assert.equal(controller.getState().exercise.status, 'active'); + assert.equal(controller.getState().exercise.exerciseRevision, 2); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · current state reloaded'); + assert.equal(dom.window.document.getElementById('instructorExercisePauseBtn').hidden, false); + controller.destroy(); +}); + +test('Instructor releases only current-stage optional evidence and progress rows reuse observer navigation', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + const observed = []; + let exerciseState = { + id: 'exercise-1', + caseStudyId: 'staged-a', + status: 'active', + stagePhase: 'work', + exerciseRevision: 2, + currentStageId: 'stage-1' + }; + let releases = []; + const workspaceState = [ + { + workspaceId: 'workspace-a', + workspaceKind: 'group', + workspaceLabel: 'Team Alpha', + stageId: 'stage-1', + readyForDebrief: true, + readyAt: 'ready', + readyWorkspaceRevision: 7 + }, + { + workspaceId: 'workspace-b', + workspaceKind: 'group', + workspaceLabel: 'Team Beta', + stageId: 'stage-1', + readyForDebrief: false, + readyAt: null, + readyWorkspaceRevision: null + } + ]; + const caseStudy = { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + studentContent: [ + { id: 'hint-1', kind: 'evidence', title: 'Current optional evidence', body: 'Release this now.' }, + { id: 'future-2', kind: 'evidence', title: 'Future optional evidence', body: 'Do not show this yet.' } + ], + instructorContent: [], + stages: [ + { + id: 'stage-1', + title: 'Clarify', + studentObjective: 'Clarify the problem.', + suggestedMinutes: 5, + optionalReleaseIds: ['hint-1'], + instructorContentIds: [] + }, + { + id: 'stage-2', + title: 'Analyze', + studentObjective: 'Analyze later evidence.', + suggestedMinutes: 5, + optionalReleaseIds: ['future-2'], + instructorContentIds: [] + } + ] + } + }; + + const body = () => ({ + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { ...exerciseState }, + caseStudy, + releases: releases.map(item => ({ ...item })), + workspaceState: workspaceState.map(item => ({ ...item })), + checkpoints: [], + editFreezeEnforced: true, + availableCaseStudies: discoveryBody().availableCaseStudies + }); + + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + onSelectWorkspace: workspaceId => observed.push(workspaceId), + fetchImpl: async (_url, options) => { + requests.push({ + method: options.method, + body: options.body ? JSON.parse(options.body) : null + }); + if (options.method === 'GET') return response(200, body()); + if (options.method === 'PATCH') { + const payload = JSON.parse(options.body); + assert.deepEqual(payload, { + action: 'release-content', + expectedRevision: 2, + contentId: 'hint-1' + }); + releases = [{ stageId: 'stage-1', contentId: 'hint-1', releasedAt: 'released' }]; + exerciseState = { ...exerciseState, exerciseRevision: 3 }; + return response(200, { ...body(), changed: true }); + } + return response(405, {}); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), true); + const releaseText = dom.window.document.getElementById('instructorExerciseReleaseList').textContent; + assert.equal(releaseText.includes('Current optional evidence'), true); + assert.equal(releaseText.includes('Future optional evidence'), false); + assert.equal(dom.window.document.getElementById('instructorExerciseProgressSummary').textContent, '1 ready · 1 working'); + + dom.window.document + .querySelector('[aria-label="Observe Team Beta, Working"]') + .dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + assert.deepEqual(observed, ['workspace-b']); + + assert.equal(await controller.releaseContent('hint-1'), true); + assert.deepEqual(requests.at(-1), { + method: 'PATCH', + body: { + action: 'release-content', + expectedRevision: 2, + contentId: 'hint-1' + } + }); + assert.equal(controller.getState().exercise.exerciseRevision, 3); + assert.equal(controller.getState().releases.length, 1); + assert.equal(controller.getState().workspaceState.length, 2); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · content released'); + assert.equal(dom.window.document.getElementById('instructorExerciseReleaseList').textContent.includes('Released'), true); + assert.equal( + dom.window.document.querySelector('[aria-label="Release Current optional evidence to Students"]'), + null + ); + controller.destroy(); +}); + +test('Instructor evidence-release conflict refreshes authoritative released state without replay', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let getCount = 0; + const caseStudy = { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + studentContent: [ + { id: 'hint-1', kind: 'evidence', title: 'Current optional evidence', body: 'Release this now.' } + ], + instructorContent: [], + stages: [{ + id: 'stage-1', + title: 'Clarify', + studentObjective: 'Clarify the problem.', + suggestedMinutes: 5, + optionalReleaseIds: ['hint-1'], + instructorContentIds: [] + }] + } + }; + const payload = ({ revision, released }) => ({ + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { + id: 'exercise-1', + caseStudyId: 'staged-a', + status: 'active', + stagePhase: 'work', + exerciseRevision: revision, + currentStageId: 'stage-1' + }, + caseStudy, + releases: released + ? [{ stageId: 'stage-1', contentId: 'hint-1', releasedAt: 'released' }] + : [], + workspaceState: [], + checkpoints: [], + editFreezeEnforced: true, + availableCaseStudies: discoveryBody().availableCaseStudies + }); + + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => { + requests.push(options.method); + if (options.method === 'GET') { + getCount += 1; + return response(200, getCount === 1 + ? payload({ revision: 2, released: false }) + : payload({ revision: 3, released: true })); + } + if (options.method === 'PATCH') { + return response(409, { + error: 'Exercise changed. Refresh and retry.', + exercise: payload({ revision: 3, released: true }).exercise + }); + } + return response(405, {}); + } + }); + + await controller.connectInstructor(TOKEN); + assert.equal(await controller.releaseContent('hint-1'), false); + assert.deepEqual(requests, ['GET', 'PATCH', 'GET']); + assert.equal(controller.getState().exercise.exerciseRevision, 3); + assert.equal(controller.getState().releases[0].contentId, 'hint-1'); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · current state reloaded'); + assert.equal(dom.window.document.getElementById('instructorExerciseReleaseList').textContent.includes('Released'), true); + controller.destroy(); +}); + +test('Instructor begins debrief, renders immutable checkpoint revisions, and controls Student editing explicitly', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let exerciseState = { + id: 'exercise-1', + caseStudyId: 'staged-a', + status: 'active', + stagePhase: 'work', + studentEditingEnabled: true, + exerciseRevision: 4, + currentStageId: 'stage-1' + }; + let checkpoints = []; + const caseStudy = { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + studentContent: [], + instructorContent: [], + stages: [{ + id: 'stage-1', + title: 'Clarify', + studentObjective: 'Clarify the problem.', + suggestedMinutes: 5, + optionalReleaseIds: [], + instructorContentIds: [], + defaultDebriefEditPolicy: 'frozen' + }] + } + }; + const body = () => ({ + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { ...exerciseState }, + caseStudy, + releases: [], + workspaceState: [{ + workspaceId: 'workspace-a', + workspaceKind: 'group', + workspaceLabel: 'Team Alpha', + stageId: 'stage-1', + readyForDebrief: true, + readyWorkspaceRevision: 7 + }], + checkpoints: checkpoints.map(item => ({ ...item })), + editFreezeEnforced: true, + availableCaseStudies: discoveryBody().availableCaseStudies + }); + + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => { + requests.push({ + method: options.method, + body: options.body ? JSON.parse(options.body) : null + }); + if (options.method === 'GET') return response(200, body()); + if (options.method === 'PATCH') { + const payload = JSON.parse(options.body); + if (payload.action === 'begin-debrief') { + assert.deepEqual(payload, { action: 'begin-debrief', expectedRevision: 4 }); + exerciseState = { + ...exerciseState, + stagePhase: 'debrief', + studentEditingEnabled: false, + exerciseRevision: 5 + }; + checkpoints = [{ + workspaceId: 'workspace-a', + workspaceKind: 'group', + workspaceLabel: 'Team Alpha', + stageId: 'stage-1', + workspaceRevision: 7, + snapshot: { pre: { oneLine: 'Captured but not exposed by controller state.' } }, + capturedAt: 'captured' + }]; + return response(200, { ...body(), changed: true, capturedCount: 1 }); + } + if (payload.action === 'set-editing') { + assert.equal(payload.expectedRevision, exerciseState.exerciseRevision); + exerciseState = { + ...exerciseState, + studentEditingEnabled: payload.enabled, + exerciseRevision: exerciseState.exerciseRevision + 1 + }; + return response(200, { ...body(), changed: true }); + } + return response(400, {}); + } + return response(405, {}); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), true); + assert.equal(dom.window.document.getElementById('instructorExerciseBeginDebriefBtn').hidden, false); + + assert.equal(await controller.beginDebrief(), true); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Debrief · debrief started'); + assert.equal(dom.window.document.getElementById('instructorExerciseDebriefPanel').hidden, false); + assert.equal( + dom.window.document.getElementById('instructorExerciseEditingStatus').textContent, + 'Student editing frozen during debrief' + ); + assert.equal(dom.window.document.getElementById('instructorExerciseCheckpointSummary').textContent, '1 captured'); + assert.equal(dom.window.document.getElementById('instructorExerciseCheckpointList').textContent.includes('Team Alpha'), true); + assert.equal(dom.window.document.getElementById('instructorExerciseCheckpointList').textContent.includes('Revision 7'), true); + assert.equal(JSON.stringify(controller.getState()).includes('Captured but not exposed'), false); + assert.equal(dom.window.document.getElementById('instructorExerciseAllowEditingBtn').hidden, false); + assert.equal(dom.window.document.getElementById('instructorExerciseFreezeBtn').hidden, true); + + assert.equal(await controller.setEditing(true), true); + assert.deepEqual(requests.at(-1), { + method: 'PATCH', + body: { action: 'set-editing', expectedRevision: 5, enabled: true } + }); + assert.equal( + dom.window.document.getElementById('instructorExerciseEditingStatus').textContent, + 'Student editing allowed during debrief' + ); + assert.equal(dom.window.document.getElementById('instructorExerciseFreezeBtn').hidden, false); + + assert.equal(await controller.setEditing(false), true); + assert.deepEqual(requests.at(-1), { + method: 'PATCH', + body: { action: 'set-editing', expectedRevision: 6, enabled: false } + }); + assert.equal(controller.getState().exercise.exerciseRevision, 7); + assert.equal(controller.getState().exercise.studentEditingEnabled, false); + assert.equal(controller.getState().checkpoints[0].workspaceRevision, 7); + controller.destroy(); +}); + +test('Instructor advances through the authored final stage and completes without releasing additional Student material', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let exerciseState = { + id: 'exercise-1', + caseStudyId: 'staged-a', + status: 'active', + stagePhase: 'debrief', + studentEditingEnabled: false, + exerciseRevision: 7, + currentStageId: 'stage-1' + }; + let checkpoints = [{ + workspaceId: 'workspace-a', + workspaceKind: 'group', + workspaceLabel: 'Team Alpha', + stageId: 'stage-1', + workspaceRevision: 7, + capturedAt: 'stage-1-captured' + }]; + const caseStudy = { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + studentContent: [ + { id: 'brief-1', kind: 'narrative', title: 'Stage 1 brief', body: 'First stage.' }, + { id: 'brief-2', kind: 'narrative', title: 'Stage 2 brief', body: 'Second stage.' } + ], + instructorContent: [ + { id: 'teach-2', kind: 'facilitation', title: 'Final-stage cue', body: 'Facilitate the final stage.' } + ], + stages: [ + { + id: 'stage-1', + title: 'Clarify', + studentObjective: 'Clarify the problem.', + suggestedMinutes: 5, + initialReleaseIds: ['brief-1'], + optionalReleaseIds: [], + instructorContentIds: [], + defaultDebriefEditPolicy: 'frozen' + }, + { + id: 'stage-2', + title: 'Analyze', + studentObjective: 'Analyze the next evidence.', + suggestedMinutes: 4, + initialReleaseIds: ['brief-2'], + optionalReleaseIds: [], + instructorContentIds: ['teach-2'], + defaultDebriefEditPolicy: 'open' + } + ] + } + }; + const body = () => ({ + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { ...exerciseState }, + caseStudy, + releases: [], + workspaceState: [{ + workspaceId: 'workspace-a', + workspaceKind: 'group', + workspaceLabel: 'Team Alpha', + stageId: exerciseState.currentStageId, + readyForDebrief: true, + readyWorkspaceRevision: exerciseState.currentStageId === 'stage-1' ? 7 : 8 + }], + checkpoints: checkpoints.map(item => ({ ...item })), + editFreezeEnforced: true, + availableCaseStudies: discoveryBody().availableCaseStudies + }); + + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => { + requests.push({ + method: options.method, + body: options.body ? JSON.parse(options.body) : null + }); + if (options.method === 'GET') return response(200, body()); + if (options.method === 'PATCH') { + const payload = JSON.parse(options.body); + if (payload.action === 'advance') { + assert.deepEqual(payload, { action: 'advance', expectedRevision: 7 }); + exerciseState = { + ...exerciseState, + currentStageId: 'stage-2', + stagePhase: 'work', + studentEditingEnabled: true, + exerciseRevision: 8 + }; + return response(200, { ...body(), changed: true }); + } + if (payload.action === 'begin-debrief') { + assert.deepEqual(payload, { action: 'begin-debrief', expectedRevision: 8 }); + exerciseState = { + ...exerciseState, + stagePhase: 'debrief', + studentEditingEnabled: true, + exerciseRevision: 9 + }; + checkpoints = [ + ...checkpoints, + { + workspaceId: 'workspace-a', + workspaceKind: 'group', + workspaceLabel: 'Team Alpha', + stageId: 'stage-2', + workspaceRevision: 8, + capturedAt: 'stage-2-captured' + } + ]; + return response(200, { ...body(), changed: true, capturedCount: 1 }); + } + if (payload.action === 'complete') { + assert.deepEqual(payload, { action: 'complete', expectedRevision: 9 }); + exerciseState = { + ...exerciseState, + status: 'completed', + studentEditingEnabled: true, + exerciseRevision: 10 + }; + return response(200, { ...body(), changed: true }); + } + return response(400, {}); + } + return response(405, {}); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), true); + assert.equal(dom.window.document.getElementById('instructorExerciseAdvanceBtn').hidden, false); + assert.equal(dom.window.document.getElementById('instructorExerciseCompleteBtn').hidden, true); + + assert.equal(await controller.advance(), true); + assert.deepEqual(requests.at(-1), { + method: 'PATCH', + body: { action: 'advance', expectedRevision: 7 } + }); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · advanced to next stage'); + assert.equal(dom.window.document.getElementById('instructorExerciseStageTitle').textContent, 'Analyze'); + assert.equal(dom.window.document.getElementById('instructorExerciseStageObjective').textContent, 'Analyze the next evidence.'); + assert.equal(dom.window.document.getElementById('instructorExerciseFacilitationList').textContent.includes('Final-stage cue'), true); + assert.equal(dom.window.document.getElementById('instructorExerciseDebriefPanel').hidden, true); + assert.equal(dom.window.document.getElementById('instructorExerciseBeginDebriefBtn').hidden, false); + + assert.equal(await controller.beginDebrief(), true); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Debrief · debrief started'); + assert.equal(dom.window.document.getElementById('instructorExerciseEditingStatus').textContent, 'Student editing allowed during debrief'); + assert.equal(dom.window.document.getElementById('instructorExerciseCheckpointSummary').textContent, '1 captured'); + assert.equal(dom.window.document.getElementById('instructorExerciseCheckpointList').textContent.includes('Revision 8'), true); + assert.equal(dom.window.document.getElementById('instructorExerciseAdvanceBtn').hidden, true); + assert.equal(dom.window.document.getElementById('instructorExerciseCompleteBtn').hidden, false); + + assert.equal(await controller.complete(), true); + assert.deepEqual(requests.at(-1), { + method: 'PATCH', + body: { action: 'complete', expectedRevision: 9 } + }); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Completed · exercise completed'); + assert.equal( + dom.window.document.getElementById('instructorExerciseDetail').textContent, + 'Analyze · Exercise completed. No additional Student material is released automatically.' + ); + assert.equal( + dom.window.document.getElementById('instructorExerciseEditingStatus').textContent, + 'Exercise completed; staged editing policy no longer applies' + ); + assert.equal(dom.window.document.getElementById('instructorExerciseFreezeBtn').hidden, true); + assert.equal(dom.window.document.getElementById('instructorExerciseAllowEditingBtn').hidden, true); + assert.equal(dom.window.document.getElementById('instructorExerciseCompleteBtn').hidden, true); + assert.equal( + dom.window.document.getElementById('instructorExerciseLifecycleHelp').textContent.includes('does not release additional case or exemplar material'), + true + ); + assert.equal(await controller.setEditing(false), false); + assert.equal(controller.getState().exercise.status, 'completed'); + assert.equal(controller.getState().exercise.exerciseRevision, 10); + assert.equal(controller.getState().checkpoints.length, 2); + controller.destroy(); +}); + +test('Instructor Advance conflict refreshes the authoritative next stage without replaying stale intent', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let getCount = 0; + const caseStudy = { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + studentContent: [], + instructorContent: [], + stages: [ + { + id: 'stage-1', + title: 'Clarify', + studentObjective: 'Clarify.', + suggestedMinutes: 5, + optionalReleaseIds: [], + instructorContentIds: [], + defaultDebriefEditPolicy: 'frozen' + }, + { + id: 'stage-2', + title: 'Analyze', + studentObjective: 'Analyze.', + suggestedMinutes: 4, + optionalReleaseIds: [], + instructorContentIds: [], + defaultDebriefEditPolicy: 'open' + } + ] + } + }; + const payload = ({ revision, stageId, phase }) => ({ + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { + id: 'exercise-1', + caseStudyId: 'staged-a', + status: 'active', + stagePhase: phase, + studentEditingEnabled: phase === 'work', + exerciseRevision: revision, + currentStageId: stageId + }, + caseStudy, + releases: [], + workspaceState: [], + checkpoints: [], + editFreezeEnforced: true, + availableCaseStudies: discoveryBody().availableCaseStudies + }); + + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => { + requests.push(options.method); + if (options.method === 'GET') { + getCount += 1; + return response(200, getCount === 1 + ? payload({ revision: 5, stageId: 'stage-1', phase: 'debrief' }) + : payload({ revision: 6, stageId: 'stage-2', phase: 'work' })); + } + if (options.method === 'PATCH') { + return response(409, { + error: 'Exercise changed. Refresh and retry.', + exercise: payload({ revision: 6, stageId: 'stage-2', phase: 'work' }).exercise + }); + } + return response(405, {}); + } + }); + + await controller.connectInstructor(TOKEN); + assert.equal(await controller.advance(), false); + assert.deepEqual(requests, ['GET', 'PATCH', 'GET']); + assert.equal(controller.getState().exercise.exerciseRevision, 6); + assert.equal(controller.getState().exercise.currentStageId, 'stage-2'); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'In progress · current state reloaded'); + assert.equal(dom.window.document.getElementById('instructorExerciseStageTitle').textContent, 'Analyze'); + assert.equal(dom.window.document.getElementById('instructorExerciseBeginDebriefBtn').hidden, false); + controller.destroy(); +}); + +test('Instructor debrief editing conflict refreshes authoritative policy without replaying stale intent', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let getCount = 0; + const caseStudy = { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + studentContent: [], + instructorContent: [], + stages: [{ + id: 'stage-1', + title: 'Clarify', + studentObjective: 'Clarify the problem.', + suggestedMinutes: 5, + optionalReleaseIds: [], + instructorContentIds: [], + defaultDebriefEditPolicy: 'frozen' + }] + } + }; + const payload = ({ revision, enabled }) => ({ + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { + id: 'exercise-1', + caseStudyId: 'staged-a', + status: 'active', + stagePhase: 'debrief', + studentEditingEnabled: enabled, + exerciseRevision: revision, + currentStageId: 'stage-1' + }, + caseStudy, + releases: [], + workspaceState: [], + checkpoints: [{ + workspaceId: 'workspace-a', + workspaceKind: 'group', + workspaceLabel: 'Team Alpha', + stageId: 'stage-1', + workspaceRevision: 7, + snapshot: { pre: { oneLine: 'Immutable checkpoint' } }, + capturedAt: 'captured' + }], + editFreezeEnforced: true, + availableCaseStudies: discoveryBody().availableCaseStudies + }); + + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => { + requests.push(options.method); + if (options.method === 'GET') { + getCount += 1; + return response(200, getCount === 1 + ? payload({ revision: 5, enabled: false }) + : payload({ revision: 6, enabled: true })); + } + if (options.method === 'PATCH') { + return response(409, { + error: 'Exercise changed. Refresh and retry.', + exercise: payload({ revision: 6, enabled: true }).exercise + }); + } + return response(405, {}); + } + }); + + await controller.connectInstructor(TOKEN); + assert.equal(await controller.setEditing(true), false); + assert.deepEqual(requests, ['GET', 'PATCH', 'GET']); + assert.equal(controller.getState().exercise.exerciseRevision, 6); + assert.equal(controller.getState().exercise.studentEditingEnabled, true); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Debrief · current state reloaded'); + assert.equal( + dom.window.document.getElementById('instructorExerciseEditingStatus').textContent, + 'Student editing allowed during debrief' + ); + controller.destroy(); +}); + +test('Instructor exercise console keeps a connected shell on transient read failure and can refresh', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + let attempt = 0; + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async () => { + attempt += 1; + if (attempt === 1) return response(500, { error: 'Nope' }); + return response(200, { class: { id: 'class-1', title: 'PSDM Class' }, exercise: null, availableCaseStudies: [] }); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), false); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Could not load the class exercise.'); + assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, false); + assert.equal(await controller.refresh(), true); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, '0 staged Case Studies available'); + controller.destroy(); +}); + + +test('Instructor checkpoint inspection fetches one snapshot explicitly without exposing it in console state', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let inspected = null; + const exerciseBody = { + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { + id: 'exercise-1', + caseStudyId: 'staged-a', + status: 'active', + stagePhase: 'debrief', + studentEditingEnabled: false, + exerciseRevision: 5, + currentStageId: 'stage-1' + }, + caseStudy: { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + studentContent: [], + instructorContent: [], + stages: [{ + id: 'stage-1', + title: 'Clarify', + studentObjective: 'Clarify.', + suggestedMinutes: 5, + optionalReleaseIds: [], + instructorContentIds: [], + defaultDebriefEditPolicy: 'frozen' + }] + } + }, + releases: [], + workspaceState: [], + checkpoints: [{ + workspaceId: 'workspace-a', + workspaceKind: 'group', + workspaceLabel: 'Team Alpha', + stageId: 'stage-1', + workspaceRevision: 7, + snapshot: { pre: { oneLine: 'SHOULD NOT ENTER PUBLIC CONSOLE STATE' } }, + capturedAt: 'captured' + }], + editFreezeEnforced: true, + availableCaseStudies: discoveryBody().availableCaseStudies + }; + + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + onInspectCheckpoint: context => { inspected = context; }, + fetchImpl: async (url, options) => { + requests.push([url, options]); + if (url === INSTRUCTOR_EXERCISE_ENDPOINT) return response(200, exerciseBody); + if (url === `${INSTRUCTOR_EXERCISE_CHECKPOINT_ENDPOINT}?workspaceId=workspace-a`) { + return response(200, { + class: exerciseBody.class, + exercise: { + id: 'exercise-1', + currentStageId: 'stage-1', + stagePhase: 'debrief', + exerciseRevision: 5 + }, + workspace: { id: 'workspace-a', kind: 'group', label: 'Team Alpha' }, + checkpoint: { + stageId: 'stage-1', + workspaceRevision: 7, + capturedAt: 'captured', + snapshot: { pre: { oneLine: 'Immutable checkpoint snapshot' } } + } + }); + } + return response(404, {}); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), true); + assert.equal(controller.getState().checkpoints.length, 1); + assert.equal(Object.hasOwn(controller.getState().checkpoints[0], 'snapshot'), false); + assert.equal( + JSON.stringify(controller.getState()).includes('SHOULD NOT ENTER PUBLIC CONSOLE STATE'), + false + ); + + const inspectButton = dom.window.document.querySelector( + '[aria-label="Inspect Team Alpha checkpoint, revision 7"]' + ); + assert.ok(inspectButton); + assert.equal(await controller.inspectCheckpoint('workspace-a'), true); + assert.equal(requests.at(-1)[0], `${INSTRUCTOR_EXERCISE_CHECKPOINT_ENDPOINT}?workspaceId=workspace-a`); + assert.equal(requests.at(-1)[1].headers.Authorization, `Bearer ${TOKEN}`); + assert.equal(inspected.workspace.id, 'workspace-a'); + assert.equal(inspected.checkpoint.workspaceRevision, 7); + assert.equal(inspected.checkpoint.snapshot.pre.oneLine, 'Immutable checkpoint snapshot'); + assert.equal(JSON.stringify(controller.getState()).includes('Immutable checkpoint snapshot'), false); + + controller.destroy(); +}); diff --git a/tests/classroomExerciseStudent.feature.test.mjs b/tests/classroomExerciseStudent.feature.test.mjs new file mode 100644 index 0000000..06039ca --- /dev/null +++ b/tests/classroomExerciseStudent.feature.test.mjs @@ -0,0 +1,467 @@ +/** + * Feature coverage for the Student staged-exercise case reference. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, test } from 'node:test'; +import { JSDOM } from 'jsdom'; + +import { + STUDENT_EXERCISE_ENDPOINT, + STUDENT_EXERCISE_READY_ENDPOINT, + createStudentExerciseReferenceController +} from '../src/classroomExerciseStudent.js'; + +const INDEX_HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const TOKEN = 'l'.repeat(43); + +let dom = null; + +function response(status, body) { + return { ok: status >= 200 && status < 300, status, json: async () => body }; +} + +function payload({ + revision = 2, + status = 'active', + stagePhase = 'work', + editing = true, + stageTitle = 'Clarify the case', + objective = 'Capture the initial situation.', + readiness = null, + assignment = { id: 'workspace-1', kind: 'group', label: 'Team Alpha' }, + content = [ + { + stageId: 'stage-1', + releaseType: 'initial', + releasedAt: null, + content: { + id: 'brief-1', + kind: 'narrative', + title: 'Initial briefing', + body: 'Student-safe released case material.' + } + } + ] +} = {}) { + return { + class: { id: 'class-1', title: 'PSDM Class' }, + participant: { id: 'participant-1', displayName: 'Alex' }, + assignment, + exercise: { + id: 'exercise-1', + caseStudyId: 'protected-case-id', + caseStudy: { + id: 'protected-case-id', + name: 'Student-visible case name', + description: 'Student-visible summary.' + }, + status, + stagePhase, + exerciseRevision: revision, + studentEditingEnabled: editing, + editFreezeEnforced: true, + currentStage: { + id: revision > 2 ? 'stage-2' : 'stage-1', + title: stageTitle, + studentObjective: objective + }, + releasedContent: content, + readiness, + futureStage: { + title: 'DO NOT SHOW FUTURE STAGE' + }, + instructorContent: [{ + title: 'DO NOT SHOW INSTRUCTOR CONTENT' + }] + } + }; +} + +afterEach(() => { + dom?.window.close(); + dom = null; +}); + +test('Student reference uses only the Student exercise endpoint and projects Student-safe fields', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + const timers = []; + const controller = createStudentExerciseReferenceController({ + documentRef: dom.window.document, + windowRef: dom.window, + fetchImpl: async (url, options) => { + requests.push([url, options]); + return response(200, payload()); + }, + setTimeoutImpl: fn => { + timers.push(fn); + return timers.length; + }, + clearTimeoutImpl: () => {} + }); + + assert.equal(await controller.connectStudent(TOKEN), true); + assert.equal(requests.length, 1); + assert.equal(requests[0][0], STUDENT_EXERCISE_ENDPOINT); + assert.equal(requests[0][1].method, 'GET'); + assert.equal(requests[0][1].headers.Authorization, `Bearer ${TOKEN}`); + + const panel = dom.window.document.getElementById('studentCaseReference'); + assert.equal(panel.hidden, false); + assert.equal(dom.window.document.body.classList.contains('student-case-reference-visible'), true); + assert.equal(dom.window.document.getElementById('studentCaseReferenceTitle').textContent, 'Student-visible case name'); + assert.equal(dom.window.document.getElementById('studentCaseReferenceStatus').textContent, 'Work'); + assert.equal(dom.window.document.getElementById('studentCaseReferenceStageTitle').textContent, 'Clarify the case'); + assert.equal(dom.window.document.getElementById('studentCaseReferenceObjective').textContent, 'Capture the initial situation.'); + assert.equal(dom.window.document.getElementById('studentCaseReferenceContent').textContent.includes('Initial briefing'), true); + assert.equal(dom.window.document.getElementById('studentCaseReferenceContent').textContent.includes('Student-safe released case material.'), true); + + const rendered = panel.textContent; + assert.equal(rendered.includes('DO NOT SHOW FUTURE STAGE'), false); + assert.equal(rendered.includes('DO NOT SHOW INSTRUCTOR CONTENT'), false); + const stateText = JSON.stringify(controller.getState()); + assert.equal(stateText.includes(TOKEN), false, 'public state never exposes the Student bearer capability'); + assert.equal(stateText.includes('DO NOT SHOW FUTURE STAGE'), false); + assert.equal(stateText.includes('DO NOT SHOW INSTRUCTOR CONTENT'), false); + assert.equal(dom.window.localStorage.length, 0, 'reference controller adds no browser storage'); + assert.equal(timers.length, 1, 'successful read schedules exercise polling'); + + controller.destroy(); +}); + +test('Student reference refreshes to a new revision and explains a frozen debrief', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const timers = []; + let read = 0; + const secondContent = [ + ...payload().exercise.releasedContent, + { + stageId: 'stage-2', + releaseType: 'initial', + releasedAt: null, + content: { + id: 'brief-2', + kind: 'evidence', + title: 'Second-stage evidence', + body: 'New Student-safe evidence after the stage transition.' + } + } + ]; + const controller = createStudentExerciseReferenceController({ + documentRef: dom.window.document, + windowRef: dom.window, + fetchImpl: async () => { + read += 1; + return response(200, read === 1 + ? payload() + : payload({ + revision: 3, + stagePhase: 'debrief', + editing: false, + stageTitle: 'Analyze the evidence', + objective: 'Compare the released evidence.', + content: secondContent + })); + }, + setTimeoutImpl: fn => { + timers.push(fn); + return timers.length; + }, + clearTimeoutImpl: () => {} + }); + + assert.equal(await controller.connectStudent(TOKEN), true); + const nextPoll = timers.shift(); + await nextPoll(); + await new Promise(resolve => setImmediate(resolve)); + + const state = controller.getState(); + assert.equal(state.exercise.exerciseRevision, 3); + assert.equal(state.exercise.currentStage.title, 'Analyze the evidence'); + assert.equal(dom.window.document.getElementById('studentCaseReferenceStatus').textContent, 'Debrief · editing frozen'); + assert.equal(dom.window.document.getElementById('studentCaseReferenceContent').textContent.includes('Second-stage evidence'), true); + assert.equal(dom.window.document.getElementById('studentCaseReferenceMessage').hidden, false); + assert.match(dom.window.document.getElementById('studentCaseReferenceMessage').textContent, /frozen Student editing/i); + + controller.destroy(); +}); + +test('Student reference defaults collapsed on mobile and remains explicitly expandable', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + dom.window.matchMedia = query => ({ + matches: query === '(max-width: 700px)', + media: query, + addEventListener: () => {}, + removeEventListener: () => {} + }); + const controller = createStudentExerciseReferenceController({ + documentRef: dom.window.document, + windowRef: dom.window, + fetchImpl: async () => response(200, payload()), + setTimeoutImpl: () => 1, + clearTimeoutImpl: () => {} + }); + + await controller.connectStudent(TOKEN); + const panel = dom.window.document.getElementById('studentCaseReference'); + const toggle = dom.window.document.getElementById('studentCaseReferenceToggle'); + assert.equal(controller.getState().expanded, false); + assert.equal(panel.classList.contains('is-collapsed'), true); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + assert.equal(toggle.textContent, 'Open case reference'); + + toggle.click(); + assert.equal(controller.getState().expanded, true); + assert.equal(panel.classList.contains('is-collapsed'), false); + assert.equal(toggle.getAttribute('aria-expanded'), 'true'); + assert.equal(toggle.textContent, 'Collapse reference'); + + controller.destroy(); +}); + +test('Student reference clears exercise content immediately on session disconnect', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const controller = createStudentExerciseReferenceController({ + documentRef: dom.window.document, + windowRef: dom.window, + fetchImpl: async () => response(200, payload()), + setTimeoutImpl: () => 1, + clearTimeoutImpl: () => {} + }); + + await controller.connectStudent(TOKEN); + assert.equal(dom.window.document.getElementById('studentCaseReference').hidden, false); + controller.disconnect(); + + assert.equal(controller.getState().connected, false); + assert.equal(controller.getState().exercise, null); + assert.equal(dom.window.document.getElementById('studentCaseReference').hidden, true); + assert.equal(dom.window.document.body.classList.contains('student-case-reference-visible'), false); + controller.destroy(); +}); + + +test('Student can mark Ready and Resume working with only the stable class-session capability', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let ready = false; + const controller = createStudentExerciseReferenceController({ + documentRef: dom.window.document, + windowRef: dom.window, + fetchImpl: async (url, options) => { + requests.push([url, options]); + if (url === STUDENT_EXERCISE_ENDPOINT) { + return response(200, payload({ + readiness: ready + ? { + stageId: 'stage-1', + readyForDebrief: true, + readyAt: '2099-12-31T23:30:00.000Z', + readyWorkspaceRevision: 7 + } + : null + })); + } + if (url === STUDENT_EXERCISE_READY_ENDPOINT) { + const body = JSON.parse(options.body); + ready = body.ready; + return response(200, { + assignment: { id: 'workspace-1', kind: 'group', label: 'Team Alpha' }, + readiness: { + stageId: 'stage-1', + readyForDebrief: ready, + readyAt: ready ? '2099-12-31T23:30:00.000Z' : null, + readyWorkspaceRevision: ready ? 7 : null + }, + changed: true + }); + } + throw new Error(`Unexpected request: ${url}`); + }, + setTimeoutImpl: () => 1, + clearTimeoutImpl: () => {} + }); + + assert.equal(await controller.connectStudent(TOKEN), true); + const readinessPanel = dom.window.document.getElementById('studentCaseReferenceReadiness'); + const status = dom.window.document.getElementById('studentCaseReferenceReadinessStatus'); + const button = dom.window.document.getElementById('studentCaseReferenceReadyBtn'); + assert.equal(readinessPanel.hidden, false); + assert.equal(status.textContent, 'Working'); + assert.equal(button.textContent, 'Mark Ready'); + assert.equal(button.getAttribute('aria-pressed'), 'false'); + + assert.equal(await controller.setReadiness(true), true); + assert.equal(status.textContent, 'Ready for debrief · Intake revision 7'); + assert.equal(button.textContent, 'Resume working'); + assert.equal(button.getAttribute('aria-pressed'), 'true'); + + const readyRequest = requests.find(([url]) => url === STUDENT_EXERCISE_READY_ENDPOINT); + assert.ok(readyRequest); + assert.equal(readyRequest[1].method, 'PUT'); + assert.equal(readyRequest[1].headers.Authorization, `Bearer ${TOKEN}`); + assert.deepEqual(JSON.parse(readyRequest[1].body), { ready: true }); + assert.equal(readyRequest[1].body.includes('workspace'), false); + assert.equal(JSON.stringify(controller.getState()).includes(TOKEN), false); + + assert.equal(await controller.setReadiness(false), true); + assert.equal(status.textContent, 'Working'); + assert.equal(button.textContent, 'Mark Ready'); + assert.equal(button.getAttribute('aria-pressed'), 'false'); + + const readyBodies = requests + .filter(([url]) => url === STUDENT_EXERCISE_READY_ENDPOINT) + .map(([, options]) => JSON.parse(options.body)); + assert.deepEqual(readyBodies, [{ ready: true }, { ready: false }]); + + controller.destroy(); +}); + +test('Student readiness 409 refreshes authoritative assignment and does not replay stale intent', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let exerciseReads = 0; + const controller = createStudentExerciseReferenceController({ + documentRef: dom.window.document, + windowRef: dom.window, + fetchImpl: async (url, options) => { + requests.push([url, options]); + if (url === STUDENT_EXERCISE_READY_ENDPOINT) { + return response(409, { + error: 'Student assignment changed. Refresh and retry.' + }); + } + if (url === STUDENT_EXERCISE_ENDPOINT) { + exerciseReads += 1; + return response(200, exerciseReads === 1 + ? payload() + : payload({ + assignment: null, + readiness: null + })); + } + throw new Error(`Unexpected request: ${url}`); + }, + setTimeoutImpl: () => 1, + clearTimeoutImpl: () => {} + }); + + assert.equal(await controller.connectStudent(TOKEN), true); + assert.equal(await controller.setReadiness(true), false); + + const readyRequests = requests.filter(([url]) => url === STUDENT_EXERCISE_READY_ENDPOINT); + assert.equal(readyRequests.length, 1, 'stale Ready intent is never replayed'); + assert.equal(exerciseReads, 2, '409 is followed by exactly one authoritative exercise refresh'); + assert.equal(controller.getState().assignment, null); + assert.equal(dom.window.document.getElementById('studentCaseReferenceReadiness').hidden, true); + assert.equal(dom.window.document.getElementById('studentCaseReferenceReadyBtn').hidden, true); + + controller.destroy(); +}); + +test('Student readiness controls are unavailable outside active work', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const controller = createStudentExerciseReferenceController({ + documentRef: dom.window.document, + windowRef: dom.window, + fetchImpl: async () => response(200, payload({ + stagePhase: 'debrief', + editing: false, + readiness: { + stageId: 'stage-1', + readyForDebrief: true, + readyAt: '2099-12-31T23:30:00.000Z', + readyWorkspaceRevision: 4 + } + })), + setTimeoutImpl: () => 1, + clearTimeoutImpl: () => {} + }); + + await controller.connectStudent(TOKEN); + assert.equal(dom.window.document.getElementById('studentCaseReferenceReadiness').hidden, true); + assert.equal(dom.window.document.getElementById('studentCaseReferenceReadyBtn').hidden, true); + assert.equal(await controller.setReadiness(false), false); + + controller.destroy(); +}); + + +test('frozen Student debrief projects Intake read only and restores when editing reopens', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + let reads = 0; + const controller = createStudentExerciseReferenceController({ + documentRef: dom.window.document, + windowRef: dom.window, + fetchImpl: async () => { + reads += 1; + return response(200, reads === 1 + ? payload({ stagePhase: 'debrief', editing: false }) + : payload({ stagePhase: 'debrief', editing: true })); + }, + setTimeoutImpl: () => 1, + clearTimeoutImpl: () => {} + }); + + const oneLine = dom.window.document.getElementById('oneLine'); + const addCause = dom.window.document.getElementById('addCauseBtn'); + const leaveClass = dom.window.document.getElementById('studentClassLeaveBtn'); + const wrap = dom.window.document.querySelector('.wrap[data-experience-surface="intake"]'); + + assert.equal(oneLine.readOnly, false); + assert.equal(addCause.disabled, false); + assert.equal(leaveClass.disabled, false); + + await controller.connectStudent(TOKEN); + + assert.equal(wrap.classList.contains('student-exercise-readonly'), true); + assert.equal(oneLine.readOnly, true); + assert.equal(oneLine.getAttribute('aria-readonly'), 'true'); + assert.equal(addCause.disabled, true); + assert.equal(addCause.hasAttribute('data-student-exercise-readonly-control'), true); + assert.equal(leaveClass.disabled, false, 'Leave class remains available during a frozen debrief'); + assert.equal(leaveClass.hasAttribute('data-student-exercise-readonly-control'), false); + + await controller.refresh(); + + assert.equal(wrap.classList.contains('student-exercise-readonly'), false); + assert.equal(oneLine.readOnly, false); + assert.equal(oneLine.hasAttribute('aria-readonly'), false); + assert.equal(addCause.disabled, false); + assert.equal(addCause.hasAttribute('data-student-exercise-readonly-control'), false); + assert.equal(leaveClass.disabled, false); + + controller.destroy(); +}); + +test('paused frozen debrief remains read only and disconnect restores original control state', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const controller = createStudentExerciseReferenceController({ + documentRef: dom.window.document, + windowRef: dom.window, + fetchImpl: async () => response(200, payload({ + status: 'paused', + stagePhase: 'debrief', + editing: false + })), + setTimeoutImpl: () => 1, + clearTimeoutImpl: () => {} + }); + + const oneLine = dom.window.document.getElementById('oneLine'); + const addCause = dom.window.document.getElementById('addCauseBtn'); + await controller.connectStudent(TOKEN); + + assert.equal(oneLine.readOnly, true); + assert.equal(addCause.disabled, true); + assert.equal(dom.window.document.getElementById('studentCaseReferenceStatus').textContent, 'Paused · debrief · editing frozen'); + + controller.disconnect(); + + assert.equal(oneLine.readOnly, false); + assert.equal(addCause.disabled, false); + assert.equal(dom.window.document.querySelector('.wrap[data-experience-surface="intake"]').classList.contains('student-exercise-readonly'), false); + + controller.destroy(); +}); diff --git a/tests/classroomInstructor.feature.test.mjs b/tests/classroomInstructor.feature.test.mjs index 630f6d5..00155a7 100644 --- a/tests/classroomInstructor.feature.test.mjs +++ b/tests/classroomInstructor.feature.test.mjs @@ -88,8 +88,22 @@ function observeBody(id, statement) { }; } -function setup(fetchImpl, { onObservation = () => {}, onObservationEnd = () => {}, onClassConnected = () => {}, onClassDisconnected = () => {} } = {}) { +function setup(fetchImpl, { + onObservation = () => {}, + onObservationEnd = () => {}, + onClassConnected = () => {}, + onClassDisconnected = () => {}, + mobile = false +} = {}) { dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + if (mobile) { + dom.window.matchMedia = query => ({ + matches: query === '(max-width: 700px)', + media: query, + addEventListener: () => {}, + removeEventListener: () => {} + }); + } persistExperienceRolePreference(EXPERIENCE_ROLE_IDS.INSTRUCTOR, dom.window.localStorage); initExperienceRoleController({ documentRef: dom.window.document, @@ -149,6 +163,37 @@ afterEach(() => { dom = null; }); +test('Instructor class rail defaults collapsed on mobile and remains explicitly expandable', async () => { + const env = setup(async (url) => { + if (url === '/api/classes/workspaces') return response(200, rosterBody()); + if (url === '/api/classes/participants') return response(200, participantBody()); + if (url.includes(W1)) return response(200, observeBody(W1, 'First')); + return response(404, {}); + }, { mobile: true }); + + await env.controller.openClass(TOKEN); + + const dashboard = dom.window.document.getElementById('instructorClassDashboard'); + const toggle = dom.window.document.getElementById('instructorClassPanelToggle'); + assert.equal(env.controller.getState().railExpanded, false); + assert.equal(dashboard.classList.contains('is-collapsed'), true); + assert.equal(dom.window.document.body.classList.contains('instructor-rail-collapsed'), true); + assert.equal(toggle.getAttribute('aria-expanded'), 'false'); + assert.equal(toggle.textContent, 'Open class panel'); + + toggle.click(); + assert.equal(env.controller.getState().railExpanded, true); + assert.equal(dashboard.classList.contains('is-collapsed'), false); + assert.equal(dom.window.document.body.classList.contains('instructor-rail-collapsed'), false); + assert.equal(toggle.getAttribute('aria-expanded'), 'true'); + assert.equal(toggle.textContent, 'Collapse class panel'); + + toggle.click(); + assert.equal(env.controller.getState().railExpanded, false); + assert.equal(dashboard.classList.contains('is-collapsed'), true); + env.controller.destroy(); +}); + test('Instructor opens one class, renders roster, and observes through the GET-only class endpoint', async () => { const requests = []; const env = setup(async (url, options) => { @@ -454,3 +499,80 @@ test('Instructor class lifecycle exposes protected-resource capability hooks', a await settle(); assert.ok(disconnected >= 2, 'activation reset and role pause both clear protected-resource context'); }); + + +test('Instructor checkpoint view pauses live observation and returns explicitly to current live Intake', async () => { + let observeReads = 0; + const observed = []; + let ended = 0; + const env = setup(async url => { + if (url === '/api/classes/workspaces') return response(200, rosterBody()); + if (url === '/api/classes/participants') return response(200, participantBody()); + if (url.includes(W1)) { + observeReads += 1; + return response(200, observeReads === 1 + ? observeBody(W1, 'First') + : { + ...observeBody(W1, 'Second'), + workspace: { + ...observeBody(W1, 'Second').workspace, + revision: 2 + } + }); + } + return response(404, {}); + }, { + onObservation: context => observed.push(context), + onObservationEnd: () => { ended += 1; } + }); + + await env.controller.openClass(TOKEN); + assert.equal(dom.window.document.getElementById('oneLine').value, 'First'); + assert.equal(dom.window.document.getElementById('instructorObservationLiveBtn').hidden, true); + + const endedBeforeCheckpoint = ended; + assert.equal(await env.controller.inspectCheckpoint({ + workspace: { id: W1, kind: 'individual', label: 'Alex' }, + checkpoint: { + stageId: 'stage-1', + workspaceRevision: 1, + capturedAt: '2026-10-01T00:05:00Z', + snapshot: { pre: { oneLine: 'Checkpoint before debrief' } } + } + }), true); + + assert.equal(dom.window.document.getElementById('oneLine').value, 'Checkpoint before debrief'); + assert.equal(dom.window.document.getElementById('oneLine').readOnly, true); + assert.equal(dom.window.document.body.dataset.instructorClassStatus, 'observing-checkpoint'); + assert.equal( + dom.window.document.getElementById('instructorObservedRevision').textContent, + 'Checkpoint at debrief start · Revision 1' + ); + assert.equal( + dom.window.document.getElementById('instructorObserverStatus').textContent, + 'Immutable checkpoint · live updates paused' + ); + assert.equal(dom.window.document.getElementById('instructorObservationLiveBtn').hidden, false); + assert.equal(dom.window.document.getElementById('instructorObservationLiveBtn').textContent, 'View current live Intake'); + assert.equal(ended, endedBeforeCheckpoint + 1, 'checkpoint inspection suspends live coaching context'); + assert.deepEqual(env.controller.getState().checkpointView, { + workspaceId: W1, + workspaceLabel: 'Alex', + stageId: 'stage-1', + workspaceRevision: 1, + capturedAt: '2026-10-01T00:05:00Z' + }); + assert.equal(JSON.stringify(env.controller.getState()).includes('Checkpoint before debrief'), false); + assert.equal(dom.window.localStorage.getItem('sentinel'), 'keep-me'); + + assert.equal(await env.controller.returnToLiveObservation(), true); + assert.equal(dom.window.document.getElementById('oneLine').value, 'Second'); + assert.equal(dom.window.document.body.dataset.instructorClassStatus, 'observing'); + assert.equal(dom.window.document.getElementById('instructorObservedRevision').textContent, 'Revision 2'); + assert.equal(dom.window.document.getElementById('instructorObserverStatus').textContent, 'Live read-only view'); + assert.equal(dom.window.document.getElementById('instructorObservationLiveBtn').hidden, true); + assert.equal(env.controller.getState().checkpointView, null); + assert.equal(observed.at(-1).workspaceRevision, 2); + + env.controller.destroy(); +}); diff --git a/tests/classroomInstructor.layout.test.mjs b/tests/classroomInstructor.layout.test.mjs new file mode 100644 index 0000000..8ca25e8 --- /dev/null +++ b/tests/classroomInstructor.layout.test.mjs @@ -0,0 +1,20 @@ +/** + * Instructor rail layout regression coverage. + * + * The fixed desktop rail grows as Classroom orchestration controls are added. + * Its dashboard must remain vertically scrollable so roster/workspace navigation + * cannot be clipped below the viewport. + */ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; + +test('connected desktop Instructor dashboard remains vertically scrollable', async () => { + const styles = await readFile(new URL('../styles.css', import.meta.url), 'utf8'); + + assert.match( + styles, + /body\[data-experience-role="instructor"\]\.instructor-class-connected \.instructor-shell \.instructor-dashboard\s*\{[^}]*overflow-x:hidden;[^}]*overflow-y:auto;[^}]*overscroll-behavior:contain;/u, + 'the fixed Instructor dashboard rail must scroll vertically as orchestration controls grow' + ); +}); diff --git a/tests/classroomStudent.feature.test.mjs b/tests/classroomStudent.feature.test.mjs index 7360529..260f174 100644 --- a/tests/classroomStudent.feature.test.mjs +++ b/tests/classroomStudent.feature.test.mjs @@ -55,6 +55,8 @@ function mount({ fetchImpl, onClassConnected = () => {}, onClassDisconnected = () => {}, + onSessionConnected = () => {}, + onSessionDisconnected = () => {}, fakeTimers = false } = {}) { dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); @@ -112,6 +114,8 @@ function mount({ toast: () => {}, onClassConnected, onClassDisconnected, + onSessionConnected, + onSessionDisconnected, setTimeoutImpl: fakeTimers ? fn => { calls.timers.push(fn); @@ -281,6 +285,66 @@ test('live Student assignment, reassignment, and unassign rotate workspace autho assert.equal(storedWaiting.assignmentRevision, 3); }); +test('live Student session lifecycle is stable across assignment changes and disconnects on role exit', async () => { + let assignment = { + id: WORKSPACE_A, + kind: 'group', + label: 'Team Alpha' + }; + let revision = 1; + const sessionConnections = []; + let sessionDisconnects = 0; + const accessTokens = [WORKSPACE_TOKEN, WORKSPACE_TOKEN_B]; + + const env = mount({ + fakeTimers: true, + onSessionConnected: token => sessionConnections.push(token), + onSessionDisconnected: () => { sessionDisconnects += 1; }, + fetchImpl: async url => { + if (url === '/api/classes/admit') { + return response(200, { + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { id: PARTICIPANT_ID, displayName: 'Alex', assignmentRevision: revision }, + assignment, + studentSessionToken: LIVE_SESSION_TOKEN + }); + } + if (url === '/api/classes/student') { + return response(200, { + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { id: PARTICIPANT_ID, displayName: 'Alex', assignmentRevision: revision }, + assignment + }); + } + if (url === '/api/classes/student/access') { + return response(200, { + class: { id: 'class-1', title: 'Problem Solving 101', expiresAt: '2099-01-01T00:00:00Z' }, + participant: { id: PARTICIPANT_ID, displayName: 'Alex', assignmentRevision: revision }, + assignment, + workspaceToken: accessTokens.shift() + }); + } + return response(500, {}); + } + }); + await settle(); + + assert.equal(await env.controller.join({ classCode: 'K7FM-P4Q2', displayName: 'Alex' }), true); + assert.deepEqual(sessionConnections, [LIVE_SESSION_TOKEN]); + assert.equal(sessionDisconnects, 0); + + assignment = { id: WORKSPACE_B, kind: 'group', label: 'Team Beta' }; + revision = 2; + assert.equal(await env.controller.refreshStatus(), true); + assert.deepEqual(sessionConnections, [LIVE_SESSION_TOKEN], 'workspace reassignment does not reconnect the class-session capability'); + assert.equal(sessionDisconnects, 0, 'workspace reassignment does not disconnect the class-session capability'); + + applyExperienceRole(EXPERIENCE_ROLE_IDS.STANDALONE); + await settle(); + assert.equal(sessionDisconnects, 1); + assert.ok(dom.window.localStorage.getItem(STUDENT_SESSION_STORAGE_KEY), 'role exit keeps the Student resume envelope'); +}); + test('Student join uses one-time class and assignment codes then attaches the issued workspace capability', async () => { const env = mount({ fetchImpl: async (_url, options) => { diff --git a/tests/e2e/classroom-live-integration.spec.mjs b/tests/e2e/classroom-live-integration.spec.mjs index 34896c9..1b3a8d8 100644 --- a/tests/e2e/classroom-live-integration.spec.mjs +++ b/tests/e2e/classroom-live-integration.spec.mjs @@ -5,6 +5,8 @@ import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@playwright/test'; +test.describe.configure({ mode: 'serial' }); + function watchPageErrors(page) { const errors = []; page.on('pageerror', error => errors.push(error.message)); @@ -46,6 +48,7 @@ async function createTeam(page, label) { } test('live class integrates Instructor roster, team sync, isolation, coaching, reassignment, and unassign', async ({ browser }, testInfo) => { + test.setTimeout(75_000); test.skip(testInfo.project.name === 'chromium-mobile', 'Integrated four-browser classroom acceptance is covered once on desktop; mobile Student and Instructor paths have dedicated coverage.'); const contextOptions = { baseURL: testInfo.project.use.baseURL }; @@ -64,6 +67,8 @@ test('live class integrates Instructor roster, team sync, isolation, coaching, r const studentBErrors = watchPageErrors(studentB); const lateStudentErrors = watchPageErrors(lateStudent); const studentATokens = []; + const studentBTokens = []; + const studentBExerciseTokens = []; studentA.on('request', request => { const url = new URL(request.url()); @@ -73,6 +78,19 @@ test('live class integrates Instructor roster, team sync, isolation, coaching, r if (match && !studentATokens.includes(match[1])) studentATokens.push(match[1]); }); + studentB.on('request', request => { + const url = new URL(request.url()); + const header = request.headers().authorization || ''; + const match = /^Bearer\s+(.+)$/u.exec(header); + if (!match) return; + if (url.pathname === '/api/workspaces/session' && !studentBTokens.includes(match[1])) { + studentBTokens.push(match[1]); + } + if (url.pathname === '/api/classes/exercise/student' && !studentBExerciseTokens.includes(match[1])) { + studentBExerciseTokens.push(match[1]); + } + }); + try { await startFresh(instructor); await instructor.getByRole('button', { name: /Teach a class/ }).click(); @@ -86,6 +104,13 @@ test('live class integrates Instructor roster, team sync, isolation, coaching, r await createTeam(instructor, 'Team Alpha'); await createTeam(instructor, 'Team Beta'); + await expect(instructor.locator('#instructorExerciseStatus')).toHaveText('1 staged Case Study available'); + await instructor.getByLabel('Staged Case Study').selectOption('browser-staged-simulation'); + await instructor.getByRole('button', { name: 'Create draft' }).click(); + await expect(instructor.locator('#instructorExerciseStatus')).toHaveText('Draft · draft created'); + await instructor.getByRole('button', { name: 'Start exercise' }).click(); + await expect(instructor.locator('#instructorExerciseStatus')).toHaveText('In progress · exercise started'); + await joinLiveStudent(studentA, { name: 'Student Alpha One', classCode }); await joinLiveStudent(studentB, { name: 'Student Alpha Two', classCode }); @@ -120,6 +145,12 @@ test('live class integrates Instructor roster, team sync, isolation, coaching, r await expect(lateStudent.locator('body')).toHaveAttribute('data-student-class-status', 'connected', { timeout: 10000 }); await expect(lateStudent.locator('#studentClassWorkspace')).toHaveText('Team Beta'); await expect(lateStudent.locator('#oneLine')).toHaveValue('Team Beta live-class Intake.'); + await expect(lateStudent.locator('#studentCaseReference')).toBeVisible(); + await expect(lateStudent.locator('#studentCaseReferenceStatus')).toHaveText('Work'); + await expect(lateStudent.locator('#studentCaseReferenceStageTitle')).toHaveText('Clarify the browser case'); + await expect(lateStudent.locator('#studentCaseReferenceContent')).toContainText('Initial browser briefing'); + await expect(lateStudent.locator('#studentCaseReference')).not.toContainText('Second browser briefing'); + await expect(lateStudent.locator('#studentCaseReference')).not.toContainText('Browser facilitation note'); const betaUpdate = 'Team Beta stayed isolated with its own Intake.'; const betaSave = lateStudent.waitForResponse(response => ( @@ -155,6 +186,15 @@ test('live class integrates Instructor roster, team sync, isolation, coaching, r await expect(studentFeedback).toContainText('Instructor feedback · Needs improvement', { timeout: 10000 }); await expect(studentFeedback).toContainText('Make the deviation measurable before the debrief.'); + const alphaReadyResponse = studentA.waitForResponse(response => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/classes/exercise/student/ready' + && response.ok() + )); + await studentA.getByRole('button', { name: 'Mark Ready' }).click(); + await alphaReadyResponse; + await expect(studentA.locator('#studentCaseReferenceReadinessStatus')).toContainText('Ready for debrief'); + expect(studentATokens.length).toBeGreaterThanOrEqual(1); const staleAlphaToken = studentATokens.at(-1); @@ -162,6 +202,11 @@ test('live class integrates Instructor roster, team sync, isolation, coaching, r await expect(studentA.locator('#studentClassWorkspace')).toHaveText('Team Beta', { timeout: 10000 }); await expect(studentA.locator('#oneLine')).toHaveValue(betaUpdate, { timeout: 10000 }); + await expect(studentA.locator('#studentCaseReference')).toBeVisible(); + await expect(studentA.locator('#studentCaseReferenceStageTitle')).toHaveText('Clarify the browser case'); + await expect(studentA.locator('#studentCaseReferenceStatus')).toHaveText('Work'); + await expect(studentA.locator('#studentCaseReferenceReadinessStatus')).toHaveText('Working'); + await expect(studentA.getByRole('button', { name: 'Mark Ready' })).toHaveAttribute('aria-pressed', 'false'); await expect(studentB.locator('#studentClassWorkspace')).toHaveText('Team Alpha'); await expect(studentB.locator('#oneLine')).toHaveValue(alphaUpdate); expect(studentATokens.length).toBeGreaterThanOrEqual(2); @@ -186,6 +231,72 @@ test('live class integrates Instructor roster, team sync, isolation, coaching, r await expect(lateStudent.locator('#oneLine')).toHaveValue(betaUpdate); await expect(studentB.locator('#oneLine')).toHaveValue(alphaUpdate); + await expect(studentB.locator('#studentCaseReference')).toBeVisible(); + await expect(studentB.locator('#studentCaseReferenceStatus')).toHaveText('Work'); + expect(studentBTokens.length).toBeGreaterThanOrEqual(1); + expect(studentBExerciseTokens.length).toBeGreaterThanOrEqual(1); + + const stagedReadResponse = await studentB.request.get('/api/classes/exercise/student', { + headers: { Authorization: `Bearer ${studentBExerciseTokens.at(-1)}` } + }); + expect(stagedReadResponse.status()).toBe(200); + const stagedReadText = JSON.stringify(await stagedReadResponse.json()); + expect(stagedReadText).toContain('Initial browser briefing'); + expect(stagedReadText).not.toContain('Second browser briefing'); + expect(stagedReadText).not.toContain('Browser facilitation note'); + expect(stagedReadText).not.toContain('Second-stage browser facilitation'); + + const fullPayloadBypass = await studentB.request.post('/api/classes/case-studies/student', { + headers: { Authorization: `Bearer ${studentBTokens.at(-1)}` }, + data: { caseStudyId: 'browser-staged-simulation' } + }); + expect(fullPayloadBypass.status()).toBe(409); + expect(JSON.stringify(await fullPayloadBypass.json())).not.toContain('Synthetic Instructor-only'); + + await instructor.getByRole('button', { name: 'Begin debrief' }).click(); + await expect(instructor.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing frozen during debrief'); + await expect(studentB.locator('#studentCaseReferenceStatus')).toHaveText('Debrief · editing frozen', { timeout: 10000 }); + await expect(studentB.locator('#studentCaseReferenceMessage')).toContainText('frozen Student editing'); + await expect(studentB.locator('#oneLine')).toHaveJSProperty('readOnly', true); + await expect(studentB.locator('#studentClassLeaveBtn')).toBeEnabled(); + + const frozenWorkspaceToken = studentBTokens.at(-1); + const frozenBeforeResponse = await studentB.request.get('/api/workspaces/session', { + headers: { Authorization: `Bearer ${frozenWorkspaceToken}` } + }); + expect(frozenBeforeResponse.status()).toBe(200); + const frozenBefore = await frozenBeforeResponse.json(); + const rejectedSnapshot = JSON.parse(JSON.stringify(frozenBefore.snapshot)); + rejectedSnapshot.pre = rejectedSnapshot.pre && typeof rejectedSnapshot.pre === 'object' + ? rejectedSnapshot.pre + : {}; + rejectedSnapshot.pre.oneLine = 'THIS FROZEN WRITE MUST NOT PERSIST'; + + const frozenWriteResponse = await studentB.request.put('/api/workspaces/session', { + headers: { Authorization: `Bearer ${frozenWorkspaceToken}` }, + data: { + revision: frozenBefore.revision, + snapshot: rejectedSnapshot + } + }); + expect(frozenWriteResponse.status()).toBe(423); + const frozenWriteBody = await frozenWriteResponse.json(); + expect(frozenWriteBody.code).toBe('classroom-editing-locked'); + + const frozenAfterResponse = await studentB.request.get('/api/workspaces/session', { + headers: { Authorization: `Bearer ${frozenWorkspaceToken}` } + }); + expect(frozenAfterResponse.status()).toBe(200); + const frozenAfter = await frozenAfterResponse.json(); + expect(frozenAfter.revision).toBe(frozenBefore.revision); + expect(frozenAfter.snapshot).toEqual(frozenBefore.snapshot); + + await instructor.getByRole('button', { name: 'Allow editing' }).click(); + await expect(instructor.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing allowed during debrief'); + await expect(studentB.locator('#studentCaseReferenceStatus')).toHaveText('Debrief · editing open', { timeout: 10000 }); + await expect(studentB.locator('#oneLine')).toHaveJSProperty('readOnly', false); + await expect(studentB.locator('#studentClassLeaveBtn')).toBeEnabled(); + await expectNoBlockingA11yViolations(instructor); await expectNoBlockingA11yViolations(studentA); @@ -200,3 +311,112 @@ test('live class integrates Instructor roster, team sync, isolation, coaching, r await lateStudentContext.close(); } }); + + +test('Instructor compares immutable debrief checkpoint with current live Intake', async ({ browser }, testInfo) => { + test.setTimeout(60_000); + test.skip(testInfo.project.name === 'chromium-mobile', 'Checkpoint/live comparison is covered once on desktop; mobile Instructor layout has dedicated coverage.'); + + const contextOptions = { baseURL: testInfo.project.use.baseURL }; + const instructorContext = await browser.newContext(contextOptions); + const studentContext = await browser.newContext(contextOptions); + const instructor = await instructorContext.newPage(); + const student = await studentContext.newPage(); + const instructorErrors = watchPageErrors(instructor); + const studentErrors = watchPageErrors(student); + + try { + await startFresh(instructor); + await instructor.getByRole('button', { name: /Teach a class/ }).click(); + await instructor.getByLabel('Class title').fill('Integrated Browser Classroom'); + await instructor.getByRole('button', { name: 'Start class' }).click(); + await expect(instructor.locator('#instructorClassDashboard')).toBeVisible(); + + const classCode = (await instructor.locator('#instructorJoinCode').textContent())?.trim() || ''; + await createTeam(instructor, 'Team Alpha'); + + await instructor.getByLabel('Staged Case Study').selectOption('browser-staged-simulation'); + await instructor.getByRole('button', { name: 'Create draft' }).click(); + await instructor.getByRole('button', { name: 'Start exercise' }).click(); + await expect(instructor.locator('#instructorExerciseStatus')).toHaveText('In progress · exercise started'); + + await joinLiveStudent(student, { name: 'Checkpoint Student', classCode }); + await expect(instructor.locator('[data-participant-id]').filter({ hasText: 'Checkpoint Student' })).toBeVisible({ timeout: 10000 }); + await instructor.getByLabel('Assignment for Checkpoint Student').selectOption({ label: 'Team · Team Alpha' }); + await expect(student.locator('body')).toHaveAttribute('data-student-class-status', 'connected', { timeout: 10000 }); + + const beforeDebrief = 'Team Alpha reasoning captured before debrief.'; + const beforeSave = student.waitForResponse(response => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/workspaces/session' + && response.ok() + )); + await student.locator('#oneLine').fill(beforeDebrief); + await student.locator('#oneLine').blur(); + await beforeSave; + + await instructor.locator('.instructor-workspace-item').filter({ hasText: 'Team Alpha' }).click(); + await expect(instructor.locator('#oneLine')).toHaveValue(beforeDebrief, { timeout: 10000 }); + + await instructor.getByRole('button', { name: 'Begin debrief' }).click(); + const checkpointRow = instructor.locator('.instructor-exercise-checkpoint__row').filter({ hasText: 'Team Alpha' }); + await expect(checkpointRow).toBeVisible(); + const checkpointRevisionText = (await checkpointRow.locator('strong').textContent()) || ''; + const checkpointRevisionMatch = /Revision\s+(\d+)/u.exec(checkpointRevisionText); + expect(checkpointRevisionMatch).not.toBeNull(); + const checkpointRevision = Number(checkpointRevisionMatch[1]); + expect(checkpointRevision).toBeGreaterThan(0); + await instructor.getByRole('button', { name: 'Allow editing' }).click(); + + const afterDebrief = 'Team Alpha refined its reasoning after discussion.'; + const afterSave = student.waitForResponse(response => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/workspaces/session' + && response.ok() + )); + await student.locator('#oneLine').fill(afterDebrief); + await student.locator('#oneLine').blur(); + await afterSave; + await expect(instructor.locator('#oneLine')).toHaveValue(afterDebrief, { timeout: 10000 }); + + const checkpointResponse = instructor.waitForResponse(response => ( + response.request().method() === 'GET' + && new URL(response.url()).pathname === '/api/classes/exercise/checkpoint' + && response.ok() + )); + await instructor.getByRole('button', { + name: `Inspect Team Alpha checkpoint, revision ${checkpointRevision}` + }).click(); + await checkpointResponse; + + await expect(instructor.locator('#oneLine')).toHaveValue(beforeDebrief); + await expect(instructor.locator('#instructorObservedRevision')).toHaveText( + `Checkpoint at debrief start · Revision ${checkpointRevision}` + ); + await expect(instructor.locator('#instructorObserverStatus')).toHaveText( + 'Immutable checkpoint · live updates paused' + ); + await expect(instructor.getByRole('button', { name: 'View current live Intake' })).toBeVisible(); + + const liveObservation = instructor.waitForResponse(response => ( + response.request().method() === 'GET' + && new URL(response.url()).pathname === '/api/classes/observe' + && response.ok() + )); + await instructor.getByRole('button', { name: 'View current live Intake' }).click(); + await liveObservation; + + await expect(instructor.locator('#oneLine')).toHaveValue(afterDebrief); + await expect(instructor.locator('#instructorObservedRevision')).toHaveText( + `Revision ${checkpointRevision + 1}` + ); + await expect(instructor.locator('#instructorObserverStatus')).toHaveText('Live read-only view'); + + await expectNoBlockingA11yViolations(instructor); + expect(instructorErrors).toEqual([]); + expect(studentErrors).toEqual([]); + } finally { + await instructorContext.close(); + await studentContext.close(); + } +}); diff --git a/tests/e2e/coaching.spec.mjs b/tests/e2e/coaching.spec.mjs index cfef1d0..714aa53 100644 --- a/tests/e2e/coaching.spec.mjs +++ b/tests/e2e/coaching.spec.mjs @@ -57,6 +57,9 @@ test('Instructor feedback reaches only the assigned Student and becomes stale af await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Alex Student'); if (suffix === 'm') { + await expect(page.locator('#instructorClassDashboard')).toHaveClass(/is-collapsed/); + await page.getByRole('button', { name: 'Open class panel' }).click(); + await expect(page.locator('#instructorClassDashboard')).not.toHaveClass(/is-collapsed/); await page.locator('[data-workspace-id="22222222-2222-4222-8222-222222222222"]').click(); await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Beta'); } diff --git a/tests/e2e/instructor.spec.mjs b/tests/e2e/instructor.spec.mjs index 0245209..6f6241b 100644 --- a/tests/e2e/instructor.spec.mjs +++ b/tests/e2e/instructor.spec.mjs @@ -68,6 +68,38 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student, await expect(page.locator('[data-participant-id="99999999-9999-4999-8999-999999999999"]')).toContainText('Waiting Student'); await expect(page.getByLabel('Assignment for Waiting Student')).toHaveValue(''); + await expect(page.locator('#instructorExerciseConsole')).toBeVisible(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('1 staged Case Study available'); + await page.getByLabel('Staged Case Study').selectOption('browser-staged-simulation'); + await page.getByRole('button', { name: 'Create draft' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('Draft · draft created'); + await expect(page.locator('#instructorExerciseCaseName')).toHaveText('Browser Staged Simulation'); + await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled(); + await expect(page.locator('#instructorExerciseStartBtn')).toBeVisible(); + + await page.getByRole('button', { name: 'Start exercise' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress · exercise started'); + await expect(page.locator('#instructorExerciseStageTitle')).toHaveText('Clarify the browser case'); + await expect(page.locator('#instructorExerciseStageObjective')).toHaveText('Capture the initial situation in Intake.'); + await expect(page.locator('#instructorExerciseStageTiming')).toHaveText('Suggested time: 5 min'); + await expect(page.locator('#instructorExerciseFacilitation')).toContainText('Browser facilitation note'); + await expect(page.locator('#instructorExerciseFacilitation')).toContainText('Synthetic Instructor-only browser facilitation.'); + await expect(page.locator('#instructorExerciseReleasePanel')).toContainText('Optional browser evidence'); + await expect(page.locator('#instructorExercisePauseBtn')).toBeVisible(); + + await page.getByRole('button', { name: 'Release Optional browser evidence to Students' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress · content released'); + await expect(page.locator('#instructorExerciseReleaseList')).toContainText('Released'); + + await page.getByRole('button', { name: 'Pause exercise' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('Paused · exercise paused'); + await expect(page.locator('#instructorExerciseResumeBtn')).toBeVisible(); + await expect(page.locator('#instructorExerciseLifecycleHelp')).toContainText('Pause controls class pacing only'); + + await page.getByRole('button', { name: 'Resume exercise' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress · exercise resumed'); + await expect(page.locator('#instructorExercisePauseBtn')).toBeVisible(); + const storedAfterStart = await page.evaluate(key => window.localStorage.getItem(key), INSTRUCTOR_SESSION_STORAGE_KEY); expect(storedAfterStart).toContain('K7FM-P4Q2'); expect(storedAfterStart).not.toContain('studentJoinToken'); @@ -88,8 +120,56 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student, await expect(assignment).not.toHaveValue(''); await expect(page.locator('[data-participant-id="99999999-9999-4999-8999-999999999999"]')).toContainText('Team · Team Alpha'); + await page.locator('#instructorExerciseRefreshBtn').click(); + await expect(page.locator('#instructorExerciseProgressSummary')).toHaveText('1 ready · 0 working'); + await expect(page.getByRole('button', { name: 'Observe Team Alpha, Ready' })).toBeVisible(); + await page.getByRole('button', { name: 'Observe Team Alpha, Ready' }).click(); + await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Alpha'); + + await page.getByRole('button', { name: 'Begin debrief' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('Debrief · debrief started'); + await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing frozen during debrief'); + await expect(page.locator('#instructorExerciseCheckpointSummary')).toHaveText('1 captured'); + await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Team Alpha'); + await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Revision 1'); + await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Alpha'); + + await page.getByRole('button', { name: 'Allow editing' }).click(); + await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing allowed during debrief'); + await page.getByRole('button', { name: 'Freeze editing' }).click(); + await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing frozen during debrief'); + + await page.getByRole('button', { name: 'Advance to next stage' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress · advanced to next stage'); + await expect(page.locator('#instructorExerciseStageTitle')).toHaveText('Analyze the browser case'); + await expect(page.locator('#instructorExerciseStageObjective')).toHaveText('Use the second-stage information to refine the analysis.'); + await expect(page.locator('#instructorExerciseStageTiming')).toHaveText('Suggested time: 4 min'); + await expect(page.locator('#instructorExerciseFacilitation')).toContainText('Second-stage browser facilitation'); + await expect(page.locator('#instructorExerciseFacilitation')).toContainText('Synthetic Instructor-only guidance for the final browser stage.'); + await expect(page.locator('#instructorExerciseDebriefPanel')).toBeHidden(); + await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Alpha'); + + await page.getByRole('button', { name: 'Begin debrief' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('Debrief · debrief started'); + await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Student editing allowed during debrief'); + await expect(page.locator('#instructorExerciseCheckpointSummary')).toHaveText('1 captured'); + await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Team Alpha'); + await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Revision 1'); + await expect(page.getByRole('button', { name: 'Complete exercise' })).toBeVisible(); + + await page.getByRole('button', { name: 'Complete exercise' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('Completed · exercise completed'); + await expect(page.locator('#instructorExerciseDetail')).toContainText('No additional Student material is released automatically.'); + await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Exercise completed; staged editing policy no longer applies'); + await expect(page.getByRole('button', { name: 'Complete exercise' })).toBeHidden(); + await expect(page.getByRole('button', { name: 'Freeze editing' })).toBeHidden(); + await expect(page.getByRole('button', { name: 'Allow editing' })).toBeHidden(); + await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Team Alpha'); + expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes')).toBe(true); expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/workspaces')).toBe(true); + expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/exercise')).toBe(true); + expect(classroomRequests.filter(request => request.method === 'PATCH' && request.pathname === '/api/classes/exercise')).toHaveLength(10); expect(classroomRequests.some(request => request.method === 'PATCH' && request.pathname === '/api/classes/participants')).toBe(true); expect(classroomRequests.some(request => request.pathname === '/api/classes/join')).toBe(false); expect(classroomRequests.some(request => request.body.includes('assignmentToken'))).toBe(false); @@ -103,6 +183,51 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student, await expect(page.locator('#instructorJoinCode')).toHaveText('K7FM-P4Q2'); await expect(page.locator('#instructorParticipantSummary')).toHaveText('1 student · 0 waiting'); await expect(page.getByLabel('Assignment for Waiting Student')).not.toHaveValue(''); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('Completed'); + await expect(page.locator('#instructorExerciseCaseName')).toHaveText('Browser Staged Simulation'); + await expect(page.getByLabel('Staged Case Study')).toHaveValue('browser-staged-simulation'); + await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled(); + await expect(page.locator('#instructorExerciseStageTitle')).toHaveText('Analyze the browser case'); + await expect(page.locator('#instructorExerciseProgressSummary')).toHaveText('1 ready · 0 working'); + await expect(page.locator('#instructorExerciseEditingStatus')).toHaveText('Exercise completed; staged editing policy no longer applies'); + await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Team Alpha'); + await expect(page.locator('#instructorExerciseCheckpointList')).toContainText('Revision 1'); + await expect(page.locator('#instructorExerciseLifecycleHelp')).toContainText('completion does not release additional case or exemplar material'); + await expect(page.locator('#instructorExercisePauseBtn')).toBeHidden(); + await expect(page.getByRole('button', { name: 'Advance to next stage' })).toBeHidden(); + await expect(page.getByRole('button', { name: 'Complete exercise' })).toBeHidden(); + + await expectNoBlockingA11yViolations(page); + expect(pageErrors).toEqual([]); +}); + +test('mobile Instructor can open and run the staged exercise console accessibly', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-mobile', 'Focused staged-console mobile acceptance.'); + const pageErrors = watchPageErrors(page); + const instructorCode = `i${'m'.repeat(40)}${testInfo.retry}m`; + + await startFresh(page); + await page.getByRole('button', { name: /Teach a class/ }).click(); + await page.locator('#instructorExistingClass > summary').click(); + await page.getByLabel('Instructor access code').fill(instructorCode); + await page.getByRole('button', { name: 'Open class' }).click(); + + await expect(page.locator('#instructorClassDashboard')).toBeVisible(); + await expect(page.locator('#instructorClassDashboard')).toHaveClass(/is-collapsed/); + await page.getByRole('button', { name: 'Open class panel' }).click(); + await expect(page.locator('#instructorClassDashboard')).not.toHaveClass(/is-collapsed/); + + await expect(page.locator('#instructorExerciseConsole')).toBeVisible(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('1 staged Case Study available'); + await page.getByLabel('Staged Case Study').selectOption('browser-staged-simulation'); + await page.getByRole('button', { name: 'Create draft' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('Draft · draft created'); + + await page.getByRole('button', { name: 'Start exercise' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('In progress · exercise started'); + await expect(page.locator('#instructorExerciseStageTitle')).toHaveText('Clarify the browser case'); + await expect(page.locator('#instructorExerciseFacilitation')).toContainText('Browser facilitation note'); + await expect(page.getByRole('button', { name: 'Pause exercise' })).toBeVisible(); await expectNoBlockingA11yViolations(page); expect(pageErrors).toEqual([]); @@ -133,6 +258,12 @@ test('Instructor opens a class, observes work read-only, switches workspaces, an await expect(page.locator('#instructorClassDashboard')).toBeVisible(); await expect(page.locator('#instructorClassTitle')).toHaveText('Browser Test Classroom'); + if (testInfo.project.name === 'chromium-mobile') { + await expect(page.locator('#instructorClassDashboard')).toHaveClass(/is-collapsed/); + await expect(page.getByRole('button', { name: 'Open class panel' })).toBeVisible(); + await page.getByRole('button', { name: 'Open class panel' }).click(); + await expect(page.locator('#instructorClassDashboard')).not.toHaveClass(/is-collapsed/); + } await expect(page.locator('#instructorRosterSummary')).toHaveText('2 workspaces'); await expect(page.locator('#instructorObservedWorkspace')).toHaveText('Alex Student'); await expect(page.locator('#instructorObserverStatus')).toHaveText('Live read-only view'); diff --git a/tests/e2e/student.spec.mjs b/tests/e2e/student.spec.mjs index c35cd12..53ef87f 100644 --- a/tests/e2e/student.spec.mjs +++ b/tests/e2e/student.spec.mjs @@ -43,13 +43,25 @@ test('Student joins with one code, waits, resumes Team Alpha, moves to Team Beta const displayName = testInfo.project.name === 'chromium-mobile' ? 'Mobile Live Student' : 'Desktop Live Student'; const classCode = 'M7QR-T4P2'; const workspaceTokens = []; + const exerciseTokens = []; + const readinessTokens = []; + const readinessBodies = []; page.on('request', request => { const url = new URL(request.url()); - if (url.pathname !== '/api/workspaces/session') return; const header = request.headers().authorization || ''; const match = /^Bearer\s+(.+)$/u.exec(header); - if (match && !workspaceTokens.includes(match[1])) workspaceTokens.push(match[1]); + if (!match) return; + if (url.pathname === '/api/workspaces/session' && !workspaceTokens.includes(match[1])) { + workspaceTokens.push(match[1]); + } + if (url.pathname === '/api/classes/exercise/student' && !exerciseTokens.includes(match[1])) { + exerciseTokens.push(match[1]); + } + if (url.pathname === '/api/classes/exercise/student/ready') { + readinessTokens.push(match[1]); + readinessBodies.push(request.postDataJSON()); + } }); await startFresh(page); @@ -81,6 +93,27 @@ test('Student joins with one code, waits, resumes Team Alpha, moves to Team Beta await expect(page.locator('#oneLine')).toHaveValue('Team Alpha destination Intake.'); expect(workspaceTokens.length).toBeGreaterThanOrEqual(1); + await expect(page.locator('#studentCaseReference')).toBeVisible(); + if (testInfo.project.name === 'chromium-mobile') { + await expect(page.locator('#studentCaseReference')).toHaveClass(/is-collapsed/); + await expect(page.getByRole('button', { name: 'Open case reference' })).toBeVisible(); + await page.getByRole('button', { name: 'Open case reference' }).click(); + await expect(page.locator('#studentCaseReference')).not.toHaveClass(/is-collapsed/); + } + await expect(page.locator('#studentCaseReferenceTitle')).toHaveText('Browser Staged Simulation'); + await expect(page.locator('#studentCaseReferenceStatus')).toHaveText('Work'); + await expect(page.locator('#studentCaseReferenceStageTitle')).toHaveText('Clarify the browser case'); + await expect(page.locator('#studentCaseReferenceObjective')).toHaveText('Capture the initial situation in Intake.'); + await expect(page.locator('#studentCaseReferenceContent')).toContainText('Initial browser briefing'); + await expect(page.locator('#studentCaseReferenceContent')).toContainText('Optional browser evidence'); + await expect(page.locator('#studentCaseReference')).not.toContainText('Second browser briefing'); + await expect(page.locator('#studentCaseReference')).not.toContainText('Browser facilitation note'); + if (testInfo.project.name === 'chromium-mobile') { + await expectNoBlockingA11yViolations(page); + } + expect(exerciseTokens).toContain(storedWaiting.studentSessionToken); + expect(exerciseTokens.every(token => token === storedWaiting.studentSessionToken)).toBe(true); + const alphaMarker = `${displayName} wrote in Team Alpha.`; const alphaSave = page.waitForResponse(response => ( response.request().method() === 'PUT' @@ -91,6 +124,36 @@ test('Student joins with one code, waits, resumes Team Alpha, moves to Team Beta await page.locator('#oneLine').blur(); await alphaSave; + await expect(page.locator('#studentCaseReferenceReadiness')).toBeVisible(); + await expect(page.locator('#studentCaseReferenceReadinessStatus')).toHaveText('Working'); + const readyResponse = page.waitForResponse(response => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/classes/exercise/student/ready' + && response.ok() + )); + await page.getByRole('button', { name: 'Mark Ready' }).click(); + const readyResult = await readyResponse; + const readyBody = await readyResult.json(); + const capturedRevision = readyBody?.readiness?.readyWorkspaceRevision; + expect(Number.isInteger(capturedRevision)).toBe(true); + expect(capturedRevision).toBeGreaterThan(0); + await expect(page.locator('#studentCaseReferenceReadinessStatus')).toHaveText( + `Ready for debrief · Intake revision ${capturedRevision}` + ); + await expect(page.getByRole('button', { name: 'Resume working' })).toHaveAttribute('aria-pressed', 'true'); + + const resumeResponse = page.waitForResponse(response => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/classes/exercise/student/ready' + && response.ok() + )); + await page.getByRole('button', { name: 'Resume working' }).click(); + await resumeResponse; + await expect(page.locator('#studentCaseReferenceReadinessStatus')).toHaveText('Working'); + await expect(page.getByRole('button', { name: 'Mark Ready' })).toHaveAttribute('aria-pressed', 'false'); + expect(readinessTokens).toEqual([storedWaiting.studentSessionToken, storedWaiting.studentSessionToken]); + expect(readinessBodies).toEqual([{ ready: true }, { ready: false }]); + await page.reload(); await expect(page.locator('#experienceRoleGate')).toBeHidden(); @@ -98,7 +161,10 @@ test('Student joins with one code, waits, resumes Team Alpha, moves to Team Beta await expect(page.locator('body')).toHaveAttribute('data-student-class-status', 'connected', { timeout: 10000 }); await expect(page.locator('#studentClassWorkspace')).toHaveText('Team Alpha'); await expect(page.locator('#oneLine')).toHaveValue(alphaMarker); + await expect(page.locator('#studentCaseReference')).toBeVisible(); + await expect(page.locator('#studentCaseReferenceTitle')).toHaveText('Browser Staged Simulation'); expect(workspaceTokens.length).toBeGreaterThanOrEqual(2); + expect(exerciseTokens.every(token => token === storedWaiting.studentSessionToken)).toBe(true); const resumedAlphaToken = workspaceTokens.at(-1); await expect(page.locator('#studentClassWorkspace')).toHaveText('Team Beta', { timeout: 10000 }); @@ -114,6 +180,7 @@ test('Student joins with one code, waits, resumes Team Alpha, moves to Team Beta await expect(page.locator('body')).toHaveAttribute('data-student-class-status', 'waiting', { timeout: 10000 }); await expect(page.locator('#studentClassWaitingPanel')).toBeVisible(); await expect(page.locator('.wrap')).toBeHidden(); + await expect(page.locator('#studentCaseReference')).toBeHidden(); const staleBetaResponse = await page.request.get('/api/workspaces/session', { headers: { Authorization: `Bearer ${betaToken}` } diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 5c86877..d5fc98f 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -88,6 +88,10 @@ export function createWorkspaceRepository() { alias.revoked = true; return true; }, + async getCapabilityKind(hash) { + const alias = aliasByHash.get(hash); + return alias && !alias.revoked ? alias.capabilityKind : null; + }, async load(hash) { const workspace = resolve(hash); return workspace ? { @@ -106,6 +110,15 @@ export function createWorkspaceRepository() { workspace.revision += 1; return { status: 'updated', workspace: { revision: workspace.revision, expires_at: workspace.expires_at } }; }, + async updateClassroomStudent(hash, snapshot, revision, writePolicy = null) { + if (writePolicy?.allowed === false) { + const workspace = resolve(hash); + return workspace + ? { status: 'locked', revision: workspace.revision, exercise: writePolicy.exercise || null } + : { status: 'missing' }; + } + return this.update(hash, snapshot, revision); + }, async upsertPresence(hash, participantId, displayName) { const workspace = resolve(hash); if (!workspace) return null; @@ -155,11 +168,16 @@ export function createWorkspaceRepository() { /** Create an in-memory classroom repository. @returns {object} Repository. */ export function createClassroomRepository() { let nextInternalId = 1; + let nextExerciseInternalId = 1; const classes = []; const workspaces = []; const memberships = new Map(); const participants = new Map(); const coaching = new Map(); + const exercises = []; + const exerciseReleases = new Map(); + const exerciseWorkspaceState = new Map(); + const exerciseCheckpoints = new Map(); const activeByInstructor = hash => classes.find(item => item.instructorHash === hash && !item.revoked) || null; const activeByJoin = hash => classes.find(item => item.studentJoinHash === hash && item.joinsEnabled && !item.revoked) || null; @@ -174,6 +192,23 @@ export function createClassroomRepository() { expiresAt: item.expiresAt, joinCode: item.studentJoinCode || null } : null; + const publicExercise = exercise => exercise ? { + id: exercise.id, + caseStudyId: exercise.caseStudyId, + simulationVersion: exercise.simulationVersion, + simulationFingerprint: exercise.simulationFingerprint, + status: exercise.status, + currentStageId: exercise.currentStageId, + stagePhase: exercise.stagePhase, + exerciseRevision: exercise.exerciseRevision, + studentEditingEnabled: exercise.studentEditingEnabled, + startedAt: exercise.startedAt, + completedAt: exercise.completedAt, + createdAt: exercise.createdAt, + updatedAt: exercise.updatedAt, + expiresAt: exercise.expiresAt + } : null; + const cloneValue = value => value == null ? value : JSON.parse(JSON.stringify(value)); return { classes, @@ -181,6 +216,10 @@ export function createClassroomRepository() { memberships, participants, coaching, + exercises, + exerciseReleases, + exerciseWorkspaceState, + exerciseCheckpoints, async createClass({ publicId, title, instructorHash, studentJoinHash, studentJoinCode = null }) { const item = { internalId: nextInternalId++, @@ -435,6 +474,517 @@ export function createClassroomRepository() { waiting: false }; }, + async createExercise(instructorHash, { + publicId, + caseStudyId, + simulationVersion, + simulationFingerprint + }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const existing = exercises.find(exercise => ( + exercise.classInternalId === item.internalId + && exercise.status !== 'completed' + )); + if (existing) { + return { + status: 'exists', + classroom: publicClass(item), + exercise: publicExercise(existing) + }; + } + if (exercises.some(exercise => exercise.id === publicId)) return null; + + const exercise = { + internalId: nextExerciseInternalId++, + id: publicId, + classInternalId: item.internalId, + caseStudyId, + simulationVersion, + simulationFingerprint, + status: 'draft', + currentStageId: null, + stagePhase: 'work', + exerciseRevision: 1, + studentEditingEnabled: true, + startedAt: null, + completedAt: null, + createdAt: 'created', + updatedAt: 'created', + expiresAt: item.expiresAt + }; + exercises.push(exercise); + return { + status: 'created', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + }, + async getCurrentExerciseForInstructor(instructorHash) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.status !== 'completed' + )); + return exercise + ? { classroom: publicClass(item), exercise: publicExercise(exercise) } + : null; + }, + async getExerciseForInstructor(instructorHash, exercisePublicId) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + return exercise + ? { classroom: publicClass(item), exercise: publicExercise(exercise) } + : null; + }, + async getCurrentExerciseByClassInternalId(classInternalId) { + const item = classes.find(candidate => candidate.internalId === Number(classInternalId) && !candidate.revoked); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.status !== 'completed' + )); + return publicExercise(exercise); + }, + async hasExerciseForClassCase(classInternalId, caseStudyId) { + const item = classes.find(candidate => candidate.internalId === Number(classInternalId) && !candidate.revoked); + if (!item) return false; + return exercises.some(exercise => ( + exercise.classInternalId === item.internalId + && exercise.caseStudyId === caseStudyId + )); + }, + async getExerciseForStudentSession(sessionHash) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === context.internal.classId + && candidate.status !== 'completed' + )); + if (!exercise) return { ...context, exercise: null, releases: [], readiness: null }; + + const releasePrefix = exercise.internalId + ':'; + const releases = [...exerciseReleases.entries()] + .filter(([key]) => key.startsWith(releasePrefix)) + .map(([, value]) => ({ ...value })) + .sort((a, b) => ( + a.releasedAt.localeCompare(b.releasedAt) + || a.stageId.localeCompare(b.stageId) + || a.contentId.localeCompare(b.contentId) + )); + + let readiness = null; + if (context.internal.workspaceId && exercise.currentStageId) { + const key = exercise.internalId + ':' + context.internal.workspaceId + ':' + exercise.currentStageId; + const state = exerciseWorkspaceState.get(key); + if (state) { + const { + classInternalId: _classInternalId, + exerciseInternalId: _exerciseInternalId, + workspaceInternalId: _workspaceInternalId, + ...publicReadiness + } = state; + readiness = { ...publicReadiness }; + } + } + + return { + ...context, + exercise: publicExercise(exercise), + releases, + readiness + }; + }, + async beginExerciseDebrief(instructorHash, { + exercisePublicId, + expectedRevision, + stageId, + workspaceRepository, + studentEditingEnabled + }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + if (!exercise) return null; + if ( + exercise.exerciseRevision !== expectedRevision + || exercise.status !== 'active' + || exercise.currentStageId !== stageId + || exercise.stagePhase !== 'work' + ) { + return { + status: 'conflict', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + } + + let capturedCount = 0; + for (const workspace of workspaces.filter(candidate => ( + candidate.classInternalId === item.internalId && !candidate.revoked + ))) { + const key = exercise.internalId + ':' + stageId + ':' + workspace.workspaceId; + if (exerciseCheckpoints.has(key)) continue; + const observation = await workspaceRepository.observeById(workspace.workspaceId); + if (!observation) continue; + exerciseCheckpoints.set(key, { + stageId, + workspaceRevision: observation.revision, + snapshot: cloneValue(observation.snapshot), + capturedAt: 'captured', + classInternalId: item.internalId, + exerciseInternalId: exercise.internalId, + workspaceInternalId: workspace.workspaceId + }); + capturedCount += 1; + } + + exercise.stagePhase = 'debrief'; + exercise.studentEditingEnabled = studentEditingEnabled; + exercise.exerciseRevision += 1; + exercise.updatedAt = 'updated'; + return { + status: 'updated', + classroom: publicClass(item), + exercise: publicExercise(exercise), + capturedCount + }; + }, + async updateExerciseLifecycle(instructorHash, { + exercisePublicId, + expectedRevision, + status, + currentStageId, + stagePhase, + studentEditingEnabled + }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + if (!exercise) return null; + if (exercise.exerciseRevision !== expectedRevision) { + return { + status: 'conflict', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + } + + exercise.status = status; + exercise.currentStageId = currentStageId; + exercise.stagePhase = stagePhase; + exercise.studentEditingEnabled = studentEditingEnabled; + exercise.exerciseRevision += 1; + if (status === 'active' && !exercise.startedAt) exercise.startedAt = 'started'; + if (status === 'completed' && !exercise.completedAt) exercise.completedAt = 'completed'; + exercise.updatedAt = 'updated'; + return { + status: 'updated', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + }, + async releaseExerciseContent(instructorHash, { + exercisePublicId, + expectedRevision, + stageId, + contentId + }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + if (!exercise) return null; + + const key = exercise.internalId + ':' + stageId + ':' + contentId; + const existing = exerciseReleases.get(key); + if (existing) { + return { + status: 'unchanged', + classroom: publicClass(item), + exercise: publicExercise(exercise), + release: { ...existing } + }; + } + if (exercise.exerciseRevision !== expectedRevision) { + return { + status: 'conflict', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + } + + const release = { stageId, contentId, releasedAt: 'released' }; + exerciseReleases.set(key, release); + exercise.exerciseRevision += 1; + exercise.updatedAt = 'updated'; + return { + status: 'updated', + classroom: publicClass(item), + exercise: publicExercise(exercise), + release: { ...release } + }; + }, + async listExerciseReleasesForInstructor(instructorHash, exercisePublicId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.id === exercisePublicId + )); + const prefix = exercise.internalId + ':'; + return { + ...current, + releases: [...exerciseReleases.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([, value]) => ({ ...value })) + .sort((a, b) => ( + a.stageId.localeCompare(b.stageId) + || a.contentId.localeCompare(b.contentId) + )) + }; + }, + async setExerciseWorkspaceReadinessBySession(sessionHash, { + exercisePublicId, + stageId, + ready, + workspaceRevision, + expectedWorkspaceInternalId = null + }) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + if (!context.internal.workspaceId || !context.assignment) { + return { status: 'waiting', ...context }; + } + if ( + expectedWorkspaceInternalId !== null + && Number(context.internal.workspaceId) !== Number(expectedWorkspaceInternalId) + ) { + return { status: 'conflict', ...context }; + } + const exercise = exercises.find(candidate => ( + candidate.classInternalId === context.internal.classId + && candidate.id === exercisePublicId + && candidate.currentStageId === stageId + && candidate.status !== 'completed' + )); + if (!exercise) return null; + + const key = exercise.internalId + ':' + context.internal.workspaceId + ':' + stageId; + const previous = exerciseWorkspaceState.get(key); + const nextRevision = ready ? workspaceRevision : null; + if (previous + && previous.readyForDebrief === ready + && previous.readyWorkspaceRevision === nextRevision) { + return { + status: 'unchanged', + classroom: context.classroom, + participant: context.participant, + workspace: context.assignment, + exercise: publicExercise(exercise), + readiness: { ...previous } + }; + } + + const readiness = { + classInternalId: context.internal.classId, + exerciseInternalId: exercise.internalId, + workspaceInternalId: context.internal.workspaceId, + stageId, + readyForDebrief: ready, + readyAt: ready ? 'ready' : null, + readyWorkspaceRevision: nextRevision, + createdAt: previous?.createdAt || 'created', + updatedAt: 'updated' + }; + exerciseWorkspaceState.set(key, readiness); + const { + classInternalId: _classInternalId, + exerciseInternalId: _exerciseInternalId, + workspaceInternalId: _workspaceInternalId, + ...publicReadiness + } = readiness; + return { + status: 'updated', + classroom: context.classroom, + participant: context.participant, + workspace: context.assignment, + exercise: publicExercise(exercise), + readiness: publicReadiness + }; + }, + async listExerciseWorkspaceStateForInstructor(instructorHash, exercisePublicId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.id === exercisePublicId + )); + return { + ...current, + workspaceState: [...exerciseWorkspaceState.values()] + .filter(state => state.exerciseInternalId === exercise.internalId) + .map(state => { + const workspace = workspaces.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.workspaceId === state.workspaceInternalId + && !candidate.revoked + )); + if (!workspace) return null; + return { + workspaceId: workspace.id, + workspaceKind: workspace.kind, + workspaceLabel: workspace.label, + stageId: state.stageId, + readyForDebrief: state.readyForDebrief, + readyAt: state.readyAt, + readyWorkspaceRevision: state.readyWorkspaceRevision, + createdAt: state.createdAt, + updatedAt: state.updatedAt + }; + }) + .filter(Boolean) + }; + }, + async captureExerciseCheckpoint(instructorHash, { + exercisePublicId, + stageId, + workspacePublicId, + workspaceRevision, + snapshot + }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + if (!exercise) return null; + const workspace = workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === workspacePublicId + && !candidate.revoked + )); + if (!workspace) return null; + + const key = exercise.internalId + ':' + stageId + ':' + workspace.workspaceId; + const existing = exerciseCheckpoints.get(key); + if (existing) { + return { + status: 'unchanged', + classroom: publicClass(item), + workspace: { id: workspace.id }, + checkpoint: cloneValue(existing) + }; + } + + const checkpoint = { + stageId, + workspaceRevision, + snapshot: cloneValue(snapshot), + capturedAt: 'captured', + classInternalId: item.internalId, + exerciseInternalId: exercise.internalId, + workspaceInternalId: workspace.workspaceId + }; + exerciseCheckpoints.set(key, checkpoint); + const { + classInternalId: _classInternalId, + exerciseInternalId: _exerciseInternalId, + workspaceInternalId: _workspaceInternalId, + ...publicCheckpoint + } = checkpoint; + return { + status: 'captured', + classroom: publicClass(item), + workspace: { id: workspace.id }, + checkpoint: cloneValue(publicCheckpoint) + }; + }, + async listExerciseCheckpointsForInstructor(instructorHash, exercisePublicId, stageId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.id === exercisePublicId + )); + return { + ...current, + checkpoints: [...exerciseCheckpoints.values()] + .filter(checkpoint => ( + checkpoint.exerciseInternalId === exercise.internalId + && checkpoint.stageId === stageId + )) + .map(checkpoint => { + const workspace = workspaces.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.workspaceId === checkpoint.workspaceInternalId + )); + return { + workspaceId: workspace?.id || null, + workspaceKind: workspace?.kind || null, + workspaceLabel: workspace?.label || null, + stageId: checkpoint.stageId, + workspaceRevision: checkpoint.workspaceRevision, + capturedAt: checkpoint.capturedAt + }; + }) + .filter(checkpoint => checkpoint.workspaceId) + }; + }, + async getExerciseCheckpointForInstructor( + instructorHash, + exercisePublicId, + stageId, + workspacePublicId + ) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.id === exercisePublicId + )); + const workspace = workspaces.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.id === workspacePublicId + && !candidate.revoked + )); + if (!exercise || !workspace) return null; + const checkpoint = exerciseCheckpoints.get( + exercise.internalId + ':' + stageId + ':' + workspace.workspaceId + ); + if (!checkpoint) return null; + return { + ...current, + workspace: { + id: workspace.id, + kind: workspace.kind, + label: workspace.label + }, + checkpoint: { + workspaceId: workspace.id, + workspaceKind: workspace.kind, + workspaceLabel: workspace.label, + stageId: checkpoint.stageId, + workspaceRevision: checkpoint.workspaceRevision, + snapshot: cloneValue(checkpoint.snapshot), + capturedAt: checkpoint.capturedAt + } + }; + }, async rotateStudentJoin(instructorHash, nextHash) { const item = activeByInstructor(instructorHash); if (!item) return null; diff --git a/tests/protectedCaseManifest.unit.test.mjs b/tests/protectedCaseManifest.unit.test.mjs index 3911a1f..08c9364 100644 --- a/tests/protectedCaseManifest.unit.test.mjs +++ b/tests/protectedCaseManifest.unit.test.mjs @@ -8,6 +8,7 @@ import { test } from 'node:test'; import { PROTECTED_CASE_STUDY_MANIFEST } from '../api/protected-case-studies.manifest.js'; import { TEMPLATE_MANIFEST } from '../src/templates.manifest.js'; import { TEMPLATE_KINDS } from '../src/templateKinds.js'; +import { validateStagedSimulation } from '../scripts/staged-simulation-schema.mjs'; test('public manifest contains only Standard Templates while protected cases stay server-side', async () => { assert.ok(TEMPLATE_MANIFEST.length > 0, 'at least one public Standard Template should exist'); @@ -22,8 +23,20 @@ test('public manifest contains only Standard Templates while protected cases sta 'server-only manifest must contain only Case Studies' ); + assert.ok( + TEMPLATE_MANIFEST.every(entry => !Object.hasOwn(entry, 'simulation')), + 'public Standard Template manifest must never contain staged simulation definitions' + ); + const publicIds = new Set(TEMPLATE_MANIFEST.map(entry => entry.id)); for (const caseStudy of PROTECTED_CASE_STUDY_MANIFEST) { + if (caseStudy.simulation !== undefined) { + assert.deepEqual( + validateStagedSimulation(caseStudy.simulation), + [], + `${caseStudy.id} staged simulation should remain valid in the server-only manifest` + ); + } assert.equal(publicIds.has(caseStudy.id), false, `${caseStudy.id} must not be public`); } diff --git a/tests/stagedCaseSimulation.unit.test.mjs b/tests/stagedCaseSimulation.unit.test.mjs new file mode 100644 index 0000000..9cc6e66 --- /dev/null +++ b/tests/stagedCaseSimulation.unit.test.mjs @@ -0,0 +1,253 @@ +/** + * Focused validation for optional server-only staged Case Study definitions. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + normalizeStagedSimulation, + validateSimulationForResource, + validateStagedSimulation +} from '../scripts/staged-simulation-schema.mjs'; + +function validSimulation() { + return { + version: 1, + studentContent: [ + { + id: 'initial-briefing', + kind: 'narrative', + title: ' Initial briefing ', + body: ' A machine has stopped unexpectedly. ' + }, + { + id: 'quality-table', + kind: 'table', + title: 'Quality readings', + headers: ['Line', 'Reject rate'], + rows: [['A', '2%'], ['B', '7%']] + }, + { + id: 'inspection-image', + kind: 'image', + title: 'Inspection photo', + assetId: 'inspection-photo-1', + alt: 'Close view of the affected surface' + }, + { + id: 'supplement-page', + kind: 'document-page', + title: 'Supplemental report', + assetId: 'report-1', + page: 2, + alt: 'Second page of the supplemental report' + } + ], + instructorContent: [ + { + id: 'stage-one-notes', + kind: 'facilitation', + title: 'Stage 1 facilitation', + body: 'Ask teams what they know before offering more evidence.' + }, + { + id: 'stage-two-exemplar', + kind: 'exemplar', + title: 'Stage 2 exemplar', + body: 'Use only during the Instructor-led debrief.' + } + ], + stages: [ + { + id: 'stage-1', + title: 'Clarify the situation', + studentObjective: 'Establish what is known before diagnosing.', + initialReleaseIds: ['initial-briefing', 'quality-table'], + optionalReleaseIds: ['inspection-image'], + intakeTargetIds: ['problem.one-line'], + suggestedMinutes: 15, + instructorContentIds: ['stage-one-notes'], + defaultDebriefEditPolicy: 'frozen' + }, + { + id: 'stage-2', + title: 'Use additional evidence', + studentObjective: 'Refine the analysis using the newly released evidence.', + initialReleaseIds: ['supplement-page'], + optionalReleaseIds: [], + intakeTargetIds: ['problem.one-line', 'pa.what-object'], + suggestedMinutes: null, + instructorContentIds: ['stage-two-exemplar'], + defaultDebriefEditPolicy: 'open' + } + ] + }; +} + +test('valid staged simulation keeps Student and Instructor content structurally separate', () => { + const simulation = validSimulation(); + assert.deepEqual(validateStagedSimulation(simulation), []); + + const normalized = normalizeStagedSimulation(simulation); + assert.equal(normalized.version, 1); + assert.equal(normalized.studentContent[0].title, 'Initial briefing'); + assert.equal(normalized.studentContent[0].body, 'A machine has stopped unexpectedly.'); + assert.equal(normalized.instructorContent[0].kind, 'facilitation'); + assert.equal(normalized.stages[0].defaultDebriefEditPolicy, 'frozen'); + + const studentIds = new Set(normalized.studentContent.map(item => item.id)); + const instructorIds = new Set(normalized.instructorContent.map(item => item.id)); + for (const stage of normalized.stages) { + for (const id of [...stage.initialReleaseIds, ...stage.optionalReleaseIds]) { + assert.equal(studentIds.has(id), true, `${id} should resolve only to Student content`); + assert.equal(instructorIds.has(id), false, `${id} must not resolve to Instructor content`); + } + for (const id of stage.instructorContentIds) { + assert.equal(instructorIds.has(id), true, `${id} should resolve only to Instructor content`); + assert.equal(studentIds.has(id), false, `${id} must not resolve to Student content`); + } + } +}); + +test('simulation is optional for protected cases and forbidden on Standard Templates', () => { + assert.deepEqual( + validateSimulationForResource({ templateKind: 'case-study', simulation: undefined }), + [], + 'existing non-staged Case Studies must remain valid' + ); + + const errors = validateSimulationForResource({ + templateKind: 'standard', + simulation: validSimulation() + }); + assert.ok(errors.some(message => message.includes('only on protected case-study resources'))); +}); + +test('staged simulation rejects duplicate identities and cross-namespace collisions', () => { + const simulation = validSimulation(); + simulation.studentContent.push({ + ...simulation.studentContent[0], + title: 'Duplicate briefing' + }); + simulation.instructorContent[1] = { + ...simulation.instructorContent[1], + id: 'stage-one-notes' + }; + simulation.stages[1] = { + ...simulation.stages[1], + id: 'stage-1' + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.includes('simulation.studentContent ids must be unique')); + assert.ok(errors.includes('simulation.instructorContent ids must be unique')); + assert.ok(errors.includes('simulation.stages ids must be unique')); + + const collision = validSimulation(); + collision.instructorContent[0] = { + ...collision.instructorContent[0], + id: 'initial-briefing' + }; + collision.stages[0] = { + ...collision.stages[0], + instructorContentIds: ['initial-briefing'] + }; + const collisionErrors = validateStagedSimulation(collision); + assert.ok(collisionErrors.includes('Student and Instructor content ids must not collide')); +}); + +test('stage references must exist in the correct content namespace and cannot double-release one item', () => { + const simulation = validSimulation(); + simulation.stages[0] = { + ...simulation.stages[0], + initialReleaseIds: ['initial-briefing', 'future-evidence'], + optionalReleaseIds: ['initial-briefing', 'stage-one-notes'], + instructorContentIds: ['quality-table'] + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.some(message => message.includes('same Student content as both initial and optional release'))); + assert.ok(errors.some(message => message.includes('unknown Student content id: future-evidence'))); + assert.ok(errors.some(message => message.includes('unknown Student content id: stage-one-notes'))); + assert.ok(errors.some(message => message.includes('unknown Instructor content id: quality-table'))); +}); + +test('orphan Student or Instructor content is rejected so authored protected material has an explicit stage', () => { + const simulation = validSimulation(); + simulation.studentContent.push({ + id: 'unused-evidence', + kind: 'evidence', + title: 'Unused evidence', + body: 'This should not silently ride along in a staged definition.' + }); + simulation.instructorContent.push({ + id: 'unused-teaching-note', + kind: 'debrief', + title: 'Unused note', + body: 'This must be deliberately assigned to a stage.' + }); + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.includes('simulation.studentContent id is not referenced by any stage: unused-evidence')); + assert.ok(errors.includes('simulation.instructorContent id is not referenced by any stage: unused-teaching-note')); +}); + +test('content blocks reject unsafe/ambiguous shapes instead of accepting arbitrary markup or URLs', () => { + const simulation = validSimulation(); + simulation.studentContent[0] = { + id: 'initial-briefing', + kind: 'narrative', + title: 'Initial briefing', + body: 'Safe text', + html: '' + }; + simulation.studentContent[2] = { + id: 'inspection-image', + kind: 'image', + title: 'Inspection photo', + assetId: 'https://example.com/public-bypass.png', + alt: '' + }; + simulation.studentContent[3] = { + id: 'supplement-page', + kind: 'document-page', + title: 'Supplemental report', + assetId: 'report-1', + page: 0, + alt: 'Report' + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.some(message => message.includes('.html is not allowed'))); + assert.ok(errors.some(message => message.includes('assetId must be a stable lowercase slug'))); + assert.ok(errors.some(message => message.includes('.alt must be a non-empty string'))); + assert.ok(errors.some(message => message.includes('.page must be a positive integer'))); +}); + +test('table blocks require a semantic rectangular string matrix', () => { + const simulation = validSimulation(); + simulation.studentContent[1] = { + id: 'quality-table', + kind: 'table', + title: 'Quality readings', + headers: ['Line', 'Reject rate'], + rows: [['A'], ['B', 7]] + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.some(message => message.includes('rows[0] must contain exactly 2 string cells'))); + assert.ok(errors.some(message => message.includes('rows[1] must contain exactly 2 string cells'))); +}); + +test('version-1 definitions reject unknown lifecycle/content fields rather than silently widening the contract', () => { + const simulation = validSimulation(); + simulation.futureStagesVisible = true; + simulation.stages[0] = { + ...simulation.stages[0], + autoAdvanceWhenReady: true + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.some(message => message.includes('simulation.futureStagesVisible is not allowed'))); + assert.ok(errors.some(message => message.includes('autoAdvanceWhenReady is not allowed'))); +});