Rich QTI assessmentTest authoring — implementation plan
Status: implemented — 2026-06-15 (branch
dsv1). Slices A–F landed as planned, plus an exhaustive gap-closing follow-up (G–J): the full expression union, test-level outcomes/processing/feedback, itemRefweights/templateDefaults, and inline validation + friendly labels — so every authorableassessmentTestfield is editable. ADR-0020 is accepted; see its Implementation section for the landed detail. This doc is retained as the build record.
Companion to ADR-0020 (decision + rationale). This is the executable build plan for a future session. The lane is cross-repo (conform-ed + emergent) and sliced so each slice is independently shippable and validated.
Let teachers author the full QTI assessmentTest surface the conform-ed delivery engine already
delivers: arbitrarily nested sections; per-level timeLimits, itemSessionControl, selection
(incl. withReplacement) / ordering; part navigationMode / submissionMode; and conditional
preConditions / branchRules. Replace the v0 flat single-part/section authoring outright (no
legacy path — pre-launch, no data).
Architecture
Section titled “Architecture”- conform-ed = single source of truth for the QTI shape (types today; types + zod after this
lane). Generic,
href-based, React-free. - emergent/domain = authoring delta only: an itemRef carrying
itemVersionId(nothref), the authoring structure composed from conform-ed’s factory, and theitemVersionId → hrefmapping into the delivery view. Stable function signatures keep the learner delivery path and server key-withholding maintenance unchanged. - emergent/teacher-portal = the rich UI: a recursive tree editor + per-node settings + an expression builder.
Target types (mirror in zod): AssessmentTestView, TestPartView, AssessmentSectionView,
AssessmentItemRefView, TimeLimitsView, ItemSessionControlView, BranchRuleView,
RpExpressionView — all in @conform-ed/qti-react/headless.
What touches what
Section titled “What touches what”| Area | Files | Change |
|---|---|---|
| conform-ed zod | packages/qti-react/src/ (new test-schema.ts / rp/schema.ts; export via headless.ts + index.ts) |
New zod atoms + recursive rpExpression/branchRule + makeAssessmentTestSchema(itemRefSchema) + assessmentTestViewSchema; release. |
| emergent domain | packages/domain/src/assessment-test.ts, new assessment-condition.ts |
Consume conform-ed factory; authoring itemRef (itemVersionId); recursive flattenTestItemRefs + assessmentTestViewFromTestStructure; condition subset builders + summarizeConditionExpression. |
| seed + teacher lib | packages/db/src/seed/qti-demo.ts, apps/teacher-portal/.../lib/assessment-structure.ts |
Migrate to children; delete flat itemRefs; extractItemVersionIds → reuse domain flatten. |
| API | apps/api/src/features/teacher-offering/{routes.ts,service.ts} |
Validate testStructure with the domain schema (replace z.record passthrough); recursive extraction; freeze-on-publish + keys_withheld unchanged. |
| teacher UI | apps/teacher-portal/src/features/offerings/{lib/test-structure-editor.ts, components/TestStructureEditor.tsx, components/ConditionBuilder.tsx, components/AssessmentsTab.tsx} |
Recursive tree editor + per-node settings + expression builder; i18n. |
| tests | domain unit, finalize/qti tests, teacher component tests, integration | Migrate to children; new coverage. |
Slices (ordered)
Section titled “Slices (ordered)”A. conform-ed QTI zod (+ release)
Section titled “A. conform-ed QTI zod (+ release)”- Add zod for atoms:
timeLimitsSchema,itemSessionControlSchema,selectionSchema,orderingSchema; recursiverpExpressionSchema(permissivekind: z.string(), recursiveexpressions),branchRuleSchema. - Recursive structure via
makeAssessmentTestSchema(itemRefSchema)returning section/part/test schemas; shipassessmentTestViewSchema = makeAssessmentTestSchema(assessmentItemRefViewSchema)(thehrefitemRef). - Verify each schema
safeParses a sample of the corresponding type; confirm React-free. - Release all conform-ed packages single-version (per
feedback_internal_lib_release_all_packages). emergent consumes via the temp tarball-override flow until released, then pins the new version and drops the override (the rowTransform precedent).
B. emergent domain (consume + migrate, no legacy)
Section titled “B. emergent domain (consume + migrate, no legacy)”assessment-test.ts: define authoringassessmentTestItemRefSchema(itemVersionId: z.uuid()plus timing/control/flags);assessmentTestStructureSchema = makeAssessmentTestSchema(authoring itemRef). RecursiveflattenTestItemRefs(depth-first) +assessmentTestViewFromTestStructure(emit all knobs;itemVersionId → href; nav/submission default linear/individual). KeepparseAssessmentTestStructure/isAssessmentTestStructuresignatures.assessment-condition.ts: subset builders (variableExpr,baseValueExpr,compareExpr,and/or/not/isNullExpr),isSupportedConditionExpression,summarizeConditionExpression.- Migrate, delete legacy: rewrite qti-demo seed +
buildTestStructuretochildren; pointextractItemVersionIdsat domain flatten; update finalize + qti integration tests; remove the flatsection.itemRefsform. - Tests: recursive parse/flatten/map, knob passthrough, condition builders/summarize.
C. API plumbing
Section titled “C. API plumbing”- Replace
testStructureSchema = z.record(...)inroutes.tswith the domainassessmentTestStructureSchema; malformed structures rejected at the boundary. - Service reuses
flattenTestItemRefsfor item-version extraction; confirm freeze-on-publish and thekeys_withheldmaintenance still pass (they ride the recursive flatten).
D. Teacher tree editor
Section titled “D. Teacher tree editor”lib/test-structure-editor.ts— pure tree-state ops (seed fromparseAssessmentTestStructure; add/remove/move-up-down/indent-outdent for part/section/subsection/itemRef; serialize back). Unit tested off-DOM.components/TestStructureEditor.tsx+ recursive node rows +NodeSettingsPanel(timing, itemSessionControl, section flags, selection/ordering, part nav/submission). Replace the flat picker inAssessmentsTab.tsx; keep the item picker (availableItems) for inserting refs.- Query the Mantine MCP before any component (repo rule). Expected: Accordion, Card, Stack/Group, Select, NumberInput, Switch, ActionIcon, Tooltip.
- Reordering —
@dnd-kit(@dnd-kit/core+@dnd-kit/sortable+@dnd-kit/utilities), the stack Mantine UI’sDndList/DndListHandle/DndTableexamples use (seehttps://ui.mantine.dev/category/dnd/; sources atmantinedev/ui.mantine.devlib/DndList*). Copy that pattern:DndContext(sensors =PointerSensor+KeyboardSensor,collisionDetection={closestCenter}) →SortableContext(verticalListSortingStrategy) → per-rowuseSortablewithCSS.Transform.toString(transform);onDragEndappliesarrayMove. Tree adaptation: oneSortableContextper section’schildren(ids = node ids); resolve cross-section moves in a singleonDragEndby locating active/over containers.KeyboardSensorgives keyboard reordering natively (no extra fallback work). A drag handle (DndListHandlevariant) is preferable so row controls stay clickable. - i18n under
teacherPortal.offeringDetail.assessments.*inen+zh-Hans;bun run i18n:generatethenoxfmt --writethe generated types. Component tests.
E. Expression builder UI
Section titled “E. Expression builder UI”components/ConditionBuilder.tsx— recursive builder over the fullRpExpressionViewsurface for preConditions (boolean) and branchRules (target + boolean); operands from declared part/section/test outcomes + SCORE plusbaseValueliterals; all operator kinds editable (logical, comparison, arithmetic,mapResponse,testVariables/aggregates, pattern/string).summarizeConditionExpressionis the read-only forward-compat fallback for any kind not yet wired, not the common case. Mount inNodeSettingsPanel.- i18n (operator/field labels) + tests.
F. Integration + docs
Section titled “F. Integration + docs”- Integration test (extend
tests/integration/qti-delivery.integration.test.tsor newqti-authoring.integration.test.ts): author a nested + timed + selection + conditional test via the real teacher API; assert persistence, theassembleDeliverableTestview shape, and finalize. - Flip ADR-0020 to accepted; note ADR-0010 authoring now matches delivery.
Reuse (don’t re-create)
Section titled “Reuse (don’t re-create)”- conform-ed delivery + expression TYPES (
@conform-ed/qti-react/headless) — the zod mirrors them. - emergent
parseAssessmentTestStructure/flattenTestItemRefs/assembleDeliverableTest(extend, keep signatures) — learner delivery unchanged. AssessmentsTabform +updateTeacherAssessmentclient mutation (testStructurealready a body field); existing item picker.- typesafe-i18n workflow; Mantine theme from
packages/ui. - conform-ed release flow + emergent temp tarball-override (rowTransform lane precedent).
Verification
Section titled “Verification”- Per slice:
bun test(touched package) +bun run typecheck. - conform-ed slice: its
bun test+bun run typecheck; release; emergent consumes + pins. - After D/E:
bun run validate:full(lint/format/typecheck/coverage, incl. i18n guard + component tests). - After F:
bun run test:integration— author→deliver→finalize green; full suite stays 75+/0. - Manual: author a 2-part test with a nested section (time limit + select-2-of-3 + “show if part1 SCORE ≥ 1” precondition) in teacher-portal dev; confirm it delivers in the learner player.
Decisions settled (2026-06-15)
Section titled “Decisions settled (2026-06-15)”- O-A. Operator breadth — full
RpExpressionView. The builder authors the entire expression union (logical, comparison, arithmetic,mapResponse,testVariables/aggregates, pattern/string), not just the gating subset.summarizeConditionExpressionremains only as a read-only forward-compat fallback for kinds not yet wired. Scopes slice E larger up front. - O-B. Reordering UX —
@dnd-kit(Mantine UI’s DnD stack). Direct-manipulation reordering of nested nodes via@dnd-kit/core+@dnd-kit/sortable+@dnd-kit/utilities, adapting the Mantine UIDndListpatterns to the tree (see slice D). Keyboard reordering is native viaKeyboardSensor.
Still open
Section titled “Still open”- O-C. Aggregate/cross-learner conditions — ADR-0005 selection-locus (server-anchored, connectivity-gated); out of scope here.