test(e2e): a fixture the seeder owes must FAIL when it is missing - #613
Conversation
`test.skip(!row, 'No X seeded in this environment.')` answers two different
questions the same way, and reports the second as a pass:
- the instance was never seeded -> the question cannot be asked; skip
- the instance WAS seeded and X is absent -> the seeder owes it; that is a
DEFECT, and it must fail
#608 fixed one instance of this after it cost an entire scenario: the
talk-classroom third test ran ZERO times on every green run, because
`Enrolment.lifecycle` defaults to 'pending', the seeder never set 'active', and
the guard matched nothing forever. Earlier still, the lookup ended in
`?? rows[0]`, so a miss asserted against an ARBITRARY row while looking green.
That fix was local to one spec. Twenty more of the same guard were left across
five other files, each able to fail the same way and none able to say so.
adaptive-release 3
course-authoring-ux 4
groepsplan 1
progress-tracking 2
report-card 10
All 23 raw guards are now `requireFixture()`. The helper moves to
`tests/e2e/seeded.ts` because THREE specs had already grown their own copy of
the seeded check, and a rule about what counts as covered should not be spelled
three ways.
⚠️ This is expected to surface work rather than stay quiet. Any scenario that
now fails was already not covering anything; the failure is the first honest
report of that. The fix for one is to seed the fixture, or to fix why the seeder
does not produce it — never to put the skip back.
Verified: prettier clean on every touched file, no root tsconfig so no
typecheck to run, and no assertion deleted or weakened.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-25 22:57 UTC
Download the full PDF report from the workflow artifacts.
Converting the skip guards turned fourteen scenarios red, and every one named
a fixture that simply is not there. Nine of them were not "missing a row" but
missing a SCHEMA: this seeder created zero report-period, report-card,
group-plan and group-plan-subgroup objects, so four whole surfaces had never
been exercised by anything.
Added:
Lesson published+ungated text, cmi5, one gated on a prerequisite,
and one drip-released (availableAfterDays)
ReportPeriod one locked-and-open, one composed, both scoped to the demo
CurriculumPlan and Cohort
GradeEntry a concept entry inside the locked period's scope, and a
published entry whose visibility window has not opened
ReportCard rapportvergadering-review (empty mentorComment), finalised
(citing that not-yet-visible entry), and published-to-parents
GroupPlan + an intensief subgroup that actually has members
Three things worth stating, because each is a way this could have gone quietly
wrong:
`isLocked` is NOT a stored field. The register declares it as a materialised
calculation — `lockDate` set AND `lockDate < now` — which is what
ReportPeriodComposeGuard reads. A period is therefore made locked by giving it
a lockDate in the PAST; writing `isLocked` would have set a property the schema
does not have, and OR would have been right to ignore it.
`releaseConditions[].lessonId` carries a `$ref`, and OR refuses an explicit
null on a $ref-bearing property while accepting an absent key — the same
distinction that made lesson blocks fail to save. The condition is only added
once there is a real lesson to point at.
The not-yet-visible GradeEntry uses a date far enough out that the fixture
cannot quietly expire. A fixture that stops matching on some future date
reopens exactly the hole this change closes.
Verified: every field checked against the register — no unknown properties, and
no required field left unsent. `node --check` passes. `.mjs` is outside this
repo's prettier glob, so its formatting is left alone deliberately.
…mpute it
Three defects, all of them found by the same red run — the one the skip
conversion produced.
1. THE COMPOSE BUTTON COULD NEVER BE CLICKED.
`ComposeReportPeriodModal` gated on `period.isLocked === true`. `isLocked` is a
declared x-openregister-calculations entry with `materialise: true`, and it IS
computed — a create response carries `isLocked: true` for a past lockDate.
It is simply never returned again. Measured against a live instance:
POST (create response) -> isLocked: true
GET /objects/.../{id} -> field ABSENT
GET /objects/...?_limit -> field ABSENT
So the value existed for exactly one HTTP response and was unreadable
thereafter. `period.isLocked` was `undefined` on every fetch the dialog makes,
`=== true` was always false, and Compose stayed disabled no matter how long ago
the period locked. ReportPeriodComposeGuard survived only because it already
carries a defensive fallback — and its docblock says why, which means someone
had seen this shape before. Both sides now agree: prefer the materialised
value, else compute `lockDate` set AND in the past.
2. THE SUBGROUP LIST ADDRESSED A SCHEMA NAME, NOT A SLUG.
`/objects/learniq/GroupPlanSubgroup` — OpenRegister resolves by SLUG
(`group-plan-subgroup`). Slugs are case-insensitive, but case-insensitivity does
not insert hyphens, so it resolved to nothing and answered HTTP 200 with an
empty result set: indistinguishable from "the seeder made none". Every other
list constant in the suite already used the slug form.
3. TWO SEEDS WERE REFUSED BY A $ref LIMITATION.
`403 Unresolved reference: schema:///Lesson#` and
`schema:///CurriculumPlan#` — OpenRegister does not resolve a $ref that sits
inside an ARRAY ITEM, even when the target object exists. The gated Lesson can
drop it (`releaseConditions.items.required` is `["kind"]` alone). The finalised
ReportCard cannot: `subjectGrades.items.required` is `["curriculumPlanId"]`. So
the finalised card is seeded without subjectGrades, which serves the
finalised-card scenario, and the narrower "finalised card citing a not-yet-
visible GradeEntry" scenario is LEFT FAILING rather than skipped. A red test
naming a real platform limitation is worth more than a green one asserting
nothing.
Measured before this commit: 14 failed -> 9 failed, 437 -> 442 passed. Of those
9, three had already stopped being fixture problems and become real assertion
failures — which is the whole point.
Both asked only for contentType + published, which was unambiguous while exactly one such Lesson existed. The seeder now also creates a release-gated and a drip-delayed text Lesson, and LessonPlayer renders its LOCKED branch for those — a branch with no footer, and so no 'Mark lesson complete' button. For the text scenario that meant the assertion turned on whichever row the API happened to return first. The cmi5 scenario is worse, because it asserts the button is ABSENT: a gated lesson shows no button either, so selecting one would have made it pass for entirely the wrong reason and prove nothing about contentType at all. Both now require the ungated shape adaptive-release.spec.ts already uses.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-26 10:35 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-26 11:42 UTC
Download the full PDF report from the workflow artifacts.
gate-16 is diff-scoped, so rewriting isLocked() made it a changed method that must carry an @SPEC. Its siblings in this file all point at openspec/changes/report-card-composer/... — a path that no longer exists, since that change was archived. Those tags are dead references that pass the gate only because gate-16 checks presence, not resolution. This one cites the LIVE spec instead: openspec/specs/report-card/spec.md, whose 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 exactly why reading the same field here with no fallback went unnoticed.
…ister#2179 This scenario needs a finalised ReportCard whose subjectGrades cite the not-yet-visible GradeEntry. That object cannot be written through the object API at all: OpenRegister refuses a $ref inside an ARRAY ITEM with '403 Unresolved reference: schema:///CurriculumPlan#', and subjectGrades.items.required is ["curriculumPlanId"] — the refused property is the required one, so there is no valid object to fall back to. openregister#2179 already tracked this from hermiq. I added learniq's three instances to it: Lesson.releaseConditions[].lessonId (self-referential), ReportCard.subjectGrades[].curriculumPlanId, Assessment.itemRefs[].itemId — the last of which is why this seeder creates zero assessments. fixme, not skip: it reports as a KNOWN failure rather than as a pass, and it names an issue someone can close. Declared as test.fixme(name, fn) rather than a bare test.fixme(true, …) statement, which would have applied to every test in the describe and quietly taken the siblings with it.
Same root cause as the report-card scenario, but it hides better, because the fixture LOOKS present. The scenario needs a Lesson whose releaseConditions carries a lesson-completed entry POINTING AT a prerequisite. `lessonId` is a $ref inside an array item, so OpenRegister refuses the write with 'Unresolved reference: schema:///Lesson#' (ConductionNL/openregister#2179) — self-referential, refused all the same. Dropping `lessonId` makes the write succeed, because items.required is ['kind'] alone. That is the trap. LessonReleaseEvaluator opens with: $lessonId = (string)($condition['lessonId'] ?? ''); if ($lessonId === '') { return ['blocked' => false, …]; } so a condition without a lessonId is treated as NOT BLOCKING. The row satisfies the discovery predicate while being unable to lock anything — the page rendered normally and the test failed on toContain('not available') against an entirely ordinary lesson. A toothless stand-in is worse than no fixture: it would let a green run claim coverage of locking. fixme until #2179 lands, and the seeder now says outright that this row is not a working gate.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-26 14:10 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-26 14:58 UTC
Download the full PDF report from the workflow artifacts.
gate-96 (manifest-copy-style) has been failing on learniq's development, not just on this branch — I checked development's own gates job before assuming it was mine. voice.md §8 bans em-dashes and double-dashes in user-visible manifest copy; this was the one string breaking it: progress.json pages[3].config.widgets[2].content.emptyText 'No sub-competencies found — this is a leaf node (learning outcome).' Replaced the em-dash with a period, which is what the gate's own guidance says to do. Verified by running the gate against the tree: 1274 manifest strings checked, zero findings. No l10n catalogue carried the old string, so nothing goes stale.
The two review scenarios found their card, navigated to the review page, and
found no rows — 'element(s) not found' on Finalise and Reopen.
RapportvergaderingReviewView renders the grid inside a v-else:
v-if="period.lifecycle === 'open'" -> 'not composed yet' + Compose button
<template v-else> -> the grid, with Finalise / Reopen
so a card hanging off an OPEN period is invisible however correct the card
itself is. The card list query is NOT lifecycle-filtered, which is what made
this look like a data problem: I checked the query, saw no gate, and moved on.
The gate is in the template.
It is also what the domain says. Cards come into existence BY composition, so a
card on a period that was never composed is not a state the app can reach — the
fixture was describing an impossible world.
All three cards now hang off P2 (composed). P1 stays exactly as it was: two
scenarios discover it by shape (open + isLocked, and the lock-guard one that
matches a concept GradeEntry on its periodCode), so it is load-bearing even
though nothing references the binding — noted in place so it does not get
tidied away.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ⏭️ | ||||
| phpcs | ⏭️ | ||||
| phpmd | ⏭️ | ||||
| psalm | ⏭️ | ||||
| phpstan | ⏭️ | ||||
| phpmetrics | ⏭️ | ||||
| eslint | ⏭️ | ||||
| stylelint | ⏭️ | ||||
| build | ⏭️ | ||||
| composer | ⏭️ | ⏭️ | |||
| npm | ⏭️ | ⏭️ | |||
| app:check-code | ⏭️ | ||||
| info.xml | ⏭️ | ||||
| REUSE | ⏭️ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-26 20:44 UTC
Download the full PDF report from the workflow artifacts.
… the dashboard
Five of the six remaining failures had one cause, and it was not the fixtures.
src/main.js builds the router with
`createWebHistory(generateUrl('/apps/learniq'))`. vue-router strips that base
from `location.pathname` and appends the UNTOUCHED hash, so
/index.php/apps/learniq/#/report-periods/<id>/review
resolved to `/#/report-periods/<id>/review`, matched no declared route, and
fell through `routesFromManifest`'s `/:pathMatch(.*)*` catch-all — which
`redirect: '/'`s to the DASHBOARD.
So every assertion in these scenarios was made against the dashboard. That is
why they reported `element(s) not found` for Finalise, Reopen and
"Compose report cards…", and why two of them matched their expected text
against a page beginning "Skip to app navigation … Learniq".
⚠️ THIS WAS INVISIBLE PRECISELY BECAUSE THEY SKIPPED. A scenario that stands
down for want of a fixture never navigates, so a broken URL costs nothing and
shows nothing. Seeding the fixtures made them navigate for the first time, and
they landed in the wrong place immediately.
The repo already knew: talk-classroom-spaces, course-authoring-ux,
detail-pages, index-pages and accessibility-conformance each carry this warning
in prose, added when it was fixed there. These four had simply not been reached.
⚠️ STILL OUTSTANDING, DELIBERATELY OUT OF SCOPE HERE: twelve other spec files
use the same hash form and currently PASS — while asserting against the
dashboard. That is the same hollow green this branch exists to remove, and
fixing them will turn several red for real reasons. It wants its own change
rather than being folded into this one.
Verified: prettier clean across the suite, eslint 0 errors.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-26 22:26 UTC
Download the full PDF report from the workflow artifacts.
⚠️ THE PR WAS CONFLICTING, AND ITS CHECKS WENT GREEN ANYWAY. Five checks passed where this branch normally runs 37 — a conflicted PR runs no real CI, so that green described nothing. The tell is the check COUNT, not the colour. Two of my changes had already been made on development while this branch was in flight, which is what conflicted: #610 navigate by path, not by hash — and it covers NINETEEN spec files, where I had fixed the four this branch made run. Theirs is the better change; I kept only the comments recording what the bug cost in these specs. #623 rewrote the same em-dash empty state gate-96 flagged, with better copy ("No sub-competencies. This is a learning outcome, the lowest level in the tree."). Took development's wording wholesale. Everything unique to this branch survives the merge, verified rather than assumed: seeded.ts, 26 requireFixture call sites, both named test.fixme declarations, the isLockedPeriod helper, the seeder's fixture block, and the `group-plan-subgroup` slug fix. Verified on the merged tree: seeder parses, prettier clean across the suite, eslint 0 errors, manifest is valid JSON, and gate-96 reports 1274 strings checked with no findings.
Quality Report — ConductionNL/learniq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 650/650 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-27 01:23 UTC
Download the full PDF report from the workflow artifacts.
test.skip(!row, 'No X seeded in this environment.')answers two differentquestions the same way, and reports the second as a pass:
What the conflation already cost
#608 fixed one instance after it swallowed an entire scenario. The
talk-classroom third test ran zero times on every green run:
Enrolment.lifecycledefaults to'pending', the seeder never set'active',so a
lifecycle === 'active'guard matched nothing forever. Earlier still thelookup ended in
?? rows[0], so a miss asserted against an arbitrary rowwhile still looking green.
That fix was local to one spec. Twenty more of the same guard sat in five
other files, each able to fail the same way and none able to say so:
report-cardcourse-authoring-uxadaptive-releaseprogress-trackinggroepsplanAll 23 are now
requireFixture().Why the helper moved
Three specs had already grown their own copy of the seeded check. A rule about
what counts as covered should not be spelled three ways, so it lives in
tests/e2e/seeded.tsnow. It reads the seeder's marker file(
.e2e-state/seeded-schemas.json), neverprocess.env— globalSetup mutatesthe runner's environment and test workers are separate processes, so the
variable it set is not there to be read.
Any scenario that now fails was already not covering anything — the failure
is the first honest report of that, not a regression this PR introduces. The fix
for one is to seed the fixture, or to fix why the seeder does not produce it.
Never to put the skip back.
I would rather this PR go red and tell the truth than stay green by not asking.
Verification
prettier --checkclean on every touched file (the one reformat is my own newhelper, and it is whitespace only). No root
tsconfig.json, so there is notypecheck to run. No assertion deleted or weakened, and no new skip added.