Skip to content

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, itemRef weights/templateDefaults, and inline validation + friendly labels — so every authorable assessmentTest field 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).

  • 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 (not href), the authoring structure composed from conform-ed’s factory, and the itemVersionId → href mapping 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.

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.
  • Add zod for atoms: timeLimitsSchema, itemSessionControlSchema, selectionSchema, orderingSchema; recursive rpExpressionSchema (permissive kind: z.string(), recursive expressions), branchRuleSchema.
  • Recursive structure via makeAssessmentTestSchema(itemRefSchema) returning section/part/test schemas; ship assessmentTestViewSchema = makeAssessmentTestSchema(assessmentItemRefViewSchema) (the href itemRef).
  • 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 authoring assessmentTestItemRefSchema (itemVersionId: z.uuid() plus timing/control/flags); assessmentTestStructureSchema = makeAssessmentTestSchema(authoring itemRef). Recursive flattenTestItemRefs (depth-first) + assessmentTestViewFromTestStructure (emit all knobs; itemVersionId → href; nav/submission default linear/individual). Keep parseAssessmentTestStructure / isAssessmentTestStructure signatures.
  • assessment-condition.ts: subset builders (variableExpr, baseValueExpr, compareExpr, and/or/not/isNullExpr), isSupportedConditionExpression, summarizeConditionExpression.
  • Migrate, delete legacy: rewrite qti-demo seed + buildTestStructure to children; point extractItemVersionIds at domain flatten; update finalize + qti integration tests; remove the flat section.itemRefs form.
  • Tests: recursive parse/flatten/map, knob passthrough, condition builders/summarize.
  • Replace testStructureSchema = z.record(...) in routes.ts with the domain assessmentTestStructureSchema; malformed structures rejected at the boundary.
  • Service reuses flattenTestItemRefs for item-version extraction; confirm freeze-on-publish and the keys_withheld maintenance still pass (they ride the recursive flatten).
  • lib/test-structure-editor.ts — pure tree-state ops (seed from parseAssessmentTestStructure; 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 in AssessmentsTab.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’s DndList / DndListHandle / DndTable examples use (see https://ui.mantine.dev/category/dnd/; sources at mantinedev/ui.mantine.dev lib/DndList*). Copy that pattern: DndContext (sensors = PointerSensor + KeyboardSensor, collisionDetection={closestCenter}) → SortableContext (verticalListSortingStrategy) → per-row useSortable with CSS.Transform.toString(transform); onDragEnd applies arrayMove. Tree adaptation: one SortableContext per section’s children (ids = node ids); resolve cross-section moves in a single onDragEnd by locating active/over containers. KeyboardSensor gives keyboard reordering natively (no extra fallback work). A drag handle (DndListHandle variant) is preferable so row controls stay clickable.
  • i18n under teacherPortal.offeringDetail.assessments.* in en + zh-Hans; bun run i18n:generate then oxfmt --write the generated types. Component tests.
  • components/ConditionBuilder.tsx — recursive builder over the full RpExpressionView surface for preConditions (boolean) and branchRules (target + boolean); operands from declared part/section/test outcomes + SCORE plus baseValue literals; all operator kinds editable (logical, comparison, arithmetic, mapResponse, testVariables/aggregates, pattern/string). summarizeConditionExpression is the read-only forward-compat fallback for any kind not yet wired, not the common case. Mount in NodeSettingsPanel.
  • i18n (operator/field labels) + tests.
  • Integration test (extend tests/integration/qti-delivery.integration.test.ts or new qti-authoring.integration.test.ts): author a nested + timed + selection + conditional test via the real teacher API; assert persistence, the assembleDeliverableTest view shape, and finalize.
  • Flip ADR-0020 to accepted; note ADR-0010 authoring now matches delivery.
  • conform-ed delivery + expression TYPES (@conform-ed/qti-react/headless) — the zod mirrors them.
  • emergent parseAssessmentTestStructure / flattenTestItemRefs / assembleDeliverableTest (extend, keep signatures) — learner delivery unchanged.
  • AssessmentsTab form + updateTeacherAssessment client mutation (testStructure already 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).
  • 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.
  • 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. summarizeConditionExpression remains 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 UI DndList patterns to the tree (see slice D). Keyboard reordering is native via KeyboardSensor.
  • O-C. Aggregate/cross-learner conditions — ADR-0005 selection-locus (server-anchored, connectivity-gated); out of scope here.