From b52f5a016ca9d1f2f10c40b5a543409b3bdbb54c Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Thu, 22 Oct 2026 17:13:37 -0500 Subject: [PATCH 1/6] Add the core for converting a question to another type Changing a question's type carries what the question holds into the new type, and names what that would remove or change so the author can confirm first. This adds the part that knows no interaction: each descriptor will export its state into neutral facets (answers, pairs, a passage) and import from them, and the orchestrator moves the facets a type takes, translates what makes answers correct and content levels, and works out the losses from one table. A facet the new type doesn't take is reported as a loss, so a type that takes nothing, as the base descriptor's defaults do, loses the rest of the question only with a warning. A passage a type doesn't take is folded into the question instead, since it is often the whole question. Nothing calls this yet. The unused convertsFrom placeholder on the descriptors goes, replaced by the conversion methods. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../shared/views/QTIEditor/constants.js | 45 +++ .../conversion/__tests__/answers.spec.js | 273 ++++++++++++++++++ .../conversion/__tests__/carryContent.spec.js | 107 +++++++ .../conversion/__tests__/content.spec.js | 105 +++++++ .../conversion/__tests__/passage.spec.js | 93 ++++++ .../views/QTIEditor/conversion/answers.js | 144 +++++++++ .../views/QTIEditor/conversion/content.js | 119 ++++++++ .../views/QTIEditor/conversion/index.js | 127 ++++++++ .../views/QTIEditor/conversion/passage.js | 80 +++++ .../interactions/InteractionDescriptor.js | 35 +++ .../__tests__/InteractionDescriptor.spec.js | 23 ++ .../interactions/associate/Descriptor.js | 1 - .../interactions/choice/Descriptor.js | 1 - .../interactions/match/Descriptor.js | 1 - .../interactions/ordering/Descriptor.js | 1 - .../interactions/textEntry/Descriptor.js | 1 - .../views/QTIEditor/serialization/xml.js | 12 + 17 files changed, 1163 insertions(+), 5 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/answers.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/carryContent.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/content.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/passage.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/answers.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/content.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/index.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/passage.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 94ed070579..7d043139df 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -134,6 +134,51 @@ export const ValidationError = Object.freeze({ DUPLICATE_MATCH_CONTENT: 'DUPLICATE_MATCH_CONTENT', }); +/** + * What makes an answer correct, for the question types whose answers are a list + * (conversion/answers.js translates one into another). + */ +export const CorrectCriterion = Object.freeze({ + // The answers marked correct (single and multi select) + SELECTED: 'selected', + // Every answer (numeric and text entry) + ACCEPTED: 'accepted', + // The order of the answers (ordering) + ORDERED: 'ordered', +}); + +/** + * How much markup a field can hold, from most to least. A question type change moves + * answers between fields of different levels: going down drops what the lower level can't + * hold, which the author is told about first, and going up loses nothing. + */ +export const ContentLevel = Object.freeze({ + // Blocks, images and formulas, as a rich text editor writes them: choices, ordering + // items, associate and match items + RICH: 'rich', + // Text, inline formatting and formulas, with no blocks, for a field that sits inside a + // sentence: gap match answers + INLINE: 'inline', + // Text only: numeric and text entry answers, inline choice options + PLAIN: 'plain', +}); + +/** + * What changing a question's type would remove or change in a way validation can't catch, + * so the author confirms it first. In the order the author is told about them. + */ +export const ConversionLoss = Object.freeze({ + ANSWERS_REMOVED: 'ANSWERS_REMOVED', + INCORRECT_ANSWERS_REMOVED: 'INCORRECT_ANSWERS_REMOVED', + ANY_CORRECT_ANSWER_ACCEPTED: 'ANY_CORRECT_ANSWER_ACCEPTED', + CORRECT_ANSWERS_REDUCED: 'CORRECT_ANSWERS_REDUCED', + CORRECT_ANSWERS_NOT_KEPT: 'CORRECT_ANSWERS_NOT_KEPT', + ORDER_BECOMES_ANSWER: 'ORDER_BECOMES_ANSWER', + ORDER_NOT_KEPT: 'ORDER_NOT_KEPT', + FORMATTING_REMOVED: 'FORMATTING_REMOVED', + PASSAGE_ADDED_TO_QUESTION: 'PASSAGE_ADDED_TO_QUESTION', +}); + export const RESPONSE_IDENTIFIER = 'RESPONSE'; export const SENTINEL_MARKER = 'data-studio-sentinel'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/answers.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/answers.spec.js new file mode 100644 index 0000000000..8211c104e2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/answers.spec.js @@ -0,0 +1,273 @@ +import { CorrectCriterion, ContentLevel, ConversionLoss } from '../../constants'; +import { carryAnswers } from '../answers'; + +const item = (content, correct = false) => ({ content, correct }); +const list = (correctCriterion, items, contentLevel = ContentLevel.RICH) => ({ + correctCriterion, + contentLevel, + items, +}); +const format = (correctCriterion, extra = {}) => ({ + correctCriterion, + contentLevel: ContentLevel.RICH, + ...extra, +}); + +const summary = items => items.map(({ content, correct }) => [content, correct]); + +describe('carryAnswers', () => { + describe('from selected answers', () => { + const answers = list(CorrectCriterion.SELECTED, [ + item('

A

', true), + item('

B

'), + item('

C

', true), + ]); + + it('keeps every answer and its mark for another selected type', () => { + const result = carryAnswers(answers, format(CorrectCriterion.SELECTED)); + expect(summary(result.answers.items)).toEqual([ + ['

A

', true], + ['

B

', false], + ['

C

', true], + ]); + expect(result.losses).toEqual([]); + }); + + it('keeps only the correct answers as accepted ones, and says the others go', () => { + const result = carryAnswers(answers, format(CorrectCriterion.ACCEPTED)); + expect(summary(result.answers.items)).toEqual([ + ['

A

', true], + ['

C

', true], + ]); + expect(result.losses).toEqual([ + ConversionLoss.INCORRECT_ANSWERS_REMOVED, + ConversionLoss.ANY_CORRECT_ANSWER_ACCEPTED, + ]); + }); + + it('says nothing of accepting any one answer when only one was correct', () => { + const oneCorrect = list(CorrectCriterion.SELECTED, [ + item('

A

', true), + item('

B

'), + ]); + expect(carryAnswers(oneCorrect, format(CorrectCriterion.ACCEPTED)).losses).toEqual([ + ConversionLoss.INCORRECT_ANSWERS_REMOVED, + ]); + }); + + it('does not count a blank correct answer towards accepting any one answer', () => { + const withBlank = list(CorrectCriterion.SELECTED, [ + item('

A

', true), + item('

', true), + ]); + expect(carryAnswers(withBlank, format(CorrectCriterion.ACCEPTED)).losses).toEqual([]); + }); + + it('removes a blank wrong answer without counting it as a loss', () => { + const withBlank = list(CorrectCriterion.SELECTED, [item('

A

', true), item('

')]); + expect(carryAnswers(withBlank, format(CorrectCriterion.ACCEPTED)).losses).toEqual([]); + }); + + it('keeps every answer for ordering, saying the marks go and the order becomes the correct answer', () => { + const result = carryAnswers(answers, format(CorrectCriterion.ORDERED)); + expect(summary(result.answers.items)).toEqual([ + ['

A

', false], + ['

B

', false], + ['

C

', false], + ]); + expect(result.losses).toEqual([ + ConversionLoss.CORRECT_ANSWERS_NOT_KEPT, + ConversionLoss.ORDER_BECOMES_ANSWER, + ]); + }); + + it('says nothing about marks for ordering when none was marked', () => { + const unmarked = list(CorrectCriterion.SELECTED, [item('

A

'), item('

B

')]); + expect(carryAnswers(unmarked, format(CorrectCriterion.ORDERED)).losses).toEqual([ + ConversionLoss.ORDER_BECOMES_ANSWER, + ]); + }); + + it('says nothing about the order for ordering with only one answer', () => { + const single = list(CorrectCriterion.SELECTED, [item('

A

'), item('

')]); + expect(carryAnswers(single, format(CorrectCriterion.ORDERED)).losses).toEqual([]); + }); + }); + + describe('from accepted answers', () => { + const answers = list( + CorrectCriterion.ACCEPTED, + [item('Paris', true), item('paris', true)], + ContentLevel.PLAIN, + ); + + it('marks every answer correct for a selected type', () => { + const result = carryAnswers(answers, format(CorrectCriterion.SELECTED)); + expect(summary(result.answers.items)).toEqual([ + ['

Paris

', true], + ['

paris

', true], + ]); + expect(result.losses).toEqual([]); + }); + + it('keeps every answer for another accepted type', () => { + const result = carryAnswers(answers, { + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + }); + expect(summary(result.answers.items)).toEqual([ + ['Paris', true], + ['paris', true], + ]); + expect(result.losses).toEqual([]); + }); + + it('keeps every answer for ordering, saying the order becomes the correct answer', () => { + const result = carryAnswers(answers, format(CorrectCriterion.ORDERED)); + expect(result.answers.items).toHaveLength(2); + expect(result.losses).toEqual([ConversionLoss.ORDER_BECOMES_ANSWER]); + }); + }); + + describe('from ordered answers', () => { + const answers = list(CorrectCriterion.ORDERED, [item('

1

'), item('

2

')]); + + it('keeps every answer, unmarked, for a selected type, saying the order goes', () => { + const result = carryAnswers(answers, format(CorrectCriterion.SELECTED)); + expect(summary(result.answers.items)).toEqual([ + ['

1

', false], + ['

2

', false], + ]); + expect(result.losses).toEqual([ConversionLoss.ORDER_NOT_KEPT]); + }); + + it('removes every answer for an accepted type, since none was accepted', () => { + const result = carryAnswers(answers, { + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + }); + expect(result.answers.items).toEqual([]); + expect(result.losses).toEqual([ConversionLoss.ANSWERS_REMOVED]); + }); + + it('keeps every answer for another ordered type', () => { + expect(carryAnswers(answers, format(CorrectCriterion.ORDERED)).losses).toEqual([]); + }); + }); + + describe('into single choice', () => { + it('keeps the first correct answer marked, saying the others lose their mark', () => { + const answers = list(CorrectCriterion.SELECTED, [ + item('

A

'), + item('

B

', true), + item('

C

', true), + ]); + const result = carryAnswers(answers, format(CorrectCriterion.SELECTED, { single: true })); + expect(summary(result.answers.items)).toEqual([ + ['

A

', false], + ['

B

', true], + ['

C

', false], + ]); + expect(result.losses).toEqual([ConversionLoss.CORRECT_ANSWERS_REDUCED]); + }); + + it('keeps the mark on an answer with content over a blank one marked first', () => { + const answers = list(CorrectCriterion.SELECTED, [ + item('

', true), + item('

B

', true), + ]); + const result = carryAnswers(answers, format(CorrectCriterion.SELECTED, { single: true })); + expect(summary(result.answers.items)).toEqual([ + ['

', false], + ['

B

', true], + ]); + expect(result.losses).toEqual([]); + }); + + it('says nothing when only one answer was correct', () => { + const answers = list(CorrectCriterion.SELECTED, [item('

A

', true), item('

B

')]); + expect( + carryAnswers(answers, format(CorrectCriterion.SELECTED, { single: true })).losses, + ).toEqual([]); + }); + + it('marks none when none was correct', () => { + const answers = list(CorrectCriterion.SELECTED, [item('

A

'), item('

B

')]); + const result = carryAnswers(answers, format(CorrectCriterion.SELECTED, { single: true })); + expect(result.answers.items.every(({ correct }) => !correct)).toBe(true); + }); + + it('keeps only the first of several accepted answers marked', () => { + const answers = list( + CorrectCriterion.ACCEPTED, + [item('Paris', true), item('paris', true)], + ContentLevel.PLAIN, + ); + const result = carryAnswers(answers, format(CorrectCriterion.SELECTED, { single: true })); + expect(summary(result.answers.items)).toEqual([ + ['

Paris

', true], + ['

paris

', false], + ]); + expect(result.losses).toEqual([ConversionLoss.CORRECT_ANSWERS_REDUCED]); + }); + }); + + describe('content levels', () => { + it('reads rich answers as plain text, saying formatting goes', () => { + const answers = list(CorrectCriterion.SELECTED, [item('

Paris

', true)]); + const result = carryAnswers(answers, { + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + }); + expect(summary(result.answers.items)).toEqual([['Paris', true]]); + expect(result.losses).toEqual([ConversionLoss.FORMATTING_REMOVED]); + }); + + it('removes an answer that was only an image', () => { + const answers = list(CorrectCriterion.SELECTED, [ + item('

', true), + item('

B

', true), + ]); + const result = carryAnswers(answers, { + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + }); + expect(summary(result.answers.items)).toEqual([['B', true]]); + expect(result.losses).toEqual([ + ConversionLoss.ANY_CORRECT_ANSWER_ACCEPTED, + ConversionLoss.FORMATTING_REMOVED, + ]); + }); + + it('keeps plain text from a single paragraph without a loss', () => { + const answers = list(CorrectCriterion.SELECTED, [item('

Paris

', true)]); + expect( + carryAnswers(answers, { + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + }).losses, + ).toEqual([]); + }); + }); + + it('carries the answers that travel with each one, and the shuffle setting', () => { + const answers = { + ...list( + CorrectCriterion.ACCEPTED, + [{ content: 'H2O', correct: true, caseSensitive: true }], + ContentLevel.PLAIN, + ), + shuffle: true, + }; + const result = carryAnswers(answers, { + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + }); + expect(result.answers).toEqual({ + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + items: [{ content: 'H2O', correct: true, caseSensitive: true }], + shuffle: true, + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/carryContent.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/carryContent.spec.js new file mode 100644 index 0000000000..e3a13c355f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/carryContent.spec.js @@ -0,0 +1,107 @@ +import { CorrectCriterion, ContentLevel, ConversionLoss } from '../../constants'; +import { carryContent } from '../index'; + +const answers = { + correctCriterion: CorrectCriterion.SELECTED, + contentLevel: ContentLevel.RICH, + items: [ + { content: '

A

', correct: true }, + { content: '

B

', correct: false }, + ], +}; +const pairs = { items: [{ source: '

Dog

', target: '

Mammal

' }], distractors: [] }; +const passage = { + html: '

The is blue.

', + blanks: [{ answer: 'sky', options: [] }], + distractors: [], + contentLevel: ContentLevel.PLAIN, +}; + +describe('carryContent', () => { + it('always carries the question', () => { + expect(carryContent({ prompt: '

Q

' }, {})).toEqual({ + carried: { prompt: '

Q

' }, + losses: [], + }); + }); + + it('carries answers into a type that takes them, with the losses that brings', () => { + const { carried, losses } = carryContent( + { prompt: '', answers }, + { + answers: { correctCriterion: CorrectCriterion.ACCEPTED, contentLevel: ContentLevel.PLAIN }, + }, + ); + expect(carried.answers.items).toEqual([{ content: 'A', correct: true }]); + expect(losses).toEqual([ConversionLoss.INCORRECT_ANSWERS_REMOVED]); + }); + + it.each([ + ['answers', { answers }], + ['pairs', { pairs }], + ])('drops %s a type does not take, saying the answers go', (_, facet) => { + const { carried, losses } = carryContent({ prompt: '

Q

', ...facet }, {}); + expect(carried).toEqual({ prompt: '

Q

' }); + expect(losses).toEqual([ConversionLoss.ANSWERS_REMOVED]); + }); + + it('drops empty answers a type does not take without a loss', () => { + const blank = { ...answers, items: [{ content: '

', correct: false }] }; + expect(carryContent({ prompt: '', answers: blank }, {}).losses).toEqual([]); + }); + + it('drops pairs that hold nothing without a loss', () => { + const blank = { items: [{ source: '', target: '

' }], distractors: [] }; + expect(carryContent({ prompt: '', pairs: blank }, {}).losses).toEqual([]); + }); + + it('carries pairs into a type that takes them, as they are', () => { + expect(carryContent({ prompt: '', pairs }, { pairs: true }).carried.pairs).toBe(pairs); + }); + + it('folds a passage into the question for a type with no passage', () => { + const { carried, losses } = carryContent({ prompt: '

Q

', passage }, {}); + expect(carried).toEqual({ prompt: '

Q

The _____ is blue.

' }); + expect(losses).toEqual([ConversionLoss.PASSAGE_ADDED_TO_QUESTION]); + }); + + it('leaves the question alone when the passage holds nothing', () => { + const empty = { ...passage, html: '

', blanks: [] }; + expect(carryContent({ prompt: '

Q

', passage: empty }, {})).toEqual({ + carried: { prompt: '

Q

' }, + losses: [], + }); + }); + + it('carries a passage into a type that takes one', () => { + const { carried, losses } = carryContent( + { prompt: '', passage }, + { passage: { contentLevel: ContentLevel.INLINE } }, + ); + expect(carried.passage.blanks).toEqual([{ answer: 'sky', options: [] }]); + expect(losses).toEqual([]); + }); + + it('reports each loss once, in the order the author is told about them', () => { + const rich = { + correctCriterion: CorrectCriterion.SELECTED, + contentLevel: ContentLevel.RICH, + items: [ + { content: '

A

', correct: true }, + { content: '

B

', correct: false }, + { content: '

C

', correct: true }, + ], + }; + const { losses } = carryContent( + { prompt: '', answers: rich }, + { + answers: { correctCriterion: CorrectCriterion.ACCEPTED, contentLevel: ContentLevel.PLAIN }, + }, + ); + expect(losses).toEqual([ + ConversionLoss.INCORRECT_ANSWERS_REMOVED, + ConversionLoss.ANY_CORRECT_ANSWER_ACCEPTED, + ConversionLoss.FORMATTING_REMOVED, + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/content.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/content.spec.js new file mode 100644 index 0000000000..36c1b42cb2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/content.spec.js @@ -0,0 +1,105 @@ +import { ContentLevel } from '../../constants'; +import { changeContentLevel, hasContent } from '../content'; + +describe('hasContent', () => { + it('reads plain text by its trimmed text', () => { + expect(hasContent(' ', ContentLevel.PLAIN)).toBe(false); + expect(hasContent(' a ', ContentLevel.PLAIN)).toBe(true); + expect(hasContent(null, ContentLevel.PLAIN)).toBe(false); + }); + + it('counts an image or a formula as content in HTML', () => { + expect(hasContent('

', ContentLevel.RICH)).toBe(false); + expect(hasContent('

', ContentLevel.RICH)).toBe(true); + expect(hasContent('', ContentLevel.INLINE)).toBe(true); + }); +}); + +describe('changeContentLevel', () => { + it('leaves content at the same level as it is', () => { + expect(changeContentLevel('

a

', ContentLevel.RICH, ContentLevel.RICH)).toEqual({ + content: '

a

', + lost: false, + }); + }); + + it('reads rich or inline content as plain text, saying when anything was lost', () => { + expect(changeContentLevel('

Paris

', ContentLevel.RICH, ContentLevel.PLAIN)).toEqual({ + content: 'Paris', + lost: false, + }); + expect(changeContentLevel('Paris', ContentLevel.INLINE, ContentLevel.PLAIN)).toEqual({ + content: 'Paris', + lost: true, + }); + }); + + const toPlain = content => changeContentLevel(content, ContentLevel.RICH, ContentLevel.PLAIN); + + it.each([ + ['Paris', 'Paris'], + ['

Paris

', 'Paris'], + ['

', ''], + ['', ''], + [null, ''], + // Entities are decoded, not left as their literal characters. + ['

a & b c

', 'a & b c'], + ])('reads %s as the plain text %s, losing nothing', (content, text) => { + expect(toPlain(content)).toEqual({ content: text, lost: false }); + }); + + it.each([ + ['

Paris

', 'Paris'], + ['

H2O

', 'H2O'], + ['

first

second

', 'first second'], + ['

first
second

', 'first second'], + ['', 'one two'], + ])( + 'reads %s as the plain text %s, saying what plain text cannot hold is lost', + (content, text) => { + expect(toPlain(content)).toEqual({ content: text, lost: true }); + }, + ); + + it.each([ + ['an image', '

'], + ['a formula', '

'], + ])('reads %s on its own as no plain text, and lost', (_, content) => { + expect(toPlain(content)).toEqual({ content: '', lost: true }); + }); + + it('counts an empty paragraph after the text as no second line', () => { + expect(toPlain('

Paris

')).toEqual({ content: 'Paris', lost: false }); + }); + + it('puts plain text in a paragraph for rich content, escaped', () => { + expect(changeContentLevel('a < b', ContentLevel.PLAIN, ContentLevel.RICH)).toEqual({ + content: '

a < b

', + lost: false, + }); + }); + + it('escapes plain text for inline content, with no paragraph around it', () => { + expect(changeContentLevel('a < b', ContentLevel.PLAIN, ContentLevel.INLINE)).toEqual({ + content: 'a < b', + lost: false, + }); + // Markup in plain text is text, so it reads back as the same text. + expect(changeContentLevel('a < b & c', ContentLevel.PLAIN, ContentLevel.INLINE)).toEqual( + { content: 'a < b & <b>c</b>', lost: false }, + ); + }); + + it('gives empty plain text no paragraph', () => { + expect(changeContentLevel(' ', ContentLevel.PLAIN, ContentLevel.RICH)).toEqual({ + content: '', + lost: false, + }); + }); + + it('refuses levels no question type family converts between', () => { + expect(() => changeContentLevel('

a

', ContentLevel.RICH, ContentLevel.INLINE)).toThrow( + /No conversion/, + ); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/passage.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/passage.spec.js new file mode 100644 index 0000000000..70117f73a9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/passage.spec.js @@ -0,0 +1,93 @@ +import { ContentLevel, ConversionLoss } from '../../constants'; +import { BLANK_TEXT, carryPassage, foldPassage, passageHasContent } from '../passage'; + +const blank = i => ``; +const passage = (overrides = {}) => ({ + html: `

The ${blank(0)} is ${blank(1)}.

`, + blanks: [ + { answer: 'sky', options: ['sea'] }, + { answer: 'blue', options: [] }, + ], + distractors: [], + contentLevel: ContentLevel.PLAIN, + ...overrides, +}); + +describe('foldPassage', () => { + it('adds the passage after the question, each blank shown as underscores', () => { + expect(foldPassage('

Fill in:

', passage().html)).toBe( + `

Fill in:

The ${BLANK_TEXT} is ${BLANK_TEXT}.

`, + ); + }); + + it('replaces blanks inside formatting and lists', () => { + const html = ``; + expect(foldPassage('', html)).toBe( + ``, + ); + }); + + it('adds a passage with no blanks as it is', () => { + expect(foldPassage('

Q

', '

Just text

')).toBe('

Q

Just text

'); + }); +}); + +describe('passageHasContent', () => { + it('is false for an empty passage with no blanks', () => { + expect(passageHasContent(passage({ html: '

', blanks: [] }))).toBe(false); + }); + + it('counts the passage text', () => { + expect(passageHasContent(passage({ html: '

Text

', blanks: [] }))).toBe(true); + }); + + it('counts a blank holding an answer, even with no text around it', () => { + const onlyBlank = passage({ + html: `

${blank(0)}

`, + blanks: [{ answer: 'x', options: [] }], + }); + expect(passageHasContent(onlyBlank)).toBe(true); + }); + + it('counts distractors', () => { + const onlyDistractor = passage({ html: '', blanks: [], distractors: ['spare'] }); + expect(passageHasContent(onlyDistractor)).toBe(true); + }); +}); + +describe('carryPassage', () => { + it('leaves a passage at the same level as it is', () => { + const result = carryPassage(passage(), { contentLevel: ContentLevel.PLAIN }); + expect(result.passage).toEqual(passage()); + expect(result.losses).toEqual([]); + }); + + it('escapes plain answers for a passage type that holds inline content', () => { + const plain = passage({ blanks: [{ answer: 'a < b', options: [] }], distractors: ['c & d'] }); + const result = carryPassage(plain, { contentLevel: ContentLevel.INLINE }); + expect(result.passage.blanks).toEqual([{ answer: 'a < b', options: [] }]); + expect(result.passage.distractors).toEqual(['c & d']); + expect(result.passage.contentLevel).toBe(ContentLevel.INLINE); + expect(result.losses).toEqual([]); + }); + + it('flattens inline answers for a plain passage type, saying formatting goes', () => { + const inline = passage({ + contentLevel: ContentLevel.INLINE, + blanks: [{ answer: 'sky', options: [] }], + distractors: ['', 'sea'], + }); + const result = carryPassage(inline, { contentLevel: ContentLevel.PLAIN }); + expect(result.passage.blanks).toEqual([{ answer: 'sky', options: [] }]); + // A formula on its own has nothing left to show. + expect(result.passage.distractors).toEqual(['sea']); + expect(result.losses).toEqual([ConversionLoss.FORMATTING_REMOVED]); + }); + + it('keeps a blank with no answer as one with no answer', () => { + const unanswered = passage({ blanks: [{ answer: null, options: ['a'] }] }); + expect(carryPassage(unanswered, { contentLevel: ContentLevel.INLINE }).passage.blanks).toEqual([ + { answer: null, options: ['a'] }, + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/answers.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/answers.js new file mode 100644 index 0000000000..d4510b1409 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/answers.js @@ -0,0 +1,144 @@ +import flatMap from 'lodash/flatMap'; +import { CorrectCriterion, ConversionLoss } from '../constants'; +import { changeContentLevel, hasContent } from './content'; + +/** + * @typedef {object} AnswerList - The `answers` facet: a question's answers as a list + * @property {string} correctCriterion - CorrectCriterion: what makes an answer correct + * @property {string} contentLevel - ContentLevel: how much markup the answers hold + * @property {Array<{ content: string, correct: boolean, caseSensitive?: boolean, + * fixed?: boolean }>} items - In authored order. `fixed` keeps an answer in place when the + * answers are shuffled. + * @property {boolean} [shuffle] - Only from a type whose author can change it + * @property {string} [orientation] - How the answers are laid out, for a type that has it + * @property {number} [expectedLength] - The size of the answer field, for a type that has it + */ + +/** + * @typedef {object} AnswerFormat - The answers a question type takes + * @property {string} correctCriterion - CorrectCriterion + * @property {string} contentLevel - ContentLevel: how much markup the type's answers can hold + * @property {boolean} [single] - At most one answer can be marked correct + */ + +const markAll = correct => items => items.map(item => ({ ...item, correct })); +const keep = items => items; + +/** + * How each correct criterion becomes each other one: `RULES[from][to]` takes the items, + * the ones among them with content, and the list to report losses in. + * + * Nothing is ever marked correct that the author didn't mark, except where every answer + * already was (accepted answers). What validation can't catch is reported: a wrong answer + * that would become an accepted one is removed, and correct answers that become enough on + * their own, or an order that becomes what is correct or stops being it, are pointed out. + */ +const RULES = { + [CorrectCriterion.SELECTED]: { + [CorrectCriterion.SELECTED]: keep, + [CorrectCriterion.ACCEPTED]: (items, filled, losses) => { + if (filled.some(item => !item.correct)) losses.push(ConversionLoss.INCORRECT_ANSWERS_REMOVED); + // Every correct choice had to be selected; any one accepted answer is enough. + if (filled.filter(item => item.correct).length > 1) { + losses.push(ConversionLoss.ANY_CORRECT_ANSWER_ACCEPTED); + } + return items.filter(item => item.correct); + }, + [CorrectCriterion.ORDERED]: (items, filled, losses) => { + if (filled.some(item => item.correct)) losses.push(ConversionLoss.CORRECT_ANSWERS_NOT_KEPT); + if (filled.length > 1) losses.push(ConversionLoss.ORDER_BECOMES_ANSWER); + return markAll(false)(items); + }, + }, + [CorrectCriterion.ACCEPTED]: { + // Every accepted answer was correct, so every choice is. Single choice can only keep one + // marked; carryAnswers applies that after these rules (keepOneCorrect), which is where + // the loss for the other correct answers is reported. + [CorrectCriterion.SELECTED]: markAll(true), + [CorrectCriterion.ACCEPTED]: keep, + [CorrectCriterion.ORDERED]: (items, filled, losses) => { + if (filled.length > 1) losses.push(ConversionLoss.ORDER_BECOMES_ANSWER); + return markAll(false)(items); + }, + }, + [CorrectCriterion.ORDERED]: { + [CorrectCriterion.SELECTED]: (items, filled, losses) => { + if (filled.length > 1) losses.push(ConversionLoss.ORDER_NOT_KEPT); + return markAll(false)(items); + }, + // No item is an accepted answer, and making them all accepted would invent correct answers. + [CorrectCriterion.ACCEPTED]: (items, filled, losses) => { + if (filled.length) losses.push(ConversionLoss.ANSWERS_REMOVED); + return []; + }, + [CorrectCriterion.ORDERED]: keep, + }, +}; + +/** + * Single choice shows its correct answer as a radio button, which can't show several. The + * first correct answer with content keeps its mark; the others stay as answers, unmarked. + */ +function keepOneCorrect(items, counts, losses) { + const correct = items.filter(item => item.correct); + if (correct.filter(counts).length > 1) losses.push(ConversionLoss.CORRECT_ANSWERS_REDUCED); + const kept = correct.find(counts) ?? correct[0]; + return items.map(item => ({ ...item, correct: item === kept })); +} + +function changeContentLevels(items, from, to, losses) { + let lost = false; + const moved = flatMap(items, item => { + const { content, lost: itemLost } = changeContentLevel(item.content, from, to); + lost = lost || itemLost; + // An answer that was only an image or a formula has nothing left to show. + const emptied = hasContent(item.content, from) && !hasContent(content, to); + return emptied ? [] : [{ ...item, content }]; + }); + if (lost) losses.push(ConversionLoss.FORMATTING_REMOVED); + return moved; +} + +/** + * Carry a question's answers into the format another question type takes. + * + * @param {AnswerList} answers + * @param {AnswerFormat} format + * @returns {{ answers: AnswerList, losses: string[] }} `losses` are ConversionLoss codes + */ +export function carryAnswers(answers, format) { + const losses = []; + const counts = item => hasContent(item.content, answers.contentLevel); + const filled = answers.items.filter(counts); + + let items = RULES[answers.correctCriterion][format.correctCriterion]( + answers.items, + filled, + losses, + ); + if (format.single) { + items = keepOneCorrect(items, counts, losses); + } + items = changeContentLevels(items, answers.contentLevel, format.contentLevel, losses); + + return { + // The list's settings travel as they are; each type takes the ones it has. + answers: { + ...answers, + correctCriterion: format.correctCriterion, + contentLevel: format.contentLevel, + items, + }, + losses, + }; +} + +/** + * Whether any answer says anything, so removing them all is a loss. + * + * @param {AnswerList} answers + * @returns {boolean} + */ +export function answersHaveContent(answers) { + return answers.items.some(item => hasContent(item.content, answers.contentLevel)); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/content.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/content.js new file mode 100644 index 0000000000..ca5739959c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/content.js @@ -0,0 +1,119 @@ +import { ContentLevel } from '../constants'; +import { getContentHTML, parseHTMLBody } from '../serialization/xml'; +import { hasRichTextContent } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; + +/** + * Elements that end a line of text. Their content is kept apart from the next block's when + * the fragment is read as one line, so `

a

b

` reads `a b`, not `ab`. + */ +const LINE_BREAKING = new Set([ + 'p', + 'div', + 'br', + 'li', + 'ul', + 'ol', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'pre', + 'table', + 'tr', + 'td', + 'th', +]); + +function lineText(node) { + if (node.nodeType === Node.TEXT_NODE) { + return node.nodeValue; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + const text = [...node.childNodes].map(lineText).join(''); + return LINE_BREAKING.has(node.localName) ? ` ${text} ` : text; +} + +/** + * A rich text fragment as one line of plain text, for a field that holds text only, such + * as a text entry answer or an inline choice option. + * + * Anything plain text cannot hold is dropped: formatting, images, formulas, and the breaks + * between paragraphs. `lost` says whether there was any, so the author can be told before + * it goes. A single paragraph of text loses nothing, since a plain text field shows it the + * same way. + * + * @param {string} content - HTML fragment from a TipTap editor + * @returns {{ text: string, lost: boolean }} + */ +function toPlainText(content) { + const body = parseHTMLBody(content); + body.querySelectorAll('script, style').forEach(el => el.remove()); + const elements = [...body.querySelectorAll('*')]; + // Loose text beside the paragraphs is a line of its own; empty paragraphs are none. + const hasLooseText = [...body.childNodes].some( + node => node.nodeType === Node.TEXT_NODE && node.nodeValue.trim(), + ); + const lines = elements.filter(el => el.textContent.trim()).length + (hasLooseText ? 1 : 0); + const lost = elements.some(el => el.localName !== 'p') || lines > 1; + const text = lineText(body).replace(/\s+/g, ' ').trim(); + return { text, lost }; +} + +/** + * Plain text as HTML, escaped, with no element around it. + * + * @param {string} text + * @returns {string} + */ +function escapeText(text) { + const body = parseHTMLBody(''); + body.textContent = text ?? ''; + return getContentHTML(body); +} + +/** + * Whether a piece of content says anything, read at its contentLevel: plain text by its trimmed + * text, HTML the way `hasRichTextContent` reads it, so an image or a formula counts. + * + * @param {string|null|undefined} content + * @param {string} contentLevel - The ContentLevel it is at + * @returns {boolean} + */ +export function hasContent(content, contentLevel) { + if (contentLevel === ContentLevel.PLAIN) { + return Boolean(content?.trim()); + } + return hasRichTextContent(content); +} + +/** + * Move a piece of content to the level a field can hold. Going down loses whatever the + * lower level cannot hold, and `lost` says whether anything was. Going up loses nothing: + * plain text becomes escaped HTML, in a paragraph when the field holds blocks. + * + * @param {string} content + * @param {string} from - The ContentLevel the content is at + * @param {string} to - The ContentLevel of the field it moves to + * @returns {{ content: string, lost: boolean }} + */ +export function changeContentLevel(content, from, to) { + if (from === to) { + return { content, lost: false }; + } + if (to === ContentLevel.PLAIN) { + const { text, lost } = toPlainText(content); + return { content: text, lost }; + } + if (from === ContentLevel.PLAIN) { + const html = escapeText(content.trim()); + const wrapped = to === ContentLevel.RICH && html ? `

${html}

` : html; + return { content: wrapped, lost: false }; + } + // Rich and inline content never meet: no question type family holds both. + throw new Error(`No conversion from ${from} to ${to} content`); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/index.js new file mode 100644 index 0000000000..755c48252c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/index.js @@ -0,0 +1,127 @@ +import { ContentLevel, ConversionLoss } from '../constants'; +import { descriptors } from '../interactions/descriptors'; +import { answersHaveContent, carryAnswers } from './answers'; +import { hasContent } from './content'; +import { carryPassage, foldPassage, passageHasContent } from './passage'; + +/** + * Changing a question's type: carrying what the question holds into the type the author + * picked, and naming what that would remove or change, so the author can confirm first. + * + * Each descriptor exports its state into neutral *facets* and imports from them; nothing + * here knows any interaction's state. A facet only moves between types that take it, so + * content only moves within a family of similar types: answer lists, pairs, or passages. + * Everything a type doesn't take is a loss, so a new interaction that implements none of + * this still keeps the question and warns before anything else goes. + */ + +/** + * Distractors (wrong options) travel inside the facet of the answers they go with: + * `pairs.distractors` and `passage.distractors`. So they only carry over to a type that + * takes those answers, and are removed with them otherwise. An associate distractor never + * ends up in a gap match pool, for example. Answer lists have no separate distractors: a + * wrong option is an item not marked correct. + * + * @typedef {object} ConvertibleContent + * @property {string} prompt - The question's HTML + * @property {import('./answers').AnswerList} [answers] + * @property {{ items: Array<{ source: string, target: string }>, distractors: string[] }} [pairs] + * Rich HTML, each pair from its source to its target + * @property {import('./passage').Passage} [passage] + */ + +/** + * @typedef {object} ConversionFormat - The facets a question type takes + * @property {import('./answers').AnswerFormat} [answers] + * @property {boolean} [pairs] + * @property {{ contentLevel: string }} [passage] - `contentLevel` is the ContentLevel: how + * much markup the type's blank answers, options and distractors can hold + */ + +// Declaration order, which ConversionLoss documents as the order the author reads them in. +const LOSS_ORDER = Object.values(ConversionLoss); + +function pairsHaveContent({ items, distractors }) { + const has = content => hasContent(content, ContentLevel.RICH); + return items.some(({ source, target }) => has(source) || has(target)) || distractors.some(has); +} + +/** + * Carry content into the facets a question type takes. + * + * @param {ConvertibleContent} content + * @param {ConversionFormat} format + * @returns {{ carried: ConvertibleContent, losses: string[] }} `losses` are ConversionLoss + * codes, each once, in the order the author is told about them + */ +export function carryContent(content, format) { + const losses = new Set(); + const carried = { prompt: content.prompt ?? '' }; + + if (content.answers) { + if (format.answers) { + const result = carryAnswers(content.answers, format.answers); + carried.answers = result.answers; + result.losses.forEach(loss => losses.add(loss)); + } else if (answersHaveContent(content.answers)) { + losses.add(ConversionLoss.ANSWERS_REMOVED); + } + } + + if (content.pairs) { + if (format.pairs) { + carried.pairs = content.pairs; + } else if (pairsHaveContent(content.pairs)) { + losses.add(ConversionLoss.ANSWERS_REMOVED); + } + } + + if (content.passage) { + if (format.passage) { + const result = carryPassage(content.passage, format.passage); + carried.passage = result.passage; + result.losses.forEach(loss => losses.add(loss)); + } else if (passageHasContent(content.passage)) { + // The passage is often the whole question, so it stays, as part of the question. + carried.prompt = foldPassage(carried.prompt, content.passage.html); + losses.add(ConversionLoss.PASSAGE_ADDED_TO_QUESTION); + } + } + + return { carried, losses: LOSS_ORDER.filter(loss => losses.has(loss)) }; +} + +function descriptorFor(questionType) { + const descriptor = descriptors.find(d => d.questionTypes.includes(questionType)); + if (!descriptor) { + throw new Error(`No interaction authors question type ${questionType}`); + } + return descriptor; +} + +/** + * Convert an interaction to another question type. + * + * Pure: nothing changes until the caller uses the result, so it can be run to find the + * losses before asking the author, and again once they confirm. + * + * @param {object} options + * @param {{ bodyXml: string, responseDeclarations: string[] }} options.interaction + * @param {string} options.fromType - The QuestionType the interaction is now + * @param {string} options.toType - The QuestionType to convert it to + * @returns {{ + * interaction: { bodyXml: string, responseDeclarations: string[] }, + * losses: string[], + * }} `losses` are ConversionLoss codes, empty when nothing would be lost + */ +export function convertInteraction({ interaction, fromType, toType }) { + const from = descriptorFor(fromType); + const to = descriptorFor(toType); + const state = from.parse(interaction.bodyXml, interaction.responseDeclarations); + const content = from.exportContent(state, fromType); + const { carried, losses } = carryContent(content, to.getConversionFormat(toType)); + return { + interaction: to.buildXML(to.importContent(carried, toType), toType), + losses, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/passage.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/passage.js new file mode 100644 index 0000000000..56ac3b5e38 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/passage.js @@ -0,0 +1,80 @@ +import { ConversionLoss } from '../constants'; +import { getContentHTML, parseHTMLBody } from '../serialization/xml'; +import { changeContentLevel, hasContent } from './content'; +import { hasRichTextContent } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; + +/** + * @typedef {object} Passage - The `passage` facet: rich text with answer blanks in it + * @property {string} html - The passage, with a `` where blank + * `i` sits. A neutral placeholder, so no passage type needs to know another's node markup. + * @property {Array<{ answer: string|null, options: string[] }>} blanks - Each blank's + * correct answer, null when it has none, and its own wrong options + * @property {string[]} distractors - Wrong answers shared by every blank + * @property {string} contentLevel - ContentLevel: how much markup the answers, options and + * distractors hold + * @property {boolean} [shuffle] - Only from a type whose author can change it + */ + +export const BLANK_ATTR = 'data-studio-blank'; + +/** How a blank reads once its passage is part of the question. Not a word, so not translated. */ +export const BLANK_TEXT = '_____'; + +/** + * Whether the passage says anything: its own text or media, or anything in its blanks. + * + * @param {Passage} passage + * @returns {boolean} + */ +export function passageHasContent(passage) { + const has = content => hasContent(content, passage.contentLevel); + return ( + hasRichTextContent(passage.html) || + passage.blanks.some(blank => has(blank.answer) || blank.options.some(has)) || + passage.distractors.some(has) + ); +} + +/** + * The question with the passage added after it, each blank shown as BLANK_TEXT, for a + * question type with no passage. The blanks' answers go: there is nowhere to put them. + * + * @param {string} prompt - The question's HTML + * @param {string} html - Passage.html + * @returns {string} + */ +export function foldPassage(prompt, html) { + const body = parseHTMLBody(html); + for (const blank of body.querySelectorAll(`[${BLANK_ATTR}]`)) { + blank.replaceWith(BLANK_TEXT); + } + return `${prompt ?? ''}${getContentHTML(body)}`; +} + +/** + * Carry a passage to the level another passage type holds its answers at. + * + * @param {Passage} passage + * @param {{ contentLevel: string }} format + * @returns {{ passage: Passage, losses: string[] }} `losses` are ConversionLoss codes + */ +export function carryPassage(passage, format) { + let lost = false; + const move = content => { + const moved = changeContentLevel(content, passage.contentLevel, format.contentLevel); + lost = lost || moved.lost; + return hasContent(moved.content, format.contentLevel) ? moved.content : null; + }; + const present = content => content !== null; + + const blanks = passage.blanks.map(({ answer, options }) => ({ + answer: answer === null ? null : move(answer), + options: options.map(move).filter(present), + })); + const distractors = passage.distractors.map(move).filter(present); + + return { + passage: { ...passage, contentLevel: format.contentLevel, blanks, distractors }, + losses: lost ? [ConversionLoss.FORMATTING_REMOVED] : [], + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js index c8871df38b..c41e5ef621 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js @@ -94,4 +94,39 @@ export class InteractionDescriptor { getTypeOptions() { return []; } + + /** + * The facets a question type takes when a question is converted to it (see + * conversion/). Subclasses are passed the question type. The default takes + * none, so only the question carries over. + * + * @returns {import('../conversion').ConversionFormat} + */ + getConversionFormat() { + return {}; + } + + /** + * The state as facets, for converting the question to another type. Subclasses are also + * passed the question type. Everything the state holds beyond the question must be + * exported: what isn't would be lost without a warning. + * + * @param {object} state + * @returns {import('../conversion').ConvertibleContent} + */ + exportContent(state) { + return { prompt: state.prompt ?? '' }; + } + + /** + * A state holding the facets this type takes (getConversionFormat), already carried into + * its format. Subclasses are also passed the question type. The default is a new + * question with the question carried over. + * + * @param {import('../conversion').ConvertibleContent} content + * @returns {object} + */ + importContent(content) { + return { ...this.parse('', []), prompt: content.prompt }; + } } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js index 0144f108f8..e268770b88 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/InteractionDescriptor.spec.js @@ -98,5 +98,28 @@ describe('InteractionDescriptor', () => { it('contributes no question type options', () => { expect(new (makeDescriptorClass())().getTypeOptions()).toEqual([]); }); + + describe('when the question type changes', () => { + it('takes nothing but the question', () => { + expect(new (makeDescriptorClass())().getConversionFormat()).toEqual({}); + }); + + it('exports the question', () => { + const descriptor = new (makeDescriptorClass())(); + expect(descriptor.exportContent({ prompt: '

Q

', choices: [] })).toEqual({ + prompt: '

Q

', + }); + expect(descriptor.exportContent({})).toEqual({ prompt: '' }); + }); + + it('imports a new question holding the question', () => { + const Descriptor = makeDescriptorClass(); + Descriptor.prototype.parse = () => ({ prompt: '', choices: ['blank'] }); + expect(new Descriptor().importContent({ prompt: '

Q

' })).toEqual({ + prompt: '

Q

', + choices: ['blank'], + }); + }); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js index 2b4d103d84..843fee1c4c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js @@ -12,7 +12,6 @@ export class AssociateInteractionDescriptor extends InteractionDescriptor { type: QtiInteraction.ASSOCIATE, questionTypes: [QuestionType.ASSOCIATE], }); - this.convertsFrom = []; } getTypeOptions(tr) { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js index 354d0bbc5a..ec208b36b0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js @@ -13,7 +13,6 @@ export class ChoiceInteractionDescriptor extends InteractionDescriptor { type: QtiInteraction.CHOICE, questionTypes: [QuestionType.SINGLE_SELECT, QuestionType.MULTI_SELECT], }); - this.convertsFrom = []; } getTypeOptions(tr) { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js index 2318b037f0..eaa9320882 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js @@ -12,7 +12,6 @@ export class MatchInteractionDescriptor extends InteractionDescriptor { type: QtiInteraction.MATCH, questionTypes: [QuestionType.MATCH], }); - this.convertsFrom = []; } getTypeOptions(tr) { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js index 58bce294de..04432cc88b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js @@ -12,7 +12,6 @@ export class OrderingInteractionDescriptor extends InteractionDescriptor { type: QtiInteraction.ORDER, questionTypes: [QuestionType.ORDERING], }); - this.convertsFrom = []; } getTypeOptions(tr) { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js index 1529e211e8..0728a930e8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js @@ -21,7 +21,6 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor { questionTypes: [QuestionType.NUMERIC, QuestionType.TEXT_ENTRY, QuestionType.FREE_RESPONSE], placement: Placement.INLINE, }); - this.convertsFrom = []; } getTypeOptions(tr) { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js index 61a64cca33..a5e0676571 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js @@ -66,6 +66,18 @@ export function getContentHTML(el) { return serializeAsHtml([...el.childNodes], el.namespaceURI); } +/** + * An HTML fragment, as a rich text editor writes it, parsed into the `` of a new + * document. As HTML, not XML: the fragment can hold `
`, ` ` and other markup an XML + * parser rejects. Read it back with getContentHTML. + * + * @param {string} html + * @returns {HTMLElement} + */ +export function parseHTMLBody(html) { + return parseXML(`${html ?? ''}`, 'text/html').body; +} + const xmlDoc = parser.parseFromString('', 'text/xml'); const XHTML_NS = 'http://www.w3.org/1999/xhtml'; const DROPPED_ELEMENTS = new Set(['noscript', 'script', 'style']); From 6dec86d23df538817ebda0474ceb57a7edc9ecf7 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Fri, 9 Oct 2026 19:04:19 -0500 Subject: [PATCH 2/6] Implement conversion modules for descriptors Each interaction now exports its state into the conversion facets and imports from them, in its own convert.js, so a question keeps what the new type can hold. The tests convert a filled question of each type to every other type, against one table of the losses each change brings. A convert module makes a new choice, item, answer, row or distractor through its interaction's parse module, which now has the one factory for it; the parse modules and the composables that add one use them too, so each is built the same way wherever it is made. Gap match and inline choice parse modules also write the gaps and dropdowns they read, so how a passage holds them stays their own. Answer lists (single and multi select, numeric, text entry, free response, ordering) differ in what makes an answer correct: - only the correct choices become accepted numeric or text entry answers, since a wrong one would become an accepted one, and when several were correct the author is told any one is now enough - a question becoming single choice keeps only its first correct answer marked, since radio buttons can't show more than one - answers ordering can't mark correct, and an order that becomes the correct answer, are reported so the author can confirm first Answers that aren't numbers stay when switching to numeric, for validation to flag. Shuffle travels between single and multi select, fixed answers and orientation between choice and ordering, and the expected length between the text entry types. Ordering's shuffle isn't the author's to set, and stays on. Pairs (associate and match) carry their pairs and distractors. A match row with several matches becomes one associate pair per match, and associate pairs from the same source become one match row, so a round trip gives back the row it started from. Passages (inline choice and gap match) travel as neutral blanks, so neither needs the other's markup. To a type with no passage, the passage is folded into the question, each blank shown as _____, since it is often the whole question. Between the two, answers move with their blanks: each dropdown's wrong options join gap match's shared pool without a warning, since the pool is the equivalent, and each dropdown made from gap match offers the whole pool, shuffled. Options the conversion repeats are dropped rather than left for validation to flag, and formatting a gap's answer can hold but an option can't is reported. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../composables/useChoiceInteraction.js | 7 +- .../composables/useOrderingInteraction.js | 4 +- .../composables/useTextEntryInteraction.js | 11 +- .../__tests__/convertInteraction.spec.js | 453 ++++++++++++++++++ .../conversion/__tests__/questions.js | 132 +++++ .../interactions/associate/Descriptor.js | 28 ++ .../associate/__tests__/convert.spec.js | 41 ++ .../interactions/associate/convert.js | 42 ++ .../interactions/choice/Descriptor.js | 25 + .../choice/__tests__/convert.spec.js | 65 +++ .../QTIEditor/interactions/choice/convert.js | 57 +++ .../QTIEditor/interactions/choice/parse.js | 15 +- .../interactions/gapMatch/Descriptor.js | 28 ++ .../gapMatch/__tests__/convert.spec.js | 80 ++++ .../interactions/gapMatch/convert.js | 87 ++++ .../QTIEditor/interactions/gapMatch/parse.js | 50 +- .../interactions/inlineChoice/Descriptor.js | 28 ++ .../inlineChoice/__tests__/convert.spec.js | 120 +++++ .../interactions/inlineChoice/convert.js | 94 ++++ .../interactions/inlineChoice/parse.js | 41 ++ .../interactions/match/Descriptor.js | 24 + .../match/__tests__/convert.spec.js | 94 ++++ .../QTIEditor/interactions/match/convert.js | 77 +++ .../QTIEditor/interactions/match/parse.js | 6 +- .../interactions/ordering/Descriptor.js | 28 ++ .../ordering/__tests__/convert.spec.js | 55 +++ .../interactions/ordering/convert.js | 53 ++ .../QTIEditor/interactions/ordering/parse.js | 14 +- .../interactions/textEntry/Descriptor.js | 31 ++ .../textEntry/__tests__/convert.spec.js | 79 +++ .../interactions/textEntry/convert.js | 66 +++ .../QTIEditor/interactions/textEntry/parse.js | 28 +- 32 files changed, 1925 insertions(+), 38 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/convertInteraction.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/questions.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/convert.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/convert.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/convert.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/convert.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/__tests__/convert.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/convert.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/convert.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/convert.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/convert.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/convert.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/convert.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/convert.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/convert.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/convert.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js index 3106bba39c..0be2bbcada 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js @@ -1,7 +1,7 @@ import { computed, readonly } from 'vue'; import { QuestionType } from '../constants'; -import { generateRandomSlug } from '../utils/generateRandomSlug'; import { choiceInteractionDescriptor } from '../interactions/choice/Descriptor'; +import { newChoice } from '../interactions/choice/parse'; import { useInteraction } from './useInteraction'; /** @@ -22,10 +22,7 @@ export function useChoiceInteraction(interactionBlock, questionType) { function addChoice() { state.value = { ...state.value, - choices: [ - ...state.value.choices, - { id: generateRandomSlug('choice'), content: '', correct: false }, - ], + choices: [...state.value.choices, newChoice()], }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js index 02341e9f32..0f38848f15 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useOrderingInteraction.js @@ -1,6 +1,6 @@ import { readonly } from 'vue'; -import { generateRandomSlug } from '../utils/generateRandomSlug'; import { orderingInteractionDescriptor } from '../interactions/ordering/Descriptor'; +import { newItem } from '../interactions/ordering/parse'; import { useInteraction } from './useInteraction'; /** @@ -16,7 +16,7 @@ export function useOrderingInteraction(interactionBlock, questionType) { function addItem() { state.value = { ...state.value, - items: [...state.value.items, { id: generateRandomSlug('order'), content: '', fixed: false }], + items: [...state.value.items, newItem({ fixed: false })], }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js index 6e1252231c..7b0f731d38 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js @@ -1,8 +1,8 @@ import { reactive, readonly, watch } from 'vue'; import { QuestionType } from '../constants'; import { formatLocaleNumber, readLocaleNumber } from '../utils/localeNumbers'; -import { generateRandomSlug } from '../utils/generateRandomSlug'; import { textEntryInteractionDescriptor } from '../interactions/textEntry/Descriptor'; +import { newAnswer } from '../interactions/textEntry/parse'; import { useInteraction } from './useInteraction'; /** @@ -79,12 +79,9 @@ export function useTextEntryInteraction(interactionBlock, questionType, { langua // Answer list mutations function addAnswer() { - const newId = generateRandomSlug('answer'); - state.value = { - ...state.value, - answers: [...state.value.answers, { id: newId, value: '', caseSensitive: false }], - }; - return newId; + const answer = newAnswer(); + state.value = { ...state.value, answers: [...state.value.answers, answer] }; + return answer.id; } /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/convertInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/convertInteraction.spec.js new file mode 100644 index 0000000000..b7048e1a93 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/convertInteraction.spec.js @@ -0,0 +1,453 @@ +import flatMap from 'lodash/flatMap'; +import { ConversionLoss, QuestionType } from '../../constants'; +import { resolveDescriptor } from '../../interactions/resolveDescriptor'; +import { convertInteraction } from '../index'; +import { getDropdowns } from '../../interactions/inlineChoice/parse'; +import { getGaps } from '../../interactions/gapMatch/parse'; +import { QUESTION_TYPES, blankInteraction, descriptorFor, filledInteraction } from './questions'; + +/** + * What converting each filled question (./questions.js) to each other type loses: the + * rules every type change follows, in one table. A pair missing from it fails the test. + */ +const EXPECTED_LOSSES = { + [QuestionType.SINGLE_SELECT]: { + [QuestionType.MULTI_SELECT]: [], + [QuestionType.NUMERIC]: [ConversionLoss.INCORRECT_ANSWERS_REMOVED], + [QuestionType.TEXT_ENTRY]: [ConversionLoss.INCORRECT_ANSWERS_REMOVED], + [QuestionType.FREE_RESPONSE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.ORDERING]: [ + ConversionLoss.CORRECT_ANSWERS_NOT_KEPT, + ConversionLoss.ORDER_BECOMES_ANSWER, + ], + [QuestionType.ASSOCIATE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.MATCH]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.INLINE_CHOICE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.GAP_MATCH]: [ConversionLoss.ANSWERS_REMOVED], + }, + [QuestionType.MULTI_SELECT]: { + [QuestionType.SINGLE_SELECT]: [ConversionLoss.CORRECT_ANSWERS_REDUCED], + [QuestionType.NUMERIC]: [ + ConversionLoss.INCORRECT_ANSWERS_REMOVED, + ConversionLoss.ANY_CORRECT_ANSWER_ACCEPTED, + ], + [QuestionType.TEXT_ENTRY]: [ + ConversionLoss.INCORRECT_ANSWERS_REMOVED, + ConversionLoss.ANY_CORRECT_ANSWER_ACCEPTED, + ], + [QuestionType.FREE_RESPONSE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.ORDERING]: [ + ConversionLoss.CORRECT_ANSWERS_NOT_KEPT, + ConversionLoss.ORDER_BECOMES_ANSWER, + ], + [QuestionType.ASSOCIATE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.MATCH]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.INLINE_CHOICE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.GAP_MATCH]: [ConversionLoss.ANSWERS_REMOVED], + }, + [QuestionType.NUMERIC]: { + [QuestionType.SINGLE_SELECT]: [ConversionLoss.CORRECT_ANSWERS_REDUCED], + [QuestionType.MULTI_SELECT]: [], + [QuestionType.TEXT_ENTRY]: [], + [QuestionType.FREE_RESPONSE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.ORDERING]: [ConversionLoss.ORDER_BECOMES_ANSWER], + [QuestionType.ASSOCIATE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.MATCH]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.INLINE_CHOICE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.GAP_MATCH]: [ConversionLoss.ANSWERS_REMOVED], + }, + [QuestionType.TEXT_ENTRY]: { + [QuestionType.SINGLE_SELECT]: [ConversionLoss.CORRECT_ANSWERS_REDUCED], + [QuestionType.MULTI_SELECT]: [], + // Answers that aren't numbers are kept, for validation to flag. + [QuestionType.NUMERIC]: [], + [QuestionType.FREE_RESPONSE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.ORDERING]: [ConversionLoss.ORDER_BECOMES_ANSWER], + [QuestionType.ASSOCIATE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.MATCH]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.INLINE_CHOICE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.GAP_MATCH]: [ConversionLoss.ANSWERS_REMOVED], + }, + [QuestionType.FREE_RESPONSE]: { + [QuestionType.SINGLE_SELECT]: [], + [QuestionType.MULTI_SELECT]: [], + [QuestionType.NUMERIC]: [], + [QuestionType.TEXT_ENTRY]: [], + [QuestionType.ORDERING]: [], + [QuestionType.ASSOCIATE]: [], + [QuestionType.MATCH]: [], + [QuestionType.INLINE_CHOICE]: [], + [QuestionType.GAP_MATCH]: [], + }, + [QuestionType.ORDERING]: { + [QuestionType.SINGLE_SELECT]: [ConversionLoss.ORDER_NOT_KEPT], + [QuestionType.MULTI_SELECT]: [ConversionLoss.ORDER_NOT_KEPT], + [QuestionType.NUMERIC]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.TEXT_ENTRY]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.FREE_RESPONSE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.ASSOCIATE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.MATCH]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.INLINE_CHOICE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.GAP_MATCH]: [ConversionLoss.ANSWERS_REMOVED], + }, + [QuestionType.ASSOCIATE]: { + [QuestionType.SINGLE_SELECT]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.MULTI_SELECT]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.NUMERIC]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.TEXT_ENTRY]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.FREE_RESPONSE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.ORDERING]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.MATCH]: [], + [QuestionType.INLINE_CHOICE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.GAP_MATCH]: [ConversionLoss.ANSWERS_REMOVED], + }, + [QuestionType.MATCH]: { + [QuestionType.SINGLE_SELECT]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.MULTI_SELECT]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.NUMERIC]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.TEXT_ENTRY]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.FREE_RESPONSE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.ORDERING]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.ASSOCIATE]: [], + [QuestionType.INLINE_CHOICE]: [ConversionLoss.ANSWERS_REMOVED], + [QuestionType.GAP_MATCH]: [ConversionLoss.ANSWERS_REMOVED], + }, + // The passage is folded into the question for every type without one. + [QuestionType.INLINE_CHOICE]: { + [QuestionType.SINGLE_SELECT]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.MULTI_SELECT]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.NUMERIC]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.TEXT_ENTRY]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.FREE_RESPONSE]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.ORDERING]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.ASSOCIATE]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.MATCH]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + // Each dropdown's wrong options join the shared pool, which is what it amounts to. + [QuestionType.GAP_MATCH]: [], + }, + [QuestionType.GAP_MATCH]: { + [QuestionType.SINGLE_SELECT]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.MULTI_SELECT]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.NUMERIC]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.TEXT_ENTRY]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.FREE_RESPONSE]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.ORDERING]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.ASSOCIATE]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + [QuestionType.MATCH]: [ConversionLoss.PASSAGE_ADDED_TO_QUESTION], + // An answer in italics, which a dropdown option can't hold. + [QuestionType.INLINE_CHOICE]: [ConversionLoss.FORMATTING_REMOVED], + }, +}; + +const PAIRS = flatMap(QUESTION_TYPES, fromType => + QUESTION_TYPES.filter(toType => toType !== fromType).map(toType => [fromType, toType]), +); + +function convert(interaction, fromType, toType) { + return convertInteraction({ interaction, fromType, toType }); +} + +/** The state the target type's editor would open the converted question with. */ +function reopen({ bodyXml, responseDeclarations }, toType) { + return descriptorFor(toType).parse(bodyXml, responseDeclarations); +} + +describe('convertInteraction', () => { + describe.each(PAIRS)('from %s to %s', (fromType, toType) => { + it('loses what the rules say, and nothing else', () => { + const { losses } = convert(filledInteraction(fromType), fromType, toType); + expect(losses).toEqual(EXPECTED_LOSSES[fromType][toType]); + }); + + it('writes a question that reopens as the new type, with the question kept', () => { + const { interaction } = convert(filledInteraction(fromType), fromType, toType); + const { questionType, error } = resolveDescriptor( + interaction.bodyXml, + interaction.responseDeclarations, + ); + expect(error).toBeNull(); + expect(questionType).toBe(toType); + const { prompt } = reopen(filledInteraction(fromType), fromType); + // A passage folded into the question comes after it. + expect(reopen(interaction, toType).prompt.startsWith(prompt)).toBe(true); + }); + + it('loses nothing from a question nobody has filled in', () => { + expect(convert(blankInteraction(fromType), fromType, toType).losses).toEqual([]); + }); + }); + + const answersOf = (interaction, toType) => { + const state = reopen(interaction, toType); + return (state.choices ?? state.answers ?? state.items).map( + answer => answer.value ?? answer.content, + ); + }; + + it('keeps the correct choices, and only those, as accepted answers', () => { + const { interaction } = convert( + filledInteraction(QuestionType.MULTI_SELECT), + QuestionType.MULTI_SELECT, + QuestionType.TEXT_ENTRY, + ); + expect(answersOf(interaction, QuestionType.TEXT_ENTRY)).toEqual(['Mars', 'Venus']); + }); + + it('keeps every choice, but only the first correct one marked, for single choice', () => { + const { interaction } = convert( + filledInteraction(QuestionType.MULTI_SELECT), + QuestionType.MULTI_SELECT, + QuestionType.SINGLE_SELECT, + ); + const { choices } = reopen(interaction, QuestionType.SINGLE_SELECT); + expect(choices.map(({ content, correct }) => [content, correct])).toEqual([ + ['

Mars

', true], + ['

Pluto

', false], + ['

Venus

', false], + ]); + }); + + it('keeps answers that are not numbers when switching to numeric', () => { + const { interaction } = convert( + filledInteraction(QuestionType.TEXT_ENTRY), + QuestionType.TEXT_ENTRY, + QuestionType.NUMERIC, + ); + expect(answersOf(interaction, QuestionType.NUMERIC)).toEqual(['Paris', 'City of Light']); + }); + + it('resets case sensitivity after a trip through numeric', () => { + const toNumeric = convert( + filledInteraction(QuestionType.TEXT_ENTRY), + QuestionType.TEXT_ENTRY, + QuestionType.NUMERIC, + ).interaction; + const back = convert(toNumeric, QuestionType.NUMERIC, QuestionType.TEXT_ENTRY).interaction; + expect(reopen(back, QuestionType.TEXT_ENTRY).answers.map(a => a.caseSensitive)).toEqual([ + false, + false, + ]); + }); + + it('keeps the shuffle setting between single and multi select', () => { + const { interaction } = convert( + filledInteraction(QuestionType.SINGLE_SELECT), + QuestionType.SINGLE_SELECT, + QuestionType.MULTI_SELECT, + ); + expect(reopen(interaction, QuestionType.MULTI_SELECT).shuffle).toBe(true); + }); + + it('keeps fixed choices and the layout between single and multi select', () => { + const choices = descriptorFor(QuestionType.MULTI_SELECT); + const interaction = choices.buildXML( + { + prompt: '

Which apply?

', + choices: [ + { id: 'choice_a', content: '

A

', correct: true }, + { id: 'choice_none', content: '

None of the above

', correct: false, fixed: true }, + ], + shuffle: true, + showAnswerCount: true, + orientation: 'horizontal', + }, + QuestionType.MULTI_SELECT, + ); + const converted = convert(interaction, QuestionType.MULTI_SELECT, QuestionType.SINGLE_SELECT); + expect(converted.losses).toEqual([]); + + const state = reopen(converted.interaction, QuestionType.SINGLE_SELECT); + expect(state.choices.map(({ fixed }) => fixed)).toEqual([false, true]); + expect(state.orientation).toBe('horizontal'); + expect(state.shuffle).toBe(true); + }); + + it.each([ + [QuestionType.NUMERIC, QuestionType.TEXT_ENTRY], + [QuestionType.TEXT_ENTRY, QuestionType.NUMERIC], + [QuestionType.FREE_RESPONSE, QuestionType.TEXT_ENTRY], + ])('keeps the expected length from %s to %s', (fromType, toType) => { + const textEntry = descriptorFor(fromType); + const interaction = textEntry.buildXML( + { prompt: '

Q

', answers: [{ id: 'answer_1', value: '42' }], expectedLength: 10 }, + fromType, + ); + const converted = convert(interaction, fromType, toType).interaction; + expect(reopen(converted, toType).expectedLength).toBe(10); + }); + + it('keeps ordering shuffled, whatever the question it came from', () => { + const { interaction } = convert( + filledInteraction(QuestionType.MULTI_SELECT), + QuestionType.MULTI_SELECT, + QuestionType.ORDERING, + ); + expect(reopen(interaction, QuestionType.ORDERING).shuffle).toBe(true); + expect(answersOf(interaction, QuestionType.ORDERING)).toEqual([ + '

Mars

', + '

Pluto

', + '

Venus

', + ]); + }); + + it('turns accepted answers into choices, all marked correct', () => { + const { interaction } = convert( + filledInteraction(QuestionType.TEXT_ENTRY), + QuestionType.TEXT_ENTRY, + QuestionType.MULTI_SELECT, + ); + const { choices } = reopen(interaction, QuestionType.MULTI_SELECT); + expect(choices.map(({ content, correct }) => [content, correct])).toEqual([ + ['

Paris

', true], + ['

City of Light

', true], + ]); + }); + + it('removes the answers a free response would not save', () => { + const { interaction } = convert( + filledInteraction(QuestionType.TEXT_ENTRY), + QuestionType.TEXT_ENTRY, + QuestionType.FREE_RESPONSE, + ); + const back = convert( + interaction, + QuestionType.FREE_RESPONSE, + QuestionType.TEXT_ENTRY, + ).interaction; + expect(answersOf(back, QuestionType.TEXT_ENTRY)).toEqual(['']); + }); + + it('turns each associate pair into a match row, and keeps the distractors', () => { + const { interaction } = convert( + filledInteraction(QuestionType.ASSOCIATE), + QuestionType.ASSOCIATE, + QuestionType.MATCH, + ); + const { rows, distractors } = reopen(interaction, QuestionType.MATCH); + expect(rows.map(({ content, matches }) => [content, matches.map(m => m.content)])).toEqual([ + ['

Antonio

', ['

Prospero

']], + ['

Capulet

', ['

Montague

']], + ]); + expect(distractors.map(({ content }) => content)).toEqual(['

Lysander

']); + }); + + it('turns each match into an associate pair, a row with two matches giving two', () => { + const { interaction } = convert( + filledInteraction(QuestionType.MATCH), + QuestionType.MATCH, + QuestionType.ASSOCIATE, + ); + const { pairs, distractors } = reopen(interaction, QuestionType.ASSOCIATE); + expect(pairs.map(pair => pair.map(({ content }) => content))).toEqual([ + ['

Dog

', '

Mammal

'], + ['

Bat

', '

Mammal

'], + ['

Bat

', '

Flyer

'], + ]); + expect(distractors.map(({ content }) => content)).toEqual(['

Reptile

']); + }); + + it('gives back the match question it started from after a round trip through associate', () => { + const toAssociate = convert( + filledInteraction(QuestionType.MATCH), + QuestionType.MATCH, + QuestionType.ASSOCIATE, + ).interaction; + const back = convert(toAssociate, QuestionType.ASSOCIATE, QuestionType.MATCH); + expect(back.losses).toEqual([]); + const summary = interaction => + reopen(interaction, QuestionType.MATCH).rows.map(({ content, matches }) => [ + content, + matches.map(m => m.content), + ]); + expect(summary(back.interaction)).toEqual(summary(filledInteraction(QuestionType.MATCH))); + expect( + descriptorFor(QuestionType.MATCH).validate(reopen(back.interaction, QuestionType.MATCH)), + ).toEqual( + descriptorFor(QuestionType.MATCH).validate( + reopen(filledInteraction(QuestionType.MATCH), QuestionType.MATCH), + ), + ); + }); + + const dropdownsOf = interaction => + getDropdowns(reopen(interaction, QuestionType.INLINE_CHOICE).passage).map( + ({ options, correctId }) => ({ + options: options.map(({ text }) => text), + correct: options.find(({ id }) => id === correctId)?.text ?? null, + }), + ); + + it('turns each dropdown into a gap holding its answer, its other options the pool', () => { + const { interaction } = convert( + filledInteraction(QuestionType.INLINE_CHOICE), + QuestionType.INLINE_CHOICE, + QuestionType.GAP_MATCH, + ); + const { passage, distractors } = reopen(interaction, QuestionType.GAP_MATCH); + expect(getGaps(passage).map(({ answer }) => answer)).toEqual(['Moon', 'blue']); + expect(distractors.map(({ content }) => content)).toEqual(['Sun', 'green']); + }); + + it('turns each gap into a dropdown offering the whole pool, its answer correct', () => { + const { interaction } = convert( + filledInteraction(QuestionType.GAP_MATCH), + QuestionType.GAP_MATCH, + QuestionType.INLINE_CHOICE, + ); + expect(dropdownsOf(interaction)).toEqual([ + { options: ['winter', 'discontent', 'summer'], correct: 'winter' }, + { options: ['discontent', 'winter', 'summer'], correct: 'discontent' }, + ]); + expect(reopen(interaction, QuestionType.INLINE_CHOICE).shuffle).toBe(true); + }); + + it('gives back the gap match question it started from after a round trip through inline choice', () => { + const plain = descriptorFor(QuestionType.GAP_MATCH).buildXML( + { + prompt: '

Complete the line.

', + passage: + '

Now is the winter of our ' + + 'discontent.

', + distractors: [{ id: 'choice_summer', content: 'summer' }], + }, + QuestionType.GAP_MATCH, + ); + const toInlineChoice = convert( + plain, + QuestionType.GAP_MATCH, + QuestionType.INLINE_CHOICE, + ).interaction; + const back = convert(toInlineChoice, QuestionType.INLINE_CHOICE, QuestionType.GAP_MATCH); + expect(back.losses).toEqual([]); + const summary = interaction => { + const { passage, distractors } = reopen(interaction, QuestionType.GAP_MATCH); + return { + answers: getGaps(passage).map(({ answer }) => answer), + distractors: distractors.map(({ content }) => content), + }; + }; + expect(summary(back.interaction)).toEqual(summary(plain)); + }); + + it('folds the gap match passage into the question, each gap shown as a blank', () => { + const { interaction } = convert( + filledInteraction(QuestionType.GAP_MATCH), + QuestionType.GAP_MATCH, + QuestionType.MULTI_SELECT, + ); + expect(reopen(interaction, QuestionType.MULTI_SELECT).prompt).toBe( + '

Complete the line.

Now is the _____ of our _____.

', + ); + }); + + it('folds the inline choice passage into the question, each dropdown shown as a blank', () => { + const { interaction } = convert( + filledInteraction(QuestionType.INLINE_CHOICE), + QuestionType.INLINE_CHOICE, + QuestionType.SINGLE_SELECT, + ); + expect(reopen(interaction, QuestionType.SINGLE_SELECT).prompt).toBe( + '

Fill in the blanks.

The _____ orbits the Earth, under a _____ sky.

', + ); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/questions.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/questions.js new file mode 100644 index 0000000000..195ac08872 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/conversion/__tests__/questions.js @@ -0,0 +1,132 @@ +import { Orientation, QuestionType } from '../../constants'; +import { descriptors } from '../../interactions/descriptors'; + +/** + * One fully filled question per question type, as its descriptor's state, for the + * conversion tests. Each holds something in every field that can be lost, so every loss a + * conversion can bring shows up. + */ + +const choice = (content, correct = false) => ({ id: `choice_${content}`, content, correct }); +const associable = (id, content) => ({ id, content }); + +/** A dropdown as the inline choice passage holds it, its first option correct. */ +function dropdown(name, options) { + const ids = options.map(text => `choice_${name}_${text}`); + const optionsHTML = options + .map((text, i) => `${text}`) + .join(''); + return ( + `${optionsHTML}` + ); +} + +const FILLED = { + [QuestionType.SINGLE_SELECT]: { + prompt: '

Which planet is closest to the Sun?

', + choices: [choice('

Mercury

', true), choice('

Venus

'), choice('

Earth

')], + showAnswerCount: true, + shuffle: true, + orientation: Orientation.VERTICAL, + }, + [QuestionType.MULTI_SELECT]: { + prompt: '

Which are planets?

', + choices: [choice('

Mars

', true), choice('

Pluto

'), choice('

Venus

', true)], + showAnswerCount: true, + shuffle: false, + orientation: Orientation.VERTICAL, + }, + [QuestionType.NUMERIC]: { + prompt: '

What is six times seven?

', + answers: [ + { id: 'answer_1', value: '42', caseSensitive: false }, + { id: 'answer_2', value: '42.0', caseSensitive: false }, + ], + expectedLength: 50, + }, + [QuestionType.TEXT_ENTRY]: { + prompt: '

What is the capital of France?

', + answers: [ + { id: 'answer_1', value: 'Paris', caseSensitive: true }, + { id: 'answer_2', value: 'City of Light', caseSensitive: false }, + ], + expectedLength: 50, + }, + [QuestionType.FREE_RESPONSE]: { + prompt: '

Describe your favourite planet.

', + answers: [], + expectedLength: 50, + }, + [QuestionType.ORDERING]: { + prompt: '

Order the planets from the Sun.

', + items: [ + { id: 'order_1', content: '

Mercury

' }, + { id: 'order_2', content: '

Venus

' }, + { id: 'order_3', content: '

Earth

' }, + ], + orientation: Orientation.VERTICAL, + shuffle: true, + }, + [QuestionType.ASSOCIATE]: { + prompt: '

Match each character to their adversary.

', + pairs: [ + [associable('choice_a', '

Antonio

'), associable('choice_b', '

Prospero

')], + [associable('choice_c', '

Capulet

'), associable('choice_d', '

Montague

')], + ], + distractors: [associable('choice_e', '

Lysander

')], + }, + [QuestionType.MATCH]: { + prompt: '

Match each animal to what it is.

', + rows: [ + { id: 'row_dog', content: '

Dog

', matches: [associable('choice_m', '

Mammal

')] }, + { + id: 'row_bat', + content: '

Bat

', + matches: [associable('choice_m2', '

Mammal

'), associable('choice_f', '

Flyer

')], + }, + ], + distractors: [associable('choice_r', '

Reptile

')], + }, + [QuestionType.INLINE_CHOICE]: { + prompt: '

Fill in the blanks.

', + passage: `

The ${dropdown('moon', ['Moon', 'Sun'])} orbits the Earth, under a ${dropdown( + 'sky', + ['blue', 'green'], + )} sky.

`, + shuffle: true, + }, + [QuestionType.GAP_MATCH]: { + prompt: '

Complete the line.

', + passage: + '

Now is the winter of our ' + + 'discontent.

', + distractors: [{ id: 'choice_summer', content: 'summer' }], + }, +}; + +export function descriptorFor(questionType) { + return descriptors.find(d => d.questionTypes.includes(questionType)); +} + +/** The question types with a filled question here. */ +export const QUESTION_TYPES = Object.keys(FILLED); + +/** + * @param {string} questionType + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ +export function filledInteraction(questionType) { + return descriptorFor(questionType).buildXML(FILLED[questionType], questionType); +} + +/** + * A question as an author starts it, before filling anything in. + * + * @param {string} questionType + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ +export function blankInteraction(questionType) { + const descriptor = descriptorFor(questionType); + return descriptor.buildXML(descriptor.parse('', []), questionType); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js index 843fee1c4c..e23c7e90bd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js @@ -1,6 +1,11 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseAssociateInteraction, buildAssociateInteractionXML } from './parse'; +import { + exportAssociateContent, + getAssociateConversionFormat, + importAssociateContent, +} from './convert'; import { validateAssociateInteraction } from './validation'; /** @@ -74,6 +79,29 @@ export class AssociateInteractionDescriptor extends InteractionDescriptor { validate(state) { return validateAssociateInteraction(state); } + + /** + * @returns {import('../../conversion').ConversionFormat} + */ + getConversionFormat() { + return getAssociateConversionFormat(); + } + + /** + * @param {object} state - AssociateState + * @returns {import('../../conversion').ConvertibleContent} + */ + exportContent(state) { + return exportAssociateContent(state); + } + + /** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {object} AssociateState + */ + importContent(content) { + return importAssociateContent(content); + } } /** Singleton — safe to import from any file in the associate module tree. */ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/convert.spec.js new file mode 100644 index 0000000000..1cf548df4c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/convert.spec.js @@ -0,0 +1,41 @@ +import { exportAssociateContent, importAssociateContent } from '../convert'; + +const choice = content => ({ id: expect.stringMatching(/^choice_/), content }); + +describe('associate conversion', () => { + it('exports each pair from its first choice to its second, and the distractors', () => { + const state = { + prompt: '

Q

', + pairs: [ + [ + { id: 'choice_a', content: '

A

' }, + { id: 'choice_b', content: '

B

' }, + ], + ], + distractors: [{ id: 'choice_c', content: '

C

' }], + }; + expect(exportAssociateContent(state)).toEqual({ + prompt: '

Q

', + pairs: { + items: [{ source: '

A

', target: '

B

' }], + distractors: ['

C

'], + }, + }); + }); + + it('imports each pair and distractor as new choices', () => { + const state = importAssociateContent({ + prompt: '

Q

', + pairs: { items: [{ source: '

A

', target: '

B

' }], distractors: ['

C

'] }, + }); + expect(state.prompt).toBe('

Q

'); + expect(state.pairs).toEqual([[choice('

A

'), choice('

B

')]]); + expect(state.distractors).toEqual([choice('

C

')]); + }); + + it('starts with one blank pair and no distractors when there are no pairs', () => { + const state = importAssociateContent({ prompt: '' }); + expect(state.pairs).toEqual([[choice(''), choice('')]]); + expect(state.distractors).toEqual([]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/convert.js new file mode 100644 index 0000000000..ea75b7c97a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/convert.js @@ -0,0 +1,42 @@ +import { _defaultState, newChoice } from './parse'; + +/** + * @returns {import('../../conversion').ConversionFormat} + */ +export function getAssociateConversionFormat() { + return { pairs: true }; +} + +/** + * @param {import('./parse').AssociateState} state + * @returns {import('../../conversion').ConvertibleContent} + */ +export function exportAssociateContent(state) { + return { + prompt: state.prompt, + pairs: { + items: state.pairs.map(([source, target]) => ({ + source: source.content, + target: target.content, + })), + distractors: state.distractors.map(({ content }) => content), + }, + }; +} + +/** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {import('./parse').AssociateState} + */ +export function importAssociateContent({ prompt, pairs }) { + const state = _defaultState(); + const items = pairs?.items ?? []; + return { + ...state, + prompt, + pairs: items.length + ? items.map(({ source, target }) => [newChoice(source), newChoice(target)]) + : state.pairs, + distractors: (pairs?.distractors ?? []).map(content => newChoice(content)), + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js index ec208b36b0..c4bff8c4e5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/Descriptor.js @@ -2,6 +2,7 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../const import { parseXML } from '../../serialization/xml'; import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseChoiceInteraction, buildChoiceInteractionXML } from './parse'; +import { exportChoiceContent, getChoiceConversionFormat, importChoiceContent } from './convert'; import { validateChoiceInteraction } from './validation'; /** @@ -100,6 +101,30 @@ export class ChoiceInteractionDescriptor extends InteractionDescriptor { validate(state, questionType) { return validateChoiceInteraction(state, questionType); } + + /** + * @param {string} questionType + * @returns {import('../../conversion').ConversionFormat} + */ + getConversionFormat(questionType) { + return getChoiceConversionFormat(questionType); + } + + /** + * @param {object} state - ChoiceState + * @returns {import('../../conversion').ConvertibleContent} + */ + exportContent(state) { + return exportChoiceContent(state); + } + + /** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {object} ChoiceState + */ + importContent(content) { + return importChoiceContent(content); + } } /** Singleton — safe to import from any file in the choice module tree. */ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/convert.spec.js new file mode 100644 index 0000000000..0379fdc505 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/convert.spec.js @@ -0,0 +1,65 @@ +import { CorrectCriterion, ContentLevel, QuestionType } from '../../../constants'; +import { exportChoiceContent, getChoiceConversionFormat, importChoiceContent } from '../convert'; + +describe('choice conversion', () => { + it('takes rich selected answers, at most one correct for single choice', () => { + expect(getChoiceConversionFormat(QuestionType.SINGLE_SELECT).answers).toEqual({ + correctCriterion: CorrectCriterion.SELECTED, + contentLevel: ContentLevel.RICH, + single: true, + }); + expect(getChoiceConversionFormat(QuestionType.MULTI_SELECT).answers.single).toBe(false); + }); + + it('exports the question, each choice with its mark, and the layout settings', () => { + const state = { + prompt: '

Q

', + choices: [ + { id: 'choice_a', content: '

A

', correct: true, fixed: true }, + { id: 'choice_b', content: '

B

', correct: false }, + ], + shuffle: true, + showAnswerCount: false, + orientation: 'horizontal', + }; + expect(exportChoiceContent(state)).toEqual({ + prompt: '

Q

', + answers: { + correctCriterion: CorrectCriterion.SELECTED, + contentLevel: ContentLevel.RICH, + items: [ + { content: '

A

', correct: true, fixed: true }, + { content: '

B

', correct: false, fixed: false }, + ], + shuffle: true, + orientation: 'horizontal', + }, + }); + }); + + it('imports each answer as a new choice', () => { + const state = importChoiceContent({ + prompt: '

Q

', + answers: { + items: [{ content: '

A

', correct: true, fixed: true }], + shuffle: true, + orientation: 'horizontal', + }, + }); + expect(state.prompt).toBe('

Q

'); + expect(state.shuffle).toBe(true); + expect(state.orientation).toBe('horizontal'); + expect(state.choices).toEqual([ + { id: expect.stringMatching(/^choice_/), content: '

A

', correct: true, fixed: true }, + ]); + }); + + it('starts with one blank choice and the default settings when there are no answers', () => { + const state = importChoiceContent({ prompt: '

Q

' }); + expect(state.choices).toEqual([ + { id: expect.stringMatching(/^choice_/), content: '', correct: false }, + ]); + expect(state.shuffle).toBe(false); + expect(state.showAnswerCount).toBe(true); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/convert.js new file mode 100644 index 0000000000..e317ea1e6f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/convert.js @@ -0,0 +1,57 @@ +import { CorrectCriterion, ContentLevel, QuestionType } from '../../constants'; +import { _defaultState, newChoice } from './parse'; + +/** + * @param {string} questionType + * @returns {import('../../conversion').ConversionFormat} + */ +export function getChoiceConversionFormat(questionType) { + return { + answers: { + correctCriterion: CorrectCriterion.SELECTED, + contentLevel: ContentLevel.RICH, + single: questionType === QuestionType.SINGLE_SELECT, + }, + }; +} + +/** + * @param {import('./parse').ChoiceState} state + * @returns {import('../../conversion').ConvertibleContent} + */ +export function exportChoiceContent(state) { + return { + prompt: state.prompt, + answers: { + correctCriterion: CorrectCriterion.SELECTED, + contentLevel: ContentLevel.RICH, + items: state.choices.map(({ content, correct, fixed }) => ({ + content, + correct, + fixed: Boolean(fixed), + })), + shuffle: state.shuffle, + orientation: state.orientation, + }, + }; +} + +/** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {import('./parse').ChoiceState} + */ +export function importChoiceContent({ prompt, answers }) { + const state = _defaultState(); + const items = answers?.items ?? []; + return { + ...state, + prompt, + choices: items.length + ? items.map(({ content, correct, fixed }) => + newChoice({ content, correct, fixed: Boolean(fixed) }), + ) + : state.choices, + shuffle: answers?.shuffle ?? state.shuffle, + orientation: answers?.orientation ?? state.orientation, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js index df809cc9be..f5a9f897ad 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js @@ -23,11 +23,24 @@ import { Orientation, QuestionType, RESPONSE_IDENTIFIER } from '../../constants' const serializer = new XMLSerializer(); +/** + * A new choice, with a fresh identifier. + * + * @param {Partial} [fields] + * @returns {ChoiceAnswer} + */ +export const newChoice = (fields = {}) => ({ + id: generateRandomSlug('choice'), + content: '', + correct: false, + ...fields, +}); + export function _defaultState() { return { responseIdentifier: RESPONSE_IDENTIFIER, prompt: '', - choices: [{ id: generateRandomSlug('choice'), content: '', correct: false }], + choices: [newChoice()], showAnswerCount: true, shuffle: false, orientation: Orientation.VERTICAL, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/Descriptor.js index 4bf6b19925..4a1691e20a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/Descriptor.js @@ -3,6 +3,11 @@ import { contentChildrenOf } from '../../serialization/xml'; import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseGapMatchInteraction, buildGapMatchInteractionXML } from './parse'; import { validateGapMatchInteraction } from './validation'; +import { + exportGapMatchContent, + getGapMatchConversionFormat, + importGapMatchContent, +} from './convert'; class GapMatchInteractionDescriptor extends InteractionDescriptor { constructor() { @@ -44,6 +49,29 @@ class GapMatchInteractionDescriptor extends InteractionDescriptor { validate(state) { return validateGapMatchInteraction(state); } + + /** + * @returns {import('../../conversion').ConversionFormat} + */ + getConversionFormat() { + return getGapMatchConversionFormat(); + } + + /** + * @param {import('./parse').GapMatchState} state + * @returns {import('../../conversion').ConvertibleContent} + */ + exportContent(state) { + return exportGapMatchContent(state); + } + + /** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {import('./parse').GapMatchState} + */ + importContent(content) { + return importGapMatchContent(content); + } } export const gapMatchInteractionDescriptor = new GapMatchInteractionDescriptor(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/__tests__/convert.spec.js new file mode 100644 index 0000000000..fef6eab393 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/__tests__/convert.spec.js @@ -0,0 +1,80 @@ +import { ContentLevel } from '../../../constants'; +import { exportGapMatchContent, importGapMatchContent } from '../convert'; +import { getGaps } from '../parse'; + +const passageFacet = (blanks, distractors = []) => ({ + html: blanks.map((_, i) => ``).join(' '), + blanks, + distractors, + contentLevel: ContentLevel.INLINE, +}); + +describe('gap match conversion', () => { + it('exports each gap as a blank, its content the answer, and the pool as distractors', () => { + const state = { + prompt: '

Q

', + passage: + '

The Sun' + + ' and

', + distractors: [{ id: 'choice_moon', content: 'Moon' }], + }; + expect(exportGapMatchContent(state)).toEqual({ + prompt: '

Q

', + passage: { + html: '

The and

', + blanks: [ + { answer: 'Sun', options: [] }, + { answer: null, options: [] }, + ], + distractors: ['Moon'], + contentLevel: ContentLevel.INLINE, + shuffle: true, + }, + }); + }); + + it('reads each answer as the build does, leaving out comments and scripts', () => { + const state = { + prompt: '', + passage: '

Sun

', + distractors: [], + }; + expect(exportGapMatchContent(state).passage.blanks).toEqual([{ answer: 'Sun', options: [] }]); + }); + + it('imports each blank as a gap holding its answer, and an unanswered one as an empty gap', () => { + const state = importGapMatchContent({ + prompt: '

Q

', + passage: passageFacet([ + { answer: 'Sun', options: [] }, + { answer: null, options: [] }, + ]), + }); + expect(state.prompt).toBe('

Q

'); + expect(getGaps(state.passage).map(({ answer }) => answer)).toEqual(['Sun', '']); + expect(state.distractors).toEqual([]); + }); + + it('pools every blank’s options with the distractors, each once and none an answer', () => { + const state = importGapMatchContent({ + prompt: '', + passage: passageFacet( + [ + { answer: 'Sun', options: ['Moon', 'Star'] }, + { answer: 'Moon', options: ['Sun', 'Star', 'Comet'] }, + ], + ['Comet', 'Planet', ''], + ), + }); + expect(state.distractors.map(({ content }) => content)).toEqual(['Star', 'Comet', 'Planet']); + }); + + it('keeps the question alone when there is no passage to import', () => { + expect(importGapMatchContent({ prompt: '

Q

' })).toEqual({ + responseIdentifier: 'RESPONSE', + prompt: '

Q

', + passage: '', + distractors: [], + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/convert.js new file mode 100644 index 0000000000..95f2386da7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/convert.js @@ -0,0 +1,87 @@ +import flatMap from 'lodash/flatMap'; +import { ContentLevel } from '../../constants'; +import { getContentHTML, parseHTMLBody } from '../../serialization/xml'; +import { BLANK_ATTR } from '../../conversion/passage'; +import { GAP, _defaultState, newDistractor, readGap, writeGap } from './parse'; +import { + hasRichTextContent, + richTextComparisonKey, +} from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; + +/** + * @returns {import('../../conversion').ConversionFormat} + */ +export function getGapMatchConversionFormat() { + return { passage: { contentLevel: ContentLevel.INLINE } }; +} + +/** + * Each gap becomes a blank, its content the blank's answer. A gap has no options of its own: + * every wrong answer is in the shared pool of distractors. + * + * @param {import('./parse').GapMatchState} state + * @returns {import('../../conversion').ConvertibleContent} + */ +export function exportGapMatchContent(state) { + const body = parseHTMLBody(state.passage); + const blanks = [...body.querySelectorAll(GAP)].map((el, index) => { + const { answer } = readGap(el); + const placeholder = body.ownerDocument.createElement('span'); + placeholder.setAttribute(BLANK_ATTR, String(index)); + el.replaceWith(placeholder); + return { answer: hasRichTextContent(answer) ? answer : null, options: [] }; + }); + return { + prompt: state.prompt, + passage: { + html: getContentHTML(body), + blanks, + distractors: state.distractors.map(({ content }) => content), + contentLevel: ContentLevel.INLINE, + // Learners always see the pool shuffled; otherwise the answers would come in order. + shuffle: true, + }, + }; +} + +/** + * Each blank becomes a gap holding its answer. Every blank's own options join the shared + * pool, which is what a gap match learner chooses from. The conversion repeats an option + * offered in several dropdowns, or one that is another blank's answer, so those repeats are + * dropped rather than left for validation to flag. + * + * @param {import('../../conversion').ConvertibleContent} content + * @returns {import('./parse').GapMatchState} + */ +export function importGapMatchContent({ prompt, passage }) { + const state = _defaultState(); + if (!passage) { + return { ...state, prompt }; + } + const body = parseHTMLBody(passage.html); + for (const placeholder of body.querySelectorAll(`[${BLANK_ATTR}]`)) { + const blank = passage.blanks[Number(placeholder.getAttribute(BLANK_ATTR))]; + if (!blank) { + placeholder.remove(); + continue; + } + placeholder.replaceWith(writeGap(body.ownerDocument, blank.answer ?? '')); + } + + const offered = new Set( + passage.blanks.map(({ answer }) => richTextComparisonKey(answer)).filter(Boolean), + ); + const distractors = []; + for (const content of [ + ...flatMap(passage.blanks, ({ options }) => options), + ...passage.distractors, + ]) { + const key = richTextComparisonKey(content); + if (key && !offered.has(key)) { + offered.add(key); + distractors.push(newDistractor(content)); + } + } + + return { ...state, prompt, passage: getContentHTML(body), distractors }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js index 08385dbfb4..799fe62d67 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/gapMatch/parse.js @@ -5,6 +5,7 @@ import { getContentHTML, getPromptHTML, isContentNode, + parseHTMLBody, parseXML, serializeAsHtml, wrapInlineRuns, @@ -19,7 +20,7 @@ import { const serializer = new XMLSerializer(); -const GAP = 'qti-gap'; +export const GAP = 'qti-gap'; const GAP_TEXT = 'qti-gap-text'; const PROMPT = 'qti-prompt'; const CORRECT_ATTR = 'data-studio-correct'; @@ -48,10 +49,45 @@ const SENTINEL_ID = 'studio_sentinel'; * @property {string} answer - HTML of the gap's correct answer; '' when blank */ -function defaultState() { +export function _defaultState() { return { responseIdentifier: RESPONSE_IDENTIFIER, prompt: '', passage: '', distractors: [] }; } +/** + * A new distractor, with a fresh identifier. + * + * @param {string} [content] + * @returns {GapMatchDistractor} + */ +export const newDistractor = (content = '') => ({ id: generateRandomSlug('choice'), content }); + +/** + * @param {Element} el - A `` as the passage holds it + * @returns {GapMatchGap} + */ +export function readGap(el) { + return { + id: el.getAttribute('identifier'), + answerId: el.getAttribute(CORRECT_ATTR), + answer: getContentHTML(el), + }; +} + +/** + * A new gap, with a fresh identifier, as the passage holds it: holding its answer, which the + * build writes out as its gap text. The reverse of readGap. + * + * @param {Document} doc - The document of the passage it goes in + * @param {string} answer - HTML of its correct answer; '' for a blank gap + * @returns {Element} + */ +export function writeGap(doc, answer) { + const gap = doc.createElement(GAP); + gap.setAttribute('identifier', generateRandomSlug('gap')); + gap.append(...parseHTMLBody(answer).childNodes); + return gap; +} + /** * Read every gap out of the passage, in document order. * @@ -60,11 +96,7 @@ function defaultState() { */ export function getGaps(passage) { const doc = parseXML(passage || '', 'text/html'); - return [...doc.querySelectorAll(GAP)].map(el => ({ - id: el.getAttribute('identifier'), - answerId: el.getAttribute(CORRECT_ATTR), - answer: getContentHTML(el), - })); + return [...doc.querySelectorAll(GAP)].map(readGap); } /** @@ -79,13 +111,13 @@ export function getGaps(passage) { * @returns {GapMatchState} */ export function parseGapMatchInteraction(bodyXml, responseDeclarations) { - if (!bodyXml) return defaultState(); + if (!bodyXml) return _defaultState(); let root; try { root = parseXML(bodyXml).documentElement; } catch { - return defaultState(); + return _defaultState(); } const contentNodes = [...root.childNodes].filter(isContentNode); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js index 75fa995cab..108ff2be16 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/Descriptor.js @@ -1,6 +1,11 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality, Placement } from '../../constants'; import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseInlineChoiceInteraction, buildInlineChoiceInteractionXML } from './parse'; +import { + exportInlineChoiceContent, + getInlineChoiceConversionFormat, + importInlineChoiceContent, +} from './convert'; import { validateInlineChoiceInteraction } from './validation'; /** @@ -82,6 +87,29 @@ class InlineChoiceInteractionDescriptor extends InteractionDescriptor { validate(state) { return validateInlineChoiceInteraction(state); } + + /** + * @returns {import('../../conversion').ConversionFormat} + */ + getConversionFormat() { + return getInlineChoiceConversionFormat(); + } + + /** + * @param {InlineChoiceState} state + * @returns {import('../../conversion').ConvertibleContent} + */ + exportContent(state) { + return exportInlineChoiceContent(state); + } + + /** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {InlineChoiceState} + */ + importContent(content) { + return importInlineChoiceContent(content); + } } export const inlineChoiceInteractionDescriptor = new InlineChoiceInteractionDescriptor(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/convert.spec.js new file mode 100644 index 0000000000..44e1547af2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/__tests__/convert.spec.js @@ -0,0 +1,120 @@ +import flatMap from 'lodash/flatMap'; +import { ContentLevel, ConversionLoss, QuestionType } from '../../../constants'; +import { carryContent } from '../../../conversion'; +import { inlineChoiceInteractionDescriptor as descriptor } from '../Descriptor'; +import { exportInlineChoiceContent, importInlineChoiceContent } from '../convert'; +import { getDropdowns } from '../parse'; + +const dropdown = (name, options, correct) => + `' + + options + .map(text => `${text}`) + .join('') + + ''; + +/** A gap match passage, as gap match exports it: answers inline, one shared pool. */ +const gapMatchPassage = { + html: '

Now is the of our .

', + blanks: [ + { answer: 'winter', options: [] }, + { answer: 'discontent', options: [] }, + ], + distractors: ['summer', 'winter'], + contentLevel: ContentLevel.INLINE, + shuffle: true, +}; + +const summary = passage => + getDropdowns(passage).map(({ options, correctId }) => ({ + options: options.map(({ text }) => text), + correct: options.find(({ id }) => id === correctId)?.text ?? null, + })); + +describe('inline choice conversion', () => { + it('exports each dropdown as a blank, its correct option the answer', () => { + const state = { + prompt: '

Q

', + passage: `

The ${dropdown('a', ['Moon', 'Sun'], 'Sun')} and ${dropdown('b', ['x'])}

`, + shuffle: true, + }; + expect(exportInlineChoiceContent(state)).toEqual({ + prompt: '

Q

', + passage: { + html: '

The and

', + blanks: [ + { answer: 'Sun', options: ['Moon'] }, + { answer: null, options: ['x'] }, + ], + distractors: [], + contentLevel: ContentLevel.PLAIN, + shuffle: true, + }, + }); + }); + + it('imports a passage from gap match, each dropdown offering the whole pool', () => { + const { carried, losses } = carryContent( + { prompt: '

Q

', passage: gapMatchPassage }, + descriptor.getConversionFormat(QuestionType.INLINE_CHOICE), + ); + // The answer was in italics, which an option can't hold. + expect(losses).toEqual([ConversionLoss.FORMATTING_REMOVED]); + + const state = importInlineChoiceContent(carried); + expect(state.prompt).toBe('

Q

'); + expect(state.shuffle).toBe(true); + expect(summary(state.passage)).toEqual([ + { options: ['winter', 'discontent', 'summer'], correct: 'winter' }, + { options: ['discontent', 'winter', 'summer'], correct: 'discontent' }, + ]); + }); + + it('gives a blank with no answer and nothing to offer one empty option, unmarked', () => { + const state = importInlineChoiceContent({ + prompt: '', + passage: { + html: '

', + blanks: [{ answer: null, options: [] }], + distractors: [], + contentLevel: ContentLevel.PLAIN, + }, + }); + expect(summary(state.passage)).toEqual([{ options: [''], correct: null }]); + expect(state.shuffle).toBe(false); + }); + + it('gives every dropdown its own identifiers', () => { + const { carried } = carryContent( + { prompt: '', passage: gapMatchPassage }, + descriptor.getConversionFormat(QuestionType.INLINE_CHOICE), + ); + const dropdowns = getDropdowns(importInlineChoiceContent(carried).passage); + const ids = flatMap(dropdowns, d => [d.responseIdentifier, ...d.options.map(o => o.id)]); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('imports a new question with the question only, when there is no passage', () => { + expect(importInlineChoiceContent({ prompt: '

Q

' })).toEqual({ + prompt: '

Q

', + passage: '', + shuffle: false, + }); + }); + + it('writes a passage the inline choice editor reads back the same', () => { + const { carried } = carryContent( + { prompt: '

Q

', passage: gapMatchPassage }, + descriptor.getConversionFormat(QuestionType.INLINE_CHOICE), + ); + const state = importInlineChoiceContent(carried); + const { bodyXml, responseDeclarations } = descriptor.buildXML( + state, + QuestionType.INLINE_CHOICE, + ); + const reopened = descriptor.parse(bodyXml, responseDeclarations); + expect(summary(reopened.passage)).toEqual(summary(state.passage)); + expect(descriptor.validate(reopened)).toEqual([]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/convert.js new file mode 100644 index 0000000000..6f83a3a6e2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/convert.js @@ -0,0 +1,94 @@ +import { ContentLevel } from '../../constants'; +import { getContentHTML, parseHTMLBody } from '../../serialization/xml'; +import { BLANK_ATTR } from '../../conversion/passage'; +import { + DROPDOWN, + _defaultState, + newDropdown, + newOption, + readDropdown, + writeDropdown, +} from './parse'; + +/** + * @returns {import('../../conversion').ConversionFormat} + */ +export function getInlineChoiceConversionFormat() { + return { passage: { contentLevel: ContentLevel.PLAIN } }; +} + +/** + * Each dropdown becomes a blank, its correct option the blank's answer and the rest its own + * options. + * + * @param {import('./parse').InlineChoiceState} state + * @returns {import('../../conversion').ConvertibleContent} + */ +export function exportInlineChoiceContent(state) { + const body = parseHTMLBody(state.passage); + const blanks = [...body.querySelectorAll(DROPDOWN)].map((el, index) => { + const { options, correctId } = readDropdown(el); + const placeholder = body.ownerDocument.createElement('span'); + placeholder.setAttribute(BLANK_ATTR, String(index)); + el.replaceWith(placeholder); + + const correct = options.find(option => option.id === correctId); + return { + answer: correct ? correct.text : null, + options: options.filter(option => option !== correct).map(option => option.text), + }; + }); + return { + prompt: state.prompt, + passage: { + html: getContentHTML(body), + blanks, + distractors: [], + contentLevel: ContentLevel.PLAIN, + shuffle: state.shuffle, + }, + }; +} + +/** + * A dropdown offering everything a learner could have put in its blank: its answer, its own + * options, every other blank's answer and every distractor, as a gap match learner chooses + * from one shared pool. Its answer, when it has one, is its correct option. One with nothing + * to offer starts as an unfilled dropdown, with one empty option. + */ +function buildDropdown(doc, { answer, options }, answers, distractors) { + const texts = [ + ...new Set( + [answer, ...options, ...answers, ...distractors].map(text => text?.trim()).filter(Boolean), + ), + ]; + const dropdownOptions = (texts.length ? texts : ['']).map(text => newOption(text)); + return writeDropdown( + doc, + newDropdown({ + options: dropdownOptions, + // The answer leads the list, so it is the first option. + correctId: answer?.trim() ? dropdownOptions[0].id : null, + }), + ); +} + +/** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {import('./parse').InlineChoiceState} + */ +export function importInlineChoiceContent({ prompt, passage }) { + const state = _defaultState(); + if (!passage) { + return { ...state, prompt }; + } + const body = parseHTMLBody(passage.html); + const answers = passage.blanks.map(({ answer }) => answer).filter(Boolean); + for (const placeholder of body.querySelectorAll(`[${BLANK_ATTR}]`)) { + const blank = passage.blanks[Number(placeholder.getAttribute(BLANK_ATTR))]; + placeholder.replaceWith( + blank ? buildDropdown(body.ownerDocument, blank, answers, passage.distractors) : '', + ); + } + return { prompt, passage: getContentHTML(body), shuffle: passage.shuffle ?? state.shuffle }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js index c1aade0468..2d0c092759 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/inlineChoice/parse.js @@ -63,6 +63,47 @@ export function readDropdown(el) { }; } +/** + * A new option, with a fresh identifier. + * + * @param {string} [text] + * @returns {{ id: string, text: string }} + */ +export const newOption = (text = '') => ({ id: generateRandomSlug('choice'), text }); + +/** + * A new dropdown, with a fresh response identifier. + * + * @param {Partial} [fields] + * @returns {InlineChoiceDropdown} + */ +export const newDropdown = (fields = {}) => ({ + responseIdentifier: generateRandomSlug('response'), + options: [newOption()], + correctId: null, + ...fields, +}); + +/** + * A dropdown as the passage holds it. The reverse of readDropdown. + * + * @param {Document} doc - The document of the passage it goes in + * @param {InlineChoiceDropdown} dropdown + * @returns {Element} + */ +export function writeDropdown(doc, { responseIdentifier, options, correctId }) { + const el = doc.createElement(DROPDOWN); + el.setAttribute('response-identifier', responseIdentifier); + if (correctId !== null) el.setAttribute(CORRECT_ATTR, correctId); + for (const { id, text } of options) { + const option = doc.createElement(OPTION); + option.setAttribute('identifier', id); + option.textContent = text; + el.appendChild(option); + } + return el; +} + /** * A dropdown as it is copied, as a DOM output spec. An editor without dropdowns keeps only the * text of an element it doesn't know, so the text is the correct answer alone, or nothing with no diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js index eaa9320882..9b2b7b638a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/Descriptor.js @@ -1,6 +1,7 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseMatchInteraction, buildMatchInteractionXML, hasTwoMatchSets } from './parse'; +import { exportMatchContent, getMatchConversionFormat, importMatchContent } from './convert'; import { validateMatchInteraction } from './validation'; /** @@ -79,6 +80,29 @@ export class MatchInteractionDescriptor extends InteractionDescriptor { validate(state) { return validateMatchInteraction(state); } + + /** + * @returns {import('../../conversion').ConversionFormat} + */ + getConversionFormat() { + return getMatchConversionFormat(); + } + + /** + * @param {object} state - MatchState + * @returns {import('../../conversion').ConvertibleContent} + */ + exportContent(state) { + return exportMatchContent(state); + } + + /** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {object} MatchState + */ + importContent(content) { + return importMatchContent(content); + } } /** Singleton — safe to import from any file in the match module tree. */ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/convert.spec.js new file mode 100644 index 0000000000..fd0eb2a316 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/__tests__/convert.spec.js @@ -0,0 +1,94 @@ +import { exportMatchContent, importMatchContent } from '../convert'; + +describe('match conversion', () => { + it('exports one pair per match, and a row with no match yet as a pair with nothing', () => { + const state = { + prompt: '

Q

', + rows: [ + { + id: 'row_a', + content: '

Bat

', + matches: [ + { id: 'choice_m', content: '

Mammal

' }, + { id: 'choice_f', content: '

Flyer

' }, + ], + }, + { id: 'row_b', content: '

Frog

', matches: [] }, + ], + distractors: [{ id: 'choice_r', content: '

Reptile

' }], + }; + expect(exportMatchContent(state)).toEqual({ + prompt: '

Q

', + pairs: { + items: [ + { source: '

Bat

', target: '

Mammal

' }, + { source: '

Bat

', target: '

Flyer

' }, + { source: '

Frog

', target: '' }, + ], + distractors: ['

Reptile

'], + }, + }); + }); + + it('imports each pair as a row of its own, with its target as the match', () => { + const state = importMatchContent({ + prompt: '

Q

', + pairs: { items: [{ source: '

A

', target: '

B

' }], distractors: ['

C

'] }, + }); + expect(state.rows).toEqual([ + { + id: expect.stringMatching(/^row_/), + content: '

A

', + matches: [{ id: expect.stringMatching(/^choice_/), content: '

B

' }], + }, + ]); + expect(state.distractors).toEqual([ + { id: expect.stringMatching(/^choice_/), content: '

C

' }, + ]); + }); + + it('gathers pairs from the same source into one row, matched to each target', () => { + const state = importMatchContent({ + prompt: '', + pairs: { + items: [ + { source: '

Bat

', target: '

Mammal

' }, + { source: '

Dog

', target: '

Mammal

' }, + { source: '

Bat

', target: '

Flyer

' }, + { source: '

Bat

', target: '

Flyer

' }, + ], + distractors: [], + }, + }); + expect( + state.rows.map(({ content, matches }) => [content, matches.map(m => m.content)]), + ).toEqual([ + ['

Bat

', ['

Mammal

', '

Flyer

']], + ['

Dog

', ['

Mammal

']], + ]); + }); + + it('keeps rows with no source apart, each with a match to fill in', () => { + const state = importMatchContent({ + prompt: '', + pairs: { + items: [ + { source: '', target: '' }, + { source: '

', target: '

B

' }, + ], + distractors: [], + }, + }); + expect(state.rows.map(({ matches }) => matches.map(m => m.content))).toEqual([ + [''], + ['

B

'], + ]); + }); + + it('starts with one blank row when there are no pairs', () => { + const state = importMatchContent({ prompt: '' }); + expect(state.rows).toHaveLength(1); + expect(state.rows[0].content).toBe(''); + expect(state.distractors).toEqual([]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/convert.js new file mode 100644 index 0000000000..9dd48b1a21 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/convert.js @@ -0,0 +1,77 @@ +import flatMap from 'lodash/flatMap'; +import { newChoice } from '../associate/parse'; +import { _defaultState, newRow } from './parse'; +import { richTextComparisonKey } from 'shared/views/TipTapEditor/TipTapEditor/utils/comparisonKey'; + +/** + * @returns {import('../../conversion').ConversionFormat} + */ +export function getMatchConversionFormat() { + return { pairs: true }; +} + +/** + * Each row gives one pair per match. A row with no match yet still gives one, with nothing + * to pair it with, so the row itself isn't lost. + * + * @param {import('./parse').MatchState} state + * @returns {import('../../conversion').ConvertibleContent} + */ +export function exportMatchContent(state) { + return { + prompt: state.prompt, + pairs: { + items: flatMap(state.rows, ({ content, matches }) => + matches.length + ? matches.map(match => ({ source: content, target: match.content })) + : [{ source: content, target: '' }], + ), + distractors: state.distractors.map(({ content }) => content), + }, + }; +} + +/** + * Pairs from the same source become one row, matched to each of their targets, so a row that + * went to associate as several pairs comes back as the row it was. A blank source is a row of + * its own, as two blank rows are two rows the author has yet to fill in. + * + * @param {Array<{ source: string, target: string }>} items + * @returns {import('./parse').MatchRow[]} + */ +function rowsFromPairs(items) { + const rows = []; + const rowByKey = new Map(); + for (const { source, target } of items) { + const key = richTextComparisonKey(source); + let row = key ? rowByKey.get(key) : null; + if (!row) { + row = newRow({ content: source, matches: [] }); + rows.push(row); + if (key) rowByKey.set(key, row); + } + const targetKey = richTextComparisonKey(target); + const repeated = row.matches.some(match => richTextComparisonKey(match.content) === targetKey); + if (targetKey && !repeated) row.matches.push(newChoice(target)); + } + // A row has a match to fill in, even before it has an answer. + for (const row of rows) { + if (!row.matches.length) row.matches.push(newChoice()); + } + return rows; +} + +/** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {import('./parse').MatchState} + */ +export function importMatchContent({ prompt, pairs }) { + const state = _defaultState(); + const items = pairs?.items ?? []; + return { + ...state, + prompt, + rows: items.length ? rowsFromPairs(items) : state.rows, + distractors: (pairs?.distractors ?? []).map(content => newChoice(content)), + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js index 4e81b80576..4b2575c40b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js @@ -30,12 +30,16 @@ const serializer = new XMLSerializer(); */ /** + * A new row, with a fresh identifier and one match to fill in. + * + * @param {Partial} [fields] * @returns {MatchRow} */ -export const newRow = () => ({ +export const newRow = (fields = {}) => ({ id: generateRandomSlug('row'), content: '', matches: [newChoice()], + ...fields, }); /** diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js index 04432cc88b..f16445c7ba 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/Descriptor.js @@ -1,6 +1,11 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; import { InteractionDescriptor } from '../InteractionDescriptor'; import { parseOrderingInteraction, buildOrderingInteractionXML } from './parse'; +import { + exportOrderingContent, + getOrderingConversionFormat, + importOrderingContent, +} from './convert'; import { validateOrderingInteraction } from './validation'; /** @@ -74,6 +79,29 @@ export class OrderingInteractionDescriptor extends InteractionDescriptor { validate(state) { return validateOrderingInteraction(state); } + + /** + * @returns {import('../../conversion').ConversionFormat} + */ + getConversionFormat() { + return getOrderingConversionFormat(); + } + + /** + * @param {object} state - OrderingState + * @returns {import('../../conversion').ConvertibleContent} + */ + exportContent(state) { + return exportOrderingContent(state); + } + + /** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {object} OrderingState + */ + importContent(content) { + return importOrderingContent(content); + } } /** Singleton — safe to import from any file in the ordering module tree. */ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/convert.spec.js new file mode 100644 index 0000000000..438feb5d51 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/__tests__/convert.spec.js @@ -0,0 +1,55 @@ +import { CorrectCriterion, ContentLevel } from '../../../constants'; +import { + exportOrderingContent, + getOrderingConversionFormat, + importOrderingContent, +} from '../convert'; + +describe('ordering conversion', () => { + it('takes rich ordered answers', () => { + expect(getOrderingConversionFormat()).toEqual({ + answers: { correctCriterion: CorrectCriterion.ORDERED, contentLevel: ContentLevel.RICH }, + }); + }); + + it('exports the items in their correct order and the layout, but not the shuffle setting', () => { + const state = { + prompt: '

Q

', + items: [ + { id: 'order_a', content: '

1

', fixed: true }, + { id: 'order_b', content: '

2

' }, + ], + shuffle: false, + orientation: 'horizontal', + }; + expect(exportOrderingContent(state)).toEqual({ + prompt: '

Q

', + answers: { + correctCriterion: CorrectCriterion.ORDERED, + contentLevel: ContentLevel.RICH, + items: [ + { content: '

1

', correct: false, fixed: true }, + { content: '

2

', correct: false, fixed: false }, + ], + orientation: 'horizontal', + }, + }); + }); + + it('imports each answer as a new item, and stays shuffled', () => { + const state = importOrderingContent({ + prompt: '

Q

', + answers: { items: [{ content: '

1

', correct: true }], shuffle: false }, + }); + expect(state.items).toEqual([ + { id: expect.stringMatching(/^order_/), content: '

1

', fixed: false }, + ]); + expect(state.shuffle).toBe(true); + }); + + it('starts with one blank item when there are no answers', () => { + expect(importOrderingContent({ prompt: '' }).items).toEqual([ + { id: expect.stringMatching(/^order_/), content: '' }, + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/convert.js new file mode 100644 index 0000000000..5f07c43c06 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/convert.js @@ -0,0 +1,53 @@ +import { CorrectCriterion, ContentLevel } from '../../constants'; +import { _defaultState, newItem } from './parse'; + +/** + * Ordering's shuffle is neither given nor taken: authors can't change it, and an ordering + * question that isn't shuffled shows its items in the correct order. + */ + +/** + * @returns {import('../../conversion').ConversionFormat} + */ +export function getOrderingConversionFormat() { + return { + answers: { correctCriterion: CorrectCriterion.ORDERED, contentLevel: ContentLevel.RICH }, + }; +} + +/** + * @param {import('./parse').OrderingState} state + * @returns {import('../../conversion').ConvertibleContent} + */ +export function exportOrderingContent(state) { + return { + prompt: state.prompt, + answers: { + correctCriterion: CorrectCriterion.ORDERED, + contentLevel: ContentLevel.RICH, + items: state.items.map(({ content, fixed }) => ({ + content, + correct: false, + fixed: Boolean(fixed), + })), + orientation: state.orientation, + }, + }; +} + +/** + * @param {import('../../conversion').ConvertibleContent} content + * @returns {import('./parse').OrderingState} + */ +export function importOrderingContent({ prompt, answers }) { + const state = _defaultState(); + const items = answers?.items ?? []; + return { + ...state, + prompt, + items: items.length + ? items.map(({ content, fixed }) => newItem({ content, fixed: Boolean(fixed) })) + : state.items, + orientation: answers?.orientation ?? state.orientation, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js index d3b9203e97..ac3470cbba 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js @@ -24,11 +24,23 @@ import { Orientation, RESPONSE_IDENTIFIER } from '../../constants'; const serializer = new XMLSerializer(); +/** + * A new item, with a fresh identifier. + * + * @param {Partial} [fields] + * @returns {OrderingItem} + */ +export const newItem = (fields = {}) => ({ + id: generateRandomSlug('order'), + content: '', + ...fields, +}); + export function _defaultState() { return { responseIdentifier: RESPONSE_IDENTIFIER, prompt: '', - items: [{ id: generateRandomSlug('order'), content: '' }], + items: [newItem()], orientation: Orientation.VERTICAL, shuffle: true, }; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js index 0728a930e8..4e59533fbb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js @@ -6,6 +6,11 @@ import { buildTextEntryInteractionXML, isSupportedTextEntryBody, } from './parse'; +import { + exportTextEntryContent, + getTextEntryConversionFormat, + importTextEntryContent, +} from './convert'; import { validateTextEntryInteraction } from './validation'; /** @@ -126,6 +131,32 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor { validate(state, questionType, options) { return validateTextEntryInteraction(state, questionType, options); } + + /** + * @param {string} questionType + * @returns {import('../../conversion').ConversionFormat} + */ + getConversionFormat(questionType) { + return getTextEntryConversionFormat(questionType); + } + + /** + * @param {object} state - TextEntryState + * @param {string} questionType + * @returns {import('../../conversion').ConvertibleContent} + */ + exportContent(state, questionType) { + return exportTextEntryContent(state, questionType); + } + + /** + * @param {import('../../conversion').ConvertibleContent} content + * @param {string} questionType + * @returns {object} TextEntryState + */ + importContent(content, questionType) { + return importTextEntryContent(content, questionType); + } } /** Singleton — safe to import from any file in the textEntry module tree. */ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/convert.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/convert.spec.js new file mode 100644 index 0000000000..94113555ae --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/convert.spec.js @@ -0,0 +1,79 @@ +import { CorrectCriterion, ContentLevel, QuestionType } from '../../../constants'; +import { + exportTextEntryContent, + getTextEntryConversionFormat, + importTextEntryContent, +} from '../convert'; +import { DEFAULT_EXPECTED_LENGTH } from '../parse'; + +const state = { + prompt: '

Q

', + answers: [{ id: 'answer_a', value: 'Paris', caseSensitive: true }], + expectedLength: 80, +}; + +describe('text entry conversion', () => { + it.each([QuestionType.NUMERIC, QuestionType.TEXT_ENTRY])( + 'takes plain accepted answers for %s', + questionType => { + expect(getTextEntryConversionFormat(questionType)).toEqual({ + answers: { correctCriterion: CorrectCriterion.ACCEPTED, contentLevel: ContentLevel.PLAIN }, + }); + }, + ); + + it('takes no answers for a free response', () => { + expect(getTextEntryConversionFormat(QuestionType.FREE_RESPONSE)).toEqual({}); + }); + + it('exports each answer as accepted, with its case sensitivity, and the expected length', () => { + expect(exportTextEntryContent(state, QuestionType.TEXT_ENTRY)).toEqual({ + prompt: '

Q

', + answers: { + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + items: [{ content: 'Paris', correct: true, caseSensitive: true }], + expectedLength: 80, + }, + }); + }); + + it('exports no answers from a free response, which saves none, but its expected length', () => { + expect(exportTextEntryContent(state, QuestionType.FREE_RESPONSE).answers).toEqual({ + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + items: [], + expectedLength: 80, + }); + }); + + it('imports the expected length that came with the answers', () => { + const content = { prompt: '', answers: { items: [], expectedLength: 10 } }; + expect(importTextEntryContent(content, QuestionType.TEXT_ENTRY).expectedLength).toBe(10); + }); + + it('imports each answer as a new one, with the default expected length otherwise', () => { + const content = { + prompt: '

Q

', + answers: { items: [{ content: 'Paris', correct: true, caseSensitive: true }] }, + }; + expect(importTextEntryContent(content, QuestionType.TEXT_ENTRY)).toEqual({ + prompt: '

Q

', + answers: [{ id: expect.stringMatching(/^answer_/), value: 'Paris', caseSensitive: true }], + expectedLength: DEFAULT_EXPECTED_LENGTH, + }); + }); + + it('makes no numeric answer case-sensitive', () => { + const content = { prompt: '', answers: { items: [{ content: '1', caseSensitive: true }] } }; + expect(importTextEntryContent(content, QuestionType.NUMERIC).answers[0].caseSensitive).toBe( + false, + ); + }); + + it('starts with one blank answer when there are none', () => { + expect(importTextEntryContent({ prompt: '' }, QuestionType.TEXT_ENTRY).answers).toEqual([ + { id: expect.stringMatching(/^answer_/), value: '', caseSensitive: false }, + ]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/convert.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/convert.js new file mode 100644 index 0000000000..53f7709384 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/convert.js @@ -0,0 +1,66 @@ +import { CorrectCriterion, ContentLevel, QuestionType } from '../../constants'; +import { _defaultState, newAnswer } from './parse'; + +// A free response has no answers to give or take. +const takesAnswers = questionType => questionType !== QuestionType.FREE_RESPONSE; + +/** + * @param {string} questionType + * @returns {import('../../conversion').ConversionFormat} + */ +export function getTextEntryConversionFormat(questionType) { + if (!takesAnswers(questionType)) { + return {}; + } + return { + answers: { correctCriterion: CorrectCriterion.ACCEPTED, contentLevel: ContentLevel.PLAIN }, + }; +} + +/** + * @param {import('./parse').TextEntryState} state + * @param {string} questionType + * @returns {import('../../conversion').ConvertibleContent} + */ +export function exportTextEntryContent(state, questionType) { + return { + prompt: state.prompt, + answers: { + correctCriterion: CorrectCriterion.ACCEPTED, + contentLevel: ContentLevel.PLAIN, + // A free response saves no answers, so it has none to carry, whatever its state holds. + items: takesAnswers(questionType) + ? state.answers.map(({ value, caseSensitive }) => ({ + content: value, + correct: true, + caseSensitive, + })) + : [], + expectedLength: state.expectedLength, + }, + }; +} + +/** + * @param {import('../../conversion').ConvertibleContent} content + * @param {string} questionType + * @returns {import('./parse').TextEntryState} + */ +export function importTextEntryContent({ prompt, answers }, questionType) { + const state = _defaultState(); + const items = answers?.items ?? []; + return { + ...state, + prompt, + answers: items.length + ? items.map(({ content, caseSensitive }) => + newAnswer({ + value: content, + // Numeric answers are never case-sensitive. + caseSensitive: questionType === QuestionType.TEXT_ENTRY && Boolean(caseSensitive), + }), + ) + : state.answers, + expectedLength: answers?.expectedLength ?? state.expectedLength, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js index faf5116a3e..d1edbc7829 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -40,6 +40,19 @@ const serializer = new XMLSerializer(); */ export const DEFAULT_EXPECTED_LENGTH = 50; +/** + * A new answer, with a fresh identifier. + * + * @param {Partial} [fields] + * @returns {TextEntryAnswer} + */ +export const newAnswer = (fields = {}) => ({ + id: generateRandomSlug('answer'), + value: '', + caseSensitive: false, + ...fields, +}); + /** * Default state — used when bodyXml is absent or unparseable. * @@ -48,7 +61,7 @@ export const DEFAULT_EXPECTED_LENGTH = 50; export function _defaultState() { return { prompt: '', - answers: [{ id: generateRandomSlug('answer'), value: '', caseSensitive: false }], + answers: [newAnswer()], expectedLength: DEFAULT_EXPECTED_LENGTH, }; } @@ -145,12 +158,7 @@ function extractNumericAnswers(declarationEl) { const mapKeys = [...declarationEl.querySelectorAll(':scope > qti-mapping qti-map-entry')] .filter(entry => parseFloat(entry.getAttribute('mapped-value')) >= 1) .map(entry => (entry.getAttribute('map-key') ?? '').trim()); - const toAnswers = answerValues => - answerValues.map(value => ({ - id: generateRandomSlug('answer'), - value, - caseSensitive: false, - })); + const toAnswers = answerValues => answerValues.map(value => newAnswer({ value })); // The editor writes its first answer as the correct response and maps every answer, // so its map-keys are the answers as authored, repeats included. if (values.length === 1 && values[0] === mapKeys[0]) { @@ -202,11 +210,7 @@ export function _extractAnswers(responseDeclarations) { return []; } - const toAnswer = (value, caseSensitive) => ({ - id: generateRandomSlug('answer'), - value, - caseSensitive, - }); + const toAnswer = (value, caseSensitive) => newAnswer({ value, caseSensitive }); const mapEntries = declaration.mapping?.entries ?? []; // Key on the XML string form: both map-key and correct-response values are coerced // on parse (empty → null under QTI NULL semantics), so formatting both back matches From 29cba53bc9b5459dfb5b2880583e92af388016f9 Mon Sep 17 00:00:00 2001 From: Alex Velez Date: Tue, 13 Oct 2026 10:23:22 -0500 Subject: [PATCH 3/6] Focus a question's editor only when its card opens Each interaction editor opened and focused its question as soon as it mounted in edit mode. That suited a card being opened, but any other mount took focus too: an editor remounted for a new question type pulled focus away from the type selector the author was using. Focus is now asked for, not implied. An editor exposes focus(), which opens its question for editing and so focuses it, and the card calls it, through InteractionSection, when it opens or is created open: the moments it already scrolls itself into view. An editor that only mounts leaves its question closed and focus where it was. The card asks after its section has re-rendered, so an editor that opens its question only in edit mode, as associate and match do, has heard of the mode change by then. TipTapEditor, switched into edit mode before it is ready, now focuses once it is. Its content only joins the page then, and a focus before that was lost: a new question's editor opened its question at once, but the question took no focus. Editors used to be created with autofocus already set, which TipTap applies once ready, so nothing hit this before. A test over the editor registry checks that every editor focuses its question when asked, so a new one can't miss it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/InteractionSection/index.vue | 18 +++++- .../__tests__/QTIItemEditor.spec.js | 32 ++++++++++- .../components/QTIItemEditor/index.vue | 15 ++++- .../interactions/__tests__/editors.spec.js | 57 +++++++++++++++++++ .../interactions/__tests__/renderFocused.js | 21 +++++++ .../interactions/associate/Editor.vue | 6 +- .../associate/__tests__/Editor.spec.js | 28 ++++++--- .../QTIEditor/interactions/choice/Editor.vue | 8 +-- .../choice/__tests__/Editor.spec.js | 30 +++++++--- .../interactions/inlineChoice/Editor.vue | 8 +-- .../inlineChoice/__tests__/Editor.spec.js | 22 ++++++- .../QTIEditor/interactions/match/Editor.vue | 6 +- .../match/__tests__/Editor.spec.js | 28 ++++++--- .../interactions/ordering/Editor.vue | 5 +- .../ordering/__tests__/Editor.spec.js | 33 ++++++++--- .../interactions/textEntry/Editor.vue | 5 +- .../textEntry/__tests__/Editor.spec.js | 33 ++++++++--- .../TipTapEditor/TipTapEditor.vue | 41 +++++++++++-- .../__tests__/TipTapEditor.spec.js | 54 ++++++++++++++++++ 19 files changed, 381 insertions(+), 69 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/editors.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/renderFocused.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue index 81a97ccf4a..f1479853ce 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue @@ -18,6 +18,7 @@ - import { computed, watch } from 'vue'; + import { computed, nextTick, ref, watch } from 'vue'; import useInteractionDescriptor from '../../composables/useInteractionDescriptor'; import QuestionTypeSelector from '../QuestionTypeSelector/index.vue'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; @@ -86,7 +87,22 @@ const editorComponent = computed(() => editors[descriptor.value.type]); + const editor = ref(null); + + /** + * Opens the question for editing, which focuses it. After this section's next render, so + * the editor has heard of a mode change the card made just before asking. + */ + async function focus() { + await nextTick(); + editor.value?.focus(); + } + return { + editor, + // Public: lets the card focus the question when it opens. + // eslint-disable-next-line vue/no-unused-properties + focus, descriptor, editorComponent, questionType, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index e8bb4eaf37..b5a4b5ef8c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/vue'; +import { render, screen, fireEvent, waitFor } from '@testing-library/vue'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; import QTIItemEditor from '../index.vue'; @@ -212,6 +212,34 @@ describe('QTIItemEditor', () => { }); }); + describe('focusing an opened card', () => { + // The mock rich text editor renders a