Add the QTI associate interaction plugin - #6113
Conversation
6e5a1f4 to
2c9fff3
Compare
AlexVelezLl
left a comment
There was a problem hiding this comment.
@rtibblesbot, from the screenshots, I can see that the padding of the chips is not the same as in the specs, and the colors of the pair rows are not the same as in the specs. Take a closer look at the images in the issue and check that everything is correctly aligned.
|
@AlexVelezLl Fixed, and I swept every element in the editor against the six mockups rather than only the two you named — eight divergences, all changed:
Two mockup details I left out, both more than styling — say the word and I'll add them:
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly |
e0394dd to
6ce2d00
Compare
AlexVelezLl
left a comment
There was a problem hiding this comment.
Just few findings from a high-level overview.
| }, | ||
|
|
||
| associateLabel: { | ||
| message: 'Connect pairs', |
There was a problem hiding this comment.
Could you translate it to "Associate" instead?
There was a problem hiding this comment.
Renamed to Associate.
| message: 'Connect pairs', | ||
| context: 'Display name for an associate question type shown in the question type selector', | ||
| }, | ||
| associateDescription: { |
There was a problem hiding this comment.
Must associate pairs of items, or something similar
There was a problem hiding this comment.
Now Learners must associate pairs of items.
There was a problem hiding this comment.
Let's add what we added for choice interaction, and let's prevent the removal of the last pair, also let's disable the remove button if its the last pair.
There was a problem hiding this comment.
removePair is a no-op at one pair, matching removeChoice, and the delete button is disabled there. Spec gained that case plus a max-associations block mirroring choice's max-choices one. Checked the branch's other list mutators: removeDistractor is the only sibling, and distractors are optional, so it keeps no floor.
|
|
||
| it('appends a third pair when the add pair button is clicked', async () => { | ||
| renderEditor(); | ||
| await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addPairBtn') })); |
There was a problem hiding this comment.
Let's use userEvent.setup() instead.
There was a problem hiding this comment.
Whole editor spec is on userEvent.setup() now — 43 interactions, no fireEvent left under interactions/associate/. It was the only spec on the branch using it; the parse, validate and composable specs call the units directly.
| it('appends a second distractor when the add distractor button is clicked', async () => { | ||
| renderEditor(); | ||
| await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addDistractorBtn') })); | ||
| expect( | ||
| screen.getByRole('button', { name: tr.$tr('deleteDistractorBtn', { number: 2 }) }), | ||
| ).toBeInTheDocument(); | ||
| }); |
There was a problem hiding this comment.
This behavior is incorrect. When the "Add distractor" button is clicked, it should show a TipTapEditor below the distractors pool, and it should be saved when the Save button is clicked.
If focus is lost and tiptap is closed, let's add a small "new distractor" label so that users can come back and edit it until they hit save. Once they hit save, then we can show the add distractor button again.
There was a problem hiding this comment.
Reworked. Add distractor opens a TipTap editor below the pool with a Save button; the draft lives outside state, so nothing joins the pool and nothing is emitted until Save. Closing the editor (by clicking a pair, say) leaves a small New distractor chip that reopens it with the written content still there; Save turns it into a chip and brings the Add distractor button back.
Verified in the running editor, not only in jsdom — worth it, because the first version passed its tests and was dead in the browser: the click that opens the draft finishes bubbling after TipTap has mounted its outside-click listener, so the editor closed itself immediately. Add pair had the same bug (the new pair's editor never stayed open). Both add buttons now stop the click.
| expect(contentsOf(parse(ASSOCIATE_XML, [decl]).pairs)).toEqual([['Capulet', 'Montague']]); | ||
| }); | ||
|
|
||
| it('drops a pair naming identifiers that only exist on Object.prototype', () => { |
There was a problem hiding this comment.
It's not clear to me what this tests for, is because these choices are not declared on ASSOCIATE_XML?
There was a problem hiding this comment.
Right — constructor/toString are not declared in ASSOCIATE_XML, and the point is that the pool lookup must not resolve them off Object.prototype (it is a Map, not a plain object). Renamed the test to say that and added the reason as a comment on both prototype cases.
| it('counts a distractor repeat of paired content towards match-max', () => { | ||
| const distractors = [{ id: 'choice_zzz00000', content: 'Antonio' }]; | ||
| const root = parseXmlString(build({ ...baseState, distractors }).bodyXml); | ||
| const antonio = choicesOf(root).filter(el => el.textContent === 'Antonio'); | ||
| expect(antonio).toHaveLength(1); | ||
| expect(antonio[0].getAttribute('match-max')).toBe('2'); | ||
| }); | ||
|
|
||
| it('reassigns the id of a later choice that reuses an id with different content', () => { | ||
| const pairs = [ | ||
| baseState.pairs[0], | ||
| [ | ||
| { id: 'choice_aaa11111', content: 'Capulet' }, | ||
| { id: 'choice_ddd44444', content: 'Montague' }, | ||
| ], | ||
| ]; | ||
| const { bodyXml, responseDeclarations } = build({ | ||
| ...baseState, | ||
| pairs, | ||
| distractors: [], | ||
| }); | ||
| const [capulet] = choicesOf(parseXmlString(bodyXml)).filter( | ||
| el => el.textContent === 'Capulet', | ||
| ); | ||
| expect(capulet.getAttribute('identifier')).toMatch(/^choice_/); | ||
| expect(capulet.getAttribute('identifier')).not.toBe('choice_aaa11111'); | ||
| expect(valuesOf(parseXmlString(responseDeclarations[0]))[1]).toBe( | ||
| `${capulet.getAttribute('identifier')} choice_ddd44444`, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Could you make some permutations of these to test what happens if: the first appearance is repeated, then a following has the same id but different content, etc. Also, lets test that the first appearance keep the id.
There was a problem hiding this comment.
Added an id normalization block with four permutations: a repeat carrying a different id (the first id wins, the second never appears), a later choice reusing an id with different content (the first keeps the id), a repeat followed by an id conflict on the same choice (match-max="2" plus a fresh id for the conflict), and a third choice repeating already-reassigned content. Each asserts the pool and the <qti-value> list.
| describe('TOO_FEW_PAIRS', () => { | ||
| it('returns error when there are no pairs at all', () => { | ||
| expect(errorCodes(validateAssociateInteraction(makeState({ pairs: [] })))).toContain( | ||
| ValidationError.TOO_FEW_PAIRS, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
This should test also valid pairs, if there is a pair, but its invalid, then show the too few valid pairs error.
There was a problem hiding this comment.
Added: the only pair blank, the only pair holding the same content twice, every pair invalid for a different reason, and one valid pair among invalid ones (no error).
| pairedCount.set(id, (pairedCount.get(id) || 0) + 1); | ||
| } | ||
|
|
||
| const distractors = pool.flatMap(({ id, content, matchMax }) => |
There was a problem hiding this comment.
Could we use 'lodash/flatMap' instead?
There was a problem hiding this comment.
Done, and the rest of the branch with it — 6 sites: parse.js (3), validate.js (1), the editor (2). No native flat/flatMap left in the associate plugin.
| function shuffled(items) { | ||
| const result = [...items]; | ||
| for (let i = result.length - 1; i > 0; i--) { | ||
| const j = Math.floor(Math.random() * (i + 1)); | ||
| [result[i], result[j]] = [result[j], result[i]]; | ||
| } | ||
| return result; | ||
| } |
There was a problem hiding this comment.
Could we use lodash/shuffle instead?
There was a problem hiding this comment.
Done — the hand-rolled Fisher–Yates is gone.
Splits the single flat pool of <qti-simple-associable-choice> elements into authoring state: `pairs` from the correct response, `distractors` from the match-max capacity the correct response does not consume. buildXML re-merges them, normalizing ids so equal content shares one pool entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Registering the descriptor does not populate QUESTION_TYPE_LABELS, so QTIItemEditor gets an explicit ASSOCIATE entry — without it every associate item's view-mode header reads "Unknown type". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6ce2d00 to
366bf77
Compare




Summary
Associate questions could not be authored: an item whose body is
<qti-associate-interaction>had no plugin, so it fell back to the choice descriptor and its header read "Unknown type". This adds the plugin end to end — the flat choice pool parses into correct pairs plus distractors, serializes back to acardinality="multiple" base-type="pair"declaration, validates, and renders in an editor.References
Fixes #6101.
Reviewer guidance
JS tests pass on CI. Beyond those, on the QTI demo page (
/channels/<id>/#/qti-demo, question 7):<qti-value>gains a matching space-separated id couple.Worth questioning:
buildXMLcollapses two choices with equal text into one pool entry and bumps itsmatch-max, so an author who deliberately wants two visually identical options gets one. Blank content is exempt, otherwise a freshly added pair could not round-trip.match-maxminus the choice's appearances in the correct response, so hand-written XML with amatch-maxlarger than intended silently gains distractors on import.Screenshots
axe-core (WCAG 2 AA) on the demo page reports one violation: colour contrast on
AddListItemButton's label. It reproduces on the unchanged ordering editor, which uses the same shared component.AI usage
Used Claude Code to implement the plugin test-first against a written plan, following the ordering interaction as the reference. Verified with the Jest suite,
pre-commit, manual QA on the QTI demo page, and an axe-core audit.@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
🟡 Waiting for feedback
Last updated: 2026-09-01 15:11 UTC