Skip to content

Add the QTI associate interaction plugin - #6113

Open
rtibblesbot wants to merge 4 commits into
learningequality:unstablefrom
rtibblesbot:issue-6101-e6feda
Open

Add the QTI associate interaction plugin#6113
rtibblesbot wants to merge 4 commits into
learningequality:unstablefrom
rtibblesbot:issue-6101-e6feda

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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 a cardinality="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):

  1. Edit the item, add a pair, and type into both cards — the header stays "Associate" and the correct-response <qti-value> gains a matching space-separated id couple.
  2. Clear the question, blank one card of a pair, and give a pair the same text on both sides — three distinct messages appear, one per rule.
  3. Click "Add distractor", click away to close the editor, then reopen the "New distractor" chip — the draft is still there and only lands in the pool on "Save".
  4. Close the editor and reopen it — pair order and within-pair order survive the round trip.
  5. Narrow the window below the small breakpoint — each pair stacks with its number and delete control on one row.

Worth questioning:

  • buildXML collapses two choices with equal text into one pool entry and bumps its match-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.
  • A distractor is derived from match-max minus the choice's appearances in the correct response, so hand-written XML with a match-max larger than intended silently gains distractors on import.

Screenshots

State Screenshot
Edit mode Edit
View mode, answers hidden View
View mode, answers shown View with answers
Validation Validation
Small screen Mobile

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?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-09-01 15:11 UTC

@rtibblesbot
rtibblesbot force-pushed the issue-6101-e6feda branch 3 times, most recently from 6e5a1f4 to 2c9fff3 Compare August 31, 2026 16:53
@rtibblesbot
rtibblesbot marked this pull request as ready for review August 31, 2026 16:54

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

@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:

  • Pair rowsgrey.v_100grey.v_50 with a fineLine border; cards now sit on surface instead of inheriting the row tint.
  • Pair cards — 40px tall with an 8px text inset; were 50px at the editor's default 24px inset.
  • Chipspadding: 4px 12px with an 8px gap before the delete control; were 0 4px 0 12px, so the × sat flush against the border. Dropping the editor's paragraph margins takes them from 50px to 34px.
  • Chip fill — a chip inverts against its container: tinted in the distractor pool, white in the shuffled pool. Both were white.
  • Shuffled pool boxgrey.v_100grey.v_50.
  • Correct-answer chip bordergreen.v_500green.v_600, matching the mockup and the choice/ordering editors.
  • Pair validation message — aligned to the row's left edge; it was indented under the cards.
  • CopyMatching pairs, Distractors (optional), Response pool (shuffled), Add distractor and both sublabels, verbatim from the mockups. The branch shipped the plan's provisional wording (Correct pairs, Additional options, Options (shuffled), Add option) — which is also why the labels in the PR body's screenshots don't match what the branch renders. The four below are current.

Two mockup details I left out, both more than styling — say the word and I'll add them:

  • the SAVE button on the new-distractor editor (the sibling editors commit on type and close via the minimize control)
  • the Answer placeholder in an empty pair card (TipTapEditor has no placeholder prop)
State Screenshot
Edit Edit
View, answers shown View
Validation Validation
Small screen Mobile

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just few findings from a high-level overview.

},

associateLabel: {
message: 'Connect pairs',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you translate it to "Associate" instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to Associate.

message: 'Connect pairs',
context: 'Display name for an associate question type shown in the question type selector',
},
associateDescription: {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must associate pairs of items, or something similar

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now Learners must associate pairs of items.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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') }));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use userEvent.setup() instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +83 to +89
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();
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Image

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to me what this tests for, is because these choices are not declared on ASSOCIATE_XML?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +267 to +296
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`,
);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +128 to +133
describe('TOO_FEW_PAIRS', () => {
it('returns error when there are no pairs at all', () => {
expect(errorCodes(validateAssociateInteraction(makeState({ pairs: [] })))).toContain(
ValidationError.TOO_FEW_PAIRS,
);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should test also valid pairs, if there is a pair, but its invalid, then show the too few valid pairs error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use 'lodash/flatMap' instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +298 to +305
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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use lodash/shuffle instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the hand-rolled Fisher–Yates is gone.

rtibblesbot and others added 4 commits September 1, 2026 08:06
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QTI] Implement Associate Interaction editor

2 participants