diff --git a/packages/categorize/configure/src/defaults.js b/packages/categorize/configure/src/defaults.js index 6fccb47ce4..1d3605df2c 100644 --- a/packages/categorize/configure/src/defaults.js +++ b/packages/categorize/configure/src/defaults.js @@ -1,10 +1,7 @@ -import { multiplePlacements } from './utils'; - export default { model: { allowAlternateEnabled: true, allowMaxChoicesPerCategory: false, - allowMultiplePlacementsEnabled: multiplePlacements.disabled, alternates: [], categories: [], categoriesPerRow: 2, diff --git a/packages/categorize/configure/src/design/index.jsx b/packages/categorize/configure/src/design/index.jsx index 89226d18e4..4d83692edd 100644 --- a/packages/categorize/configure/src/design/index.jsx +++ b/packages/categorize/configure/src/design/index.jsx @@ -538,10 +538,19 @@ export class Design extends React.Component { model.correctResponse || [], ); + const resolvedAllowMultiplePlacements = (() => { + if (model.allowMultiplePlacementsEnabled != null) { + return model.allowMultiplePlacementsEnabled; + } + const allChoiceIds = (model.correctResponse || []).flatMap((cr) => cr.choices || []); + const isExclusive = allChoiceIds.length === new Set(allChoiceIds).size; + return isExclusive ? multiplePlacements.disabled : multiplePlacements.enabled; + })(); + const choices = model.choices.map((c) => { c.correctResponseCount = this.countChoiceInCorrectResponse(c); // ensure categoryCount is set even though updatedModel hasn't been called - c.categoryCount = this.checkAllowMultiplePlacements(model.allowMultiplePlacementsEnabled, c); + c.categoryCount = this.checkAllowMultiplePlacements(resolvedAllowMultiplePlacements, c); return c; }); @@ -612,7 +621,7 @@ export class Design extends React.Component { hideSettings={settingsPanelDisabled} settings={ { }); }); + describe('allowMultiplePlacementsEnabled / categoryCount', () => { + const exclusiveCorrectResponse = [ + { category: '1', choices: ['1', '2'] }, + { category: '2', choices: ['3', '4'] }, + ]; + const nonExclusiveCorrectResponse = [ + { category: '1', choices: ['1', '2'] }, + { category: '2', choices: ['2', '3'] }, // '2' appears in both → reuse required + ]; + + it('explicit "Yes" → categoryCount 0 for all choices', async () => { + const q = makeQuestion({ allowMultiplePlacementsEnabled: 'Yes', correctResponse: exclusiveCorrectResponse }); + const result = await model(q, {}, { mode: 'gather' }, jest.fn()); + expect(result.choices.every((c) => c.categoryCount === 0)).toBe(true); + }); + + it('explicit "No" → categoryCount 1 for all choices', async () => { + const q = makeQuestion({ allowMultiplePlacementsEnabled: 'No', correctResponse: nonExclusiveCorrectResponse }); + const result = await model(q, {}, { mode: 'gather' }, jest.fn()); + expect(result.choices.every((c) => c.categoryCount === 1)).toBe(true); + }); + + it('no property + exclusive correct response → derived "No" → categoryCount 1', async () => { + const q = makeQuestion({ correctResponse: exclusiveCorrectResponse }); + const result = await model(q, {}, { mode: 'gather' }, jest.fn()); + expect(result.choices.every((c) => c.categoryCount === 1)).toBe(true); + }); + + it('no property + non-exclusive correct response → derived "Yes" → categoryCount 0', async () => { + const q = makeQuestion({ correctResponse: nonExclusiveCorrectResponse }); + const result = await model(q, {}, { mode: 'gather' }, jest.fn()); + expect(result.choices.every((c) => c.categoryCount === 0)).toBe(true); + }); + + it('no property + exclusive → sets allowMultiplePlacementsEnabled on out', async () => { + const q = makeQuestion({ correctResponse: exclusiveCorrectResponse }); + const result = await model(q, {}, { mode: 'gather' }, jest.fn()); + expect(result.allowMultiplePlacementsEnabled).toBe('No'); + }); + + it('no property + non-exclusive → sets allowMultiplePlacementsEnabled on out', async () => { + const q = makeQuestion({ correctResponse: nonExclusiveCorrectResponse }); + const result = await model(q, {}, { mode: 'gather' }, jest.fn()); + expect(result.allowMultiplePlacementsEnabled).toBe('Yes'); + }); + + it('explicit property → does not set allowMultiplePlacementsEnabled on out', async () => { + const q = makeQuestion({ allowMultiplePlacementsEnabled: 'No', correctResponse: exclusiveCorrectResponse }); + const result = await model(q, {}, { mode: 'gather' }, jest.fn()); + expect(result.allowMultiplePlacementsEnabled).toBeUndefined(); + }); + + it('perChoice → uses categoryCount from each choice', async () => { + const q = makeQuestion({ + allowMultiplePlacementsEnabled: 'Set Per Choice', + choices: [ + { id: '1', content: 'Foo', categoryCount: 0 }, + { id: '2', content: 'Bar', categoryCount: 1 }, + ], + correctResponse: exclusiveCorrectResponse, + }); + const result = await model(q, {}, { mode: 'gather' }, jest.fn()); + expect(result.choices.find((c) => c.id === '1').categoryCount).toBe(0); + expect(result.choices.find((c) => c.id === '2').categoryCount).toBe(1); + }); + }); + describe('correct response', () => { it('returns correct response if env is correct', async () => { const sess = await createCorrectResponseSession(question, { diff --git a/packages/categorize/controller/src/defaults.js b/packages/categorize/controller/src/defaults.js index 852b9172d1..c6b4388762 100644 --- a/packages/categorize/controller/src/defaults.js +++ b/packages/categorize/controller/src/defaults.js @@ -1,7 +1,4 @@ -import { multiplePlacements } from './utils'; - export default { - allowMultiplePlacementsEnabled: multiplePlacements.disabled, alternates: [], categories: [], categoriesPerRow: 2, diff --git a/packages/categorize/controller/src/index.js b/packages/categorize/controller/src/index.js index 8cac3f3a07..87ff961add 100644 --- a/packages/categorize/controller/src/index.js +++ b/packages/categorize/controller/src/index.js @@ -154,13 +154,24 @@ export const model = async (question, session, env, updateSession) => { choices = await getShuffledChoices(choices, session, updateSession, 'id'); } + const resolvedAllowMultiple = (() => { + if (question.allowMultiplePlacementsEnabled != null) { + return question.allowMultiplePlacementsEnabled; + } + // Derive from correct response: if any choice id appears in more than one category, reuse is required + const allChoiceIds = (correctResponse || []).flatMap((cr) => cr.choices || []); + const isExclusive = allChoiceIds.length === new Set(allChoiceIds).size; + return isExclusive ? multiplePlacements.disabled : multiplePlacements.enabled; + })(); + choices = (choices || []).map((c) => { let categoryCount; - if (normalizedQuestion.allowMultiplePlacementsEnabled === multiplePlacements.enabled) { + if (resolvedAllowMultiple === multiplePlacements.enabled) { categoryCount = 0; - } else if (normalizedQuestion.allowMultiplePlacementsEnabled === multiplePlacements.disabled) { + } else if (resolvedAllowMultiple === multiplePlacements.disabled) { categoryCount = 1; } else { + // perChoice — use the value set on each choice individually categoryCount = c.categoryCount || 0; } return { ...c, categoryCount }; @@ -203,6 +214,7 @@ export const model = async (question, session, env, updateSession) => { possibleResponses, responseAreasToBeFilled, hasUnplacedChoices, + ...(question.allowMultiplePlacementsEnabled == null && { allowMultiplePlacementsEnabled: resolvedAllowMultiple }), }; if (role === 'instructor' && (mode === 'view' || mode === 'evaluate')) { @@ -276,7 +288,7 @@ export const getLogTrace = (model, session, env) => { } if (hasAlternates) { - traceLog.push(`Alternate response combinations are accepted for this question.`); + traceLog.push('Alternate response combinations are accepted for this question.'); } if (hasAlternates) {