diff --git a/src/dialogs/ComposeReportPeriodModal.vue b/src/dialogs/ComposeReportPeriodModal.vue index b9e4d37e..5c039d94 100644 --- a/src/dialogs/ComposeReportPeriodModal.vue +++ b/src/dialogs/ComposeReportPeriodModal.vue @@ -123,13 +123,52 @@ export default { computed: { /** - * Materialised `isLocked` calculation, read directly off the fetched - * ReportPeriod — mirrors ReportPeriodComposeGuard's own read. + * Whether this period is locked, and therefore composable. + * + * ⚠️ READING `isLocked` ALONE DISABLED THIS BUTTON FOREVER. The field is + * a declared `x-openregister-calculations` entry with `materialise: + * true`, and it IS computed — the create response carries + * `isLocked: true` for a past lockDate. It is simply never returned + * again: measured on a live instance, both the single-object GET and + * the list endpoint omit it entirely. So `period.isLocked` was + * `undefined` on every fetch, `=== true` was always false, and Compose + * could not be clicked no matter how long ago the period locked. + * + * ReportPeriodComposeGuard already carries the same fallback and says + * why — it mirrors the declared expression exactly (`lockDate` set AND + * `lockDate` < now). The two sides now agree instead of one of them + * trusting a field that does not survive a read. + * + * Kept preferring the materialised value: when OpenRegister does start + * projecting it, that becomes the single source and this stops + * recomputing. + * + * The requirement defines locked as "`lockDate` is set AND has passed + * `@now`" and mandates that the GUARDS read `isLocked` directly — it + * says nothing about this dialog, which is why reading the same field + * here, with no fallback, went unnoticed. * * @return {boolean} + * @spec openspec/specs/report-card/spec.md#requirement-lock-date-is-enforced-by-a-materialised-calculation-and-guards-not-an-automatic-transition */ isLocked() { - return !!(this.period && this.period.isLocked === true) + if (!this.period) { + return false + } + + if (this.period.isLocked === true) { + return true + } + + const lockDate = this.period.lockDate + + if (lockDate === null || lockDate === undefined || lockDate === '') { + return false + } + + const lockedAt = Date.parse(lockDate) + + return Number.isNaN(lockedAt) === false && lockedAt < Date.now() }, /** diff --git a/tests/e2e/seed-example-data.mjs b/tests/e2e/seed-example-data.mjs index 20f4b7d1..464f6b22 100644 --- a/tests/e2e/seed-example-data.mjs +++ b/tests/e2e/seed-example-data.mjs @@ -463,6 +463,179 @@ async function seedObjects(presentSlugs) { }) } + // ── Fixtures for scenarios that were SKIPPING rather than running ────── + // + // Fourteen spec-coverage scenarios stood down on `test.skip(!row, 'No X + // seeded…')`, which cannot tell "never seeded" from "the seeder owes this + // and did not make it" and reports the second as a pass. Once those became + // requireFixture() the fourteen failed, and every one named a fixture that + // simply does not exist here: five Lesson states, and four schemas this + // file never created a single row for. + // + // `lifecycle` is set directly, as it already is above for grade-entry / + // enrolment / credential: these are fixtures FOR scenarios about a + // particular state, and the state is the thing under test. + + // Lessons in the states adaptive-release and progress-tracking look for. + // `Demo lesson 6` is deliberately plain — published, text, no + // releaseConditions, no availableAfterDays — because two scenarios want + // exactly "an ungated published lesson" and one of them asserts the ABSENCE + // of a gate. + const lessonPlain = await seed('lesson', { field: 'name', value: 'Demo lesson 6 (published, ungated)' }, { + courseId: id(courseSub) ?? id(courseRoot), name: 'Demo lesson 6 (published, ungated)', order: 6, + contentType: 'text', lifecycle: 'published', tenant_id: TENANT, + }) + await seed('lesson', { field: 'name', value: 'Demo lesson 7 (cmi5)' }, { + courseId: id(courseSub) ?? id(courseRoot), name: 'Demo lesson 7 (cmi5)', order: 7, + contentType: 'cmi5', lifecycle: 'published', tenant_id: TENANT, + }) + // ⚠️ NO `lessonId`, AND THIS GATE THEREFORE DOES NOT GATE. + // + // Sending one made OpenRegister refuse the whole create with + // `403 Unresolved reference: schema:///Lesson#` — it does not resolve a + // $ref inside an ARRAY ITEM, even a self-referential one, even when the + // target exists (ConductionNL/openregister#2179). + // + // Dropping it makes the write succeed, because + // `releaseConditions.items.required` is `["kind"]` alone. But + // LessonReleaseEvaluator::evaluateLessonCompletedCondition() returns + // `blocked => false` immediately when `lessonId` is empty, so this Lesson + // is never actually locked. The row exists to keep the discovery predicate + // honest — it is NOT a working release gate, and the scenario that needs a + // real one is `test.fixme` in adaptive-release.spec.ts rather than passing + // against this stand-in. + await seed('lesson', { field: 'name', value: 'Demo lesson 8 (gated on a prerequisite)' }, { + courseId: id(courseSub) ?? id(courseRoot), name: 'Demo lesson 8 (gated on a prerequisite)', order: 8, + contentType: 'text', lifecycle: 'published', tenant_id: TENANT, + releaseConditions: [{ kind: 'lesson-completed' }], + }) + await seed('lesson', { field: 'name', value: 'Demo lesson 9 (drip-released)' }, { + courseId: id(courseSub) ?? id(courseRoot), name: 'Demo lesson 9 (drip-released)', order: 9, + contentType: 'text', lifecycle: 'published', availableAfterDays: 7, tenant_id: TENANT, + }) + + // ReportPeriods. `isLocked` is a MATERIALISED calculation, not a stored + // field — the register declares it as `lockDate` set AND `lockDate < now`, + // which ReportPeriodComposeGuard reads directly. So a period is made + // "locked" by giving it a lockDate in the PAST; setting isLocked would + // write a field the schema does not have. + // + // ⚠️ P1 IS LOAD-BEARING EVEN THOUGH NOTHING REFERENCES THE BINDING. Two + // scenarios DISCOVER it by shape — "an open + isLocked ReportPeriod" and + // the ReportPeriodLockGuard one, which then looks for a concept GradeEntry + // whose `period` matches its `periodCode` ('P1'). Deleting this because the + // variable looks unused would take both of them out. + // eslint-disable-next-line no-unused-vars + const periodLocked = (id(plan) && id(cohort)) + ? await seed('report-period', { field: 'periodCode', value: 'P1' }, { + name: 'Rapportperiode 1 (demo)', academicYear: '2026', periodCode: 'P1', + startDate: '2026-09-01', endDate: '2026-12-31', + curriculumPlanIds: [id(plan)], cohortIds: [id(cohort)], + lockDate: '2026-01-15T00:00:00Z', lifecycle: 'open', tenant_id: TENANT, + }) + : null + const periodComposed = (id(plan) && id(cohort)) + ? await seed('report-period', { field: 'periodCode', value: 'P2' }, { + name: 'Rapportperiode 2 (demo, composed)', academicYear: '2026', periodCode: 'P2', + startDate: '2027-01-01', endDate: '2027-03-31', + curriculumPlanIds: [id(plan)], cohortIds: [id(cohort)], + lockDate: '2026-02-15T00:00:00Z', lifecycle: 'composed', tenant_id: TENANT, + }) + : null + + // A concept GradeEntry inside the locked period's scope — the row the + // ReportPeriodLockGuard scenario needs something to refuse. + if (id(plan) && id(scale)) { + await seed('grade-entry', { field: 'componentId', value: 'c-concept' }, { + learnerId: 'demo-learner-2', curriculumPlanId: id(plan), gradeScaleId: id(scale), + value: 6, period: 'P1', componentId: 'c-concept', weight: 1, + lifecycle: 'concept', tenant_id: TENANT, + }) + } + // A published GradeEntry whose visibility window has NOT opened yet. The + // date is far enough out that this fixture does not quietly expire and take + // the scenario with it — a fixture that stops matching on a given date is + // the same silent hole this whole block exists to close. + if (id(plan) && id(scale)) { + await seed('grade-entry', { field: 'componentId', value: 'c-future' }, { + learnerId: 'demo-learner-1', curriculumPlanId: id(plan), gradeScaleId: id(scale), + value: 8, period: 'P1', componentId: 'c-future', weight: 1, + lifecycle: 'published', visibleFrom: '2099-01-01T00:00:00Z', tenant_id: TENANT, + }) + } + + // ReportCards, one per lifecycle state the review surface is asserted in. + // + // ⚠️ ALL OF THEM HANG OFF THE **COMPOSED** PERIOD, NOT THE LOCKED ONE. + // RapportvergaderingReviewView renders the card grid inside a `v-else`: + // + // v-if="period.lifecycle === 'open'" -> "not composed yet" + Compose + //