@pie-element/mc-populated-blank 0.2.10 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +0 -2
  2. package/dist/author/index.js +186 -152
  3. package/dist/author/index.js.map +1 -1
  4. package/dist/controller/defaults.d.ts +41 -73
  5. package/dist/controller/defaults.d.ts.map +1 -1
  6. package/dist/controller/index.d.ts +154 -10
  7. package/dist/controller/index.d.ts.map +1 -1
  8. package/dist/controller/index.js +29 -30
  9. package/dist/controller/index.js.map +1 -1
  10. package/dist/controller/index.test.d.ts +63 -0
  11. package/dist/controller/index.test.d.ts.map +1 -0
  12. package/dist/delivery/AudioPlayer.test.d.ts +2 -0
  13. package/dist/delivery/AudioPlayer.test.d.ts.map +1 -0
  14. package/dist/delivery/ChoiceRow.test.d.ts +2 -0
  15. package/dist/delivery/ChoiceRow.test.d.ts.map +1 -0
  16. package/dist/delivery/ClozeMarker.test.d.ts +2 -0
  17. package/dist/delivery/ClozeMarker.test.d.ts.map +1 -0
  18. package/dist/delivery/McPopulatedBlank.transcript.test.d.ts +2 -0
  19. package/dist/delivery/McPopulatedBlank.transcript.test.d.ts.map +1 -0
  20. package/dist/delivery/computeAudioMode.d.ts +7 -0
  21. package/dist/delivery/computeAudioMode.d.ts.map +1 -0
  22. package/dist/delivery/computeAudioMode.test.d.ts +2 -0
  23. package/dist/delivery/computeAudioMode.test.d.ts.map +1 -0
  24. package/dist/delivery/computeChoiceCorrectness.d.ts +18 -0
  25. package/dist/delivery/computeChoiceCorrectness.d.ts.map +1 -0
  26. package/dist/delivery/computeChoiceCorrectness.test.d.ts +19 -0
  27. package/dist/delivery/computeChoiceCorrectness.test.d.ts.map +1 -0
  28. package/dist/delivery/computeDisplayState.d.ts +47 -0
  29. package/dist/delivery/computeDisplayState.d.ts.map +1 -0
  30. package/dist/delivery/computeDisplayState.test.d.ts +38 -0
  31. package/dist/delivery/computeDisplayState.test.d.ts.map +1 -0
  32. package/dist/delivery/computeLayoutProfile.d.ts +18 -0
  33. package/dist/delivery/computeLayoutProfile.d.ts.map +1 -0
  34. package/dist/delivery/computeLayoutProfile.test.d.ts +2 -0
  35. package/dist/delivery/computeLayoutProfile.test.d.ts.map +1 -0
  36. package/dist/delivery/computeLayoutStyle.d.ts +27 -0
  37. package/dist/delivery/computeLayoutStyle.d.ts.map +1 -0
  38. package/dist/delivery/computeLayoutStyle.test.d.ts +2 -0
  39. package/dist/delivery/computeLayoutStyle.test.d.ts.map +1 -0
  40. package/dist/delivery/index.d.ts.map +1 -1
  41. package/dist/delivery/index.js +1591 -927
  42. package/dist/delivery/index.js.map +1 -1
  43. package/dist/delivery/variant-css-map.d.ts.map +1 -1
  44. package/dist/index.iife.d.ts +151 -10
  45. package/dist/index.iife.d.ts.map +1 -1
  46. package/dist/index.iife.js +233 -114
  47. package/dist/index.js +1591 -927
  48. package/dist/index.js.map +1 -1
  49. package/dist/print/index.js +194 -166
  50. package/dist/print/index.js.map +1 -1
  51. package/dist/shared/layoutLimits.d.ts +40 -0
  52. package/dist/shared/layoutLimits.d.ts.map +1 -0
  53. package/dist/shared/types.d.ts +82 -0
  54. package/dist/shared/types.d.ts.map +1 -0
  55. package/package.json +4 -2
  56. package/src/controller/defaults.ts +9 -39
  57. package/src/controller/index.test.ts +428 -0
  58. package/src/controller/index.ts +63 -50
  59. package/src/delivery/AudioPlayer.svelte +246 -0
  60. package/src/delivery/AudioPlayer.test.ts +149 -0
  61. package/src/delivery/ChoiceRow.svelte +222 -0
  62. package/src/delivery/ChoiceRow.test.ts +179 -0
  63. package/src/delivery/ClozeMarker.svelte +83 -0
  64. package/src/delivery/ClozeMarker.test.ts +128 -0
  65. package/src/delivery/McPopulatedBlank.svelte +225 -732
  66. package/src/delivery/McPopulatedBlank.transcript.test.ts +154 -0
  67. package/src/delivery/computeAudioMode.test.ts +40 -0
  68. package/src/delivery/computeAudioMode.ts +11 -0
  69. package/src/delivery/computeChoiceCorrectness.test.ts +120 -0
  70. package/src/delivery/computeChoiceCorrectness.ts +42 -0
  71. package/src/delivery/computeDisplayState.test.ts +274 -0
  72. package/src/delivery/computeDisplayState.ts +101 -0
  73. package/src/delivery/computeLayoutProfile.test.ts +163 -0
  74. package/src/delivery/computeLayoutProfile.ts +72 -0
  75. package/src/delivery/computeLayoutStyle.test.ts +168 -0
  76. package/src/delivery/computeLayoutStyle.ts +128 -0
  77. package/src/delivery/cqt-css/sel-r1-base.css +59 -0
  78. package/src/delivery/cqt-css/sel-r1-g-stem.css +5 -20
  79. package/src/delivery/cqt-css/sel-r1-gg-plus.css +3 -20
  80. package/src/delivery/cqt-css/sel-r1-ggplus.css +4 -21
  81. package/src/delivery/cqt-css/sel-r1-gplusggg.css +16 -11
  82. package/src/delivery/cqt-css/sel-r1-plusggg.css +8 -11
  83. package/src/delivery/cqt-css/sel-r1-s3.css +3 -9
  84. package/src/delivery/cqt-css/sel-vic.css +61 -0
  85. package/src/delivery/cqt-css/sr-vic.css +49 -0
  86. package/src/delivery/index.ts +14 -23
  87. package/src/delivery/variant-css-map.ts +26 -12
  88. package/src/shared/layoutLimits.ts +76 -0
  89. package/src/shared/types.ts +84 -0
@@ -0,0 +1,40 @@
1
+ export type LayoutLimits = {
2
+ blankStandaloneWidthRem: number;
3
+ blankWideWidthRem: number;
4
+ blankUnderlineWidthPx: number;
5
+ blankUnderlineWideWidthPx: number;
6
+ horizontalChoiceWidthPx: number;
7
+ horizontalChoiceWidthVw: number;
8
+ horizontalChoiceTileMinHeightRem: number;
9
+ horizontalChoiceContentMinHeightRem: number;
10
+ selectedImageMaxHeightRem: number;
11
+ choiceImageMaxHeightRem: number;
12
+ listenButtonSizePx: number;
13
+ stimulusMinColumnPx: number;
14
+ textMinColumnPx: number;
15
+ legendMaxChars: number;
16
+ choiceGroupGapRem: number;
17
+ choiceRowGapRem: number;
18
+ toggleButtonGapRem: number;
19
+ horizontalChoiceRadioTopMarginRem: number;
20
+ audioBlankTemplateMarginTopRem: number;
21
+ audioBlankTemplateMarginBottomRem: number;
22
+ audioInstructionsMaxWidthPx: number;
23
+ narrowHorizontalChoiceMaxWidthPx: number;
24
+ stimulusGridColumnGapRem: number;
25
+ stimulusGridRowGapRem: number;
26
+ stimulusSentenceMarginTopRem: number;
27
+ stimulusChoicesMarginTopRem: number;
28
+ tokenGridColumnGapRem: number;
29
+ tokenGridRowGapRem: number;
30
+ tokenTemplateMarginTopRem: number;
31
+ tokenInlineTokenGapRem: number;
32
+ tokenChoicesMarginTopRem: number;
33
+ inlineGridColumnGapRem: number;
34
+ inlineGridRowGapRem: number;
35
+ inlineTemplateMarginTopRem: number;
36
+ inlineChoicesMarginTopRem: number;
37
+ };
38
+ /** Baseline values tuned to match historical CQT visuals; override via model.layoutLimits. */
39
+ export declare const DEFAULT_LAYOUT_LIMITS: LayoutLimits;
40
+ //# sourceMappingURL=layoutLimits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layoutLimits.d.ts","sourceRoot":"","sources":["../../src/shared/layoutLimits.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG;IACzB,uBAAuB,EAAE,MAAM,CAAC;IAChC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,yBAAyB,EAAE,MAAM,CAAC;IAClC,uBAAuB,EAAE,MAAM,CAAC;IAChC,uBAAuB,EAAE,MAAM,CAAC;IAChC,gCAAgC,EAAE,MAAM,CAAC;IACzC,mCAAmC,EAAE,MAAM,CAAC;IAC5C,yBAAyB,EAAE,MAAM,CAAC;IAClC,uBAAuB,EAAE,MAAM,CAAC;IAChC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iCAAiC,EAAE,MAAM,CAAC;IAC1C,8BAA8B,EAAE,MAAM,CAAC;IACvC,iCAAiC,EAAE,MAAM,CAAC;IAC1C,2BAA2B,EAAE,MAAM,CAAC;IACpC,gCAAgC,EAAE,MAAM,CAAC;IACzC,wBAAwB,EAAE,MAAM,CAAC;IACjC,qBAAqB,EAAE,MAAM,CAAC;IAC9B,4BAA4B,EAAE,MAAM,CAAC;IACrC,2BAA2B,EAAE,MAAM,CAAC;IACpC,qBAAqB,EAAE,MAAM,CAAC;IAC9B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,yBAAyB,EAAE,MAAM,CAAC;IAClC,sBAAsB,EAAE,MAAM,CAAC;IAC/B,wBAAwB,EAAE,MAAM,CAAC;IACjC,sBAAsB,EAAE,MAAM,CAAC;IAC/B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,0BAA0B,EAAE,MAAM,CAAC;IACnC,yBAAyB,EAAE,MAAM,CAAC;CACnC,CAAC;AAEF,8FAA8F;AAC9F,eAAO,MAAM,qBAAqB,EAAE,YAoCnC,CAAC"}
@@ -0,0 +1,82 @@
1
+ import type { LayoutLimits } from './layoutLimits';
2
+ export type McpbChoiceMode = 'text' | 'image';
3
+ export type McpbInteractionMode = 'populate_blank' | 'audio_mc_only';
4
+ export type McpbDeliveryMode = 'gather' | 'view' | 'evaluate' | 'authoring' | 'print';
5
+ export type McpbRole = 'student' | 'instructor';
6
+ export type McpbCorrectness = 'correct' | 'incorrect' | 'unanswered';
7
+ export interface McpbChoice {
8
+ id: string;
9
+ labelHtml?: string;
10
+ imageUrl?: string;
11
+ imageAlt?: string;
12
+ }
13
+ export interface McpbAudioButtonSkin {
14
+ silentUrl: string;
15
+ playingUrl: string;
16
+ }
17
+ export interface McpbUiText {
18
+ answerChoices?: string;
19
+ selectedAnswerInSentence?: string;
20
+ showCorrectAnswer?: string;
21
+ hideCorrectAnswer?: string;
22
+ clickToEnableAutoplay?: string;
23
+ audioResourceUnavailable?: string;
24
+ transcriptLabel?: string;
25
+ listenLabelEn?: string;
26
+ listenLabelEs?: string;
27
+ listenSilentAlt?: string;
28
+ listenPlayingAlt?: string;
29
+ listenSilentAltEs?: string;
30
+ listenPlayingAltEs?: string;
31
+ }
32
+ export interface McpbQuestion {
33
+ id?: string;
34
+ element?: string;
35
+ prompt?: string;
36
+ promptEnabled?: boolean;
37
+ interactionMode?: McpbInteractionMode;
38
+ layoutProfile?: string;
39
+ choiceLayout?: string;
40
+ layoutProfilePresets?: Record<string, Partial<LayoutLimits>>;
41
+ layoutLimits?: Partial<LayoutLimits>;
42
+ audioButtonSkin?: McpbAudioButtonSkin | null;
43
+ audioButtonSkinsByLocale?: Record<string, McpbAudioButtonSkin>;
44
+ uiText?: McpbUiText;
45
+ sentenceHtml?: string;
46
+ template?: string;
47
+ choiceMode?: McpbChoiceMode;
48
+ choices?: McpbChoice[];
49
+ correctChoiceId?: string;
50
+ hasAudio?: boolean;
51
+ autoplayAudioEnabled?: boolean;
52
+ completeAudioEnabled?: boolean;
53
+ audioUrl?: string;
54
+ audioTranscript?: string;
55
+ showVisibleTranscript?: boolean;
56
+ useFeatureButtonAudio?: boolean;
57
+ locale?: string;
58
+ shuffle?: boolean;
59
+ lockChoiceOrder?: boolean;
60
+ teacherInstructions?: string;
61
+ teacherInstructionsEnabled?: boolean;
62
+ customType?: string;
63
+ }
64
+ export interface McpbSession {
65
+ id?: string;
66
+ element?: string;
67
+ choiceId?: string;
68
+ audioStartTime?: number;
69
+ audioEndTime?: number;
70
+ shuffledValues?: string[];
71
+ data?: {
72
+ shuffledValues?: string[];
73
+ };
74
+ }
75
+ export interface McpbEnv {
76
+ mode?: McpbDeliveryMode;
77
+ role?: McpbRole;
78
+ '@pie-element'?: {
79
+ lockChoiceOrder?: boolean;
80
+ };
81
+ }
82
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/shared/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-element/mc-populated-blank",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "Multiple-choice option populates an inline blank in a sentence template (Svelte 5 PIE element)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -47,6 +47,7 @@
47
47
  "clean": "rm -rf dist tsconfig.tsbuildinfo"
48
48
  },
49
49
  "dependencies": {
50
+ "@pie-lib/delivery-events-svelte": "0.1.0",
50
51
  "@pie-lib/styling-svelte": "0.1.2"
51
52
  },
52
53
  "peerDependencies": {
@@ -56,6 +57,7 @@
56
57
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
57
58
  "svelte": "^5.54.0",
58
59
  "typescript": "^5.9.3",
59
- "vite": "^8.0.1"
60
+ "vite": "^8.0.1",
61
+ "vitest": "^4.1.0"
60
62
  }
61
63
  }
@@ -1,43 +1,9 @@
1
+ import { DEFAULT_LAYOUT_LIMITS } from '../shared/layoutLimits';
2
+
3
+ export { DEFAULT_LAYOUT_LIMITS };
4
+
1
5
  /** Token authors must include exactly once in `template` (HTML). */
2
6
  export const BLANK_TOKEN = '{{blank}}';
3
- /** Baseline values tuned to match historical CQT visuals; override via model.layoutLimits. */
4
- export const DEFAULT_LAYOUT_LIMITS = {
5
- blankStandaloneWidthRem: 7,
6
- blankWideWidthRem: 10,
7
- blankUnderlineWidthPx: 2,
8
- blankUnderlineWideWidthPx: 4,
9
- horizontalChoiceWidthPx: 170,
10
- horizontalChoiceWidthVw: 30,
11
- horizontalChoiceTileMinHeightRem: 11,
12
- horizontalChoiceContentMinHeightRem: 7.5,
13
- selectedImageMaxHeightRem: 4,
14
- choiceImageMaxHeightRem: 5,
15
- listenButtonSizePx: 128,
16
- stimulusMinColumnPx: 210,
17
- textMinColumnPx: 260,
18
- legendMaxChars: 120,
19
- choiceGroupGapRem: 0.5,
20
- choiceRowGapRem: 0.5,
21
- toggleButtonGapRem: 0.5,
22
- horizontalChoiceRadioTopMarginRem: 0.5,
23
- audioBlankTemplateMarginTopRem: 0.8,
24
- audioBlankTemplateMarginBottomRem: 1.8,
25
- audioInstructionsMaxWidthPx: 875,
26
- narrowHorizontalChoiceMaxWidthPx: 230,
27
- stimulusGridColumnGapRem: 2,
28
- stimulusGridRowGapRem: 0.7,
29
- stimulusSentenceMarginTopRem: 0.2,
30
- stimulusChoicesMarginTopRem: 0.9,
31
- tokenGridColumnGapRem: 1.5,
32
- tokenGridRowGapRem: 0.8,
33
- tokenTemplateMarginTopRem: 0.6,
34
- tokenInlineTokenGapRem: 0.35,
35
- tokenChoicesMarginTopRem: 0.2,
36
- inlineGridColumnGapRem: 1.5,
37
- inlineGridRowGapRem: 0.65,
38
- inlineTemplateMarginTopRem: 0.45,
39
- inlineChoicesMarginTopRem: 0.25,
40
- } as const;
41
7
 
42
8
  export default {
43
9
  model: {
@@ -54,7 +20,7 @@ export default {
54
20
  audioButtonSkinsByLocale: {},
55
21
  uiText: {
56
22
  answerChoices: 'Answer choices',
57
- selectedAnswerInSentence: 'Selected answer in sentence',
23
+ selectedAnswerInSentence: 'blank',
58
24
  showCorrectAnswer: 'Show correct answer',
59
25
  hideCorrectAnswer: 'Hide correct answer',
60
26
  clickToEnableAutoplay: 'Click to enable audio autoplay',
@@ -62,6 +28,10 @@ export default {
62
28
  transcriptLabel: 'Transcript',
63
29
  listenLabelEn: 'Listen',
64
30
  listenLabelEs: 'Escuchar',
31
+ listenSilentAlt: 'Repeat instructions',
32
+ listenPlayingAlt: 'Instructions are playing',
33
+ listenSilentAltEs: 'Escuchar. Repetir las instrucciones.',
34
+ listenPlayingAltEs: 'Escuchar. Estas son las instrucciones.',
65
35
  },
66
36
  sentenceHtml: '',
67
37
  template: `<p>The answer is ${BLANK_TOKEN}.</p>`,
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Unit tests for the McPopulatedBlank controller.
3
+ *
4
+ * Cases are grouped by exported function.
5
+ *
6
+ * getCorrectness
7
+ * G1. No session → unanswered
8
+ * G2. No choiceId → unanswered
9
+ * G3. Correct choice selected → correct
10
+ * G4. Wrong choice selected → incorrect
11
+ *
12
+ * outcome
13
+ * O1. Empty session → score 0, empty: true
14
+ * O2. No choiceId → score 0, empty: true
15
+ * O3. Correct choice → score 1, empty: false
16
+ * O4. Wrong choice → score 0, empty: false
17
+ * O5. traceLog includes mode, selected id, correct id, final score
18
+ *
19
+ * model — choice ordering
20
+ * M1. shuffle=false → original order preserved
21
+ * M2. shuffle=true, instructor role → original order locked
22
+ * M3. shuffle=true, lockChoiceOrder=true → original order locked
23
+ * M4. shuffle=true, stored shuffle in session.data.shuffledValues → restored
24
+ * M5. shuffle=true, stored shuffle in session.shuffledValues (legacy) → restored
25
+ * M6. shuffle=true, stored shuffle has unknown ids → extras appended at end
26
+ * M7. shuffle=true, no stored shuffle → shuffled; updateSession called with new order
27
+ * M8. shuffle=true, no stored shuffle, no updateSession → shuffled silently (no throw)
28
+ *
29
+ * model — output fields
30
+ * M9. evaluate mode → correctness, responseCorrect, correctChoiceId present
31
+ * M10. gather mode → correctness fields absent
32
+ * M11. instructor + view mode → teacherInstructions included when enabled
33
+ * M12. student role → teacherInstructions always null
34
+ * M13. prompt included when promptEnabled=true
35
+ * M14. prompt null when promptEnabled=false
36
+ * M15. audioUrl null when hasAudio=false
37
+ * M16. audioUrl passed through when hasAudio=true
38
+ * M17. disabled=true in view/evaluate mode, false in gather
39
+ *
40
+ * validate
41
+ * V1. Valid question → empty errors object
42
+ * V2. promptEnabled + empty prompt → prompt error
43
+ * V3. populate_blank with no blank token → template error
44
+ * V4. populate_blank with two blank tokens → template error
45
+ * V5. audio_mc_only with a blank token → template error
46
+ * V6. Unknown interactionMode → interactionMode error
47
+ * V7. Fewer than two choices → choices error
48
+ * V8. Choice missing id → choices error
49
+ * V9. Text mode choice missing labelHtml → choices error
50
+ * V10. Image mode choice missing imageUrl → choices error
51
+ * V11. Image mode choice missing imageAlt → choices error
52
+ * V12. correctChoiceId not in choices → correctChoiceId error
53
+ * V13. hasAudio=true with no audioUrl → audioUrl error
54
+ * V14. layoutLimits with a non-positive value → layoutLimits error
55
+ * V15. layoutLimits with a valid value → no error
56
+ *
57
+ * createCorrectResponseSession
58
+ * CR1. Instructor + mode≠evaluate → session with correct choiceId
59
+ * CR2. Student role → null
60
+ * CR3. Evaluate mode → null
61
+ */
62
+
63
+ import { describe, expect, it, vi } from 'vitest';
64
+ import { getCorrectness, outcome, model, validate, createCorrectResponseSession } from './index';
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Shared fixtures
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const CHOICES = [
71
+ { id: 'a', labelHtml: '<p>Alpha</p>' },
72
+ { id: 'b', labelHtml: '<p>Beta</p>' },
73
+ { id: 'c', labelHtml: '<p>Gamma</p>' },
74
+ ];
75
+
76
+ const BASE_QUESTION = {
77
+ id: '1',
78
+ element: 'mc-populated-blank',
79
+ template: '<p>The answer is {{blank}}.</p>',
80
+ interactionMode: 'populate_blank' as const,
81
+ choiceMode: 'text' as const,
82
+ choices: CHOICES,
83
+ correctChoiceId: 'b',
84
+ hasAudio: false,
85
+ shuffle: false,
86
+ };
87
+
88
+ const GATHER_ENV = { mode: 'gather' as const, role: 'student' as const };
89
+ const EVALUATE_ENV = { mode: 'evaluate' as const, role: 'student' as const };
90
+ const INSTRUCTOR_VIEW_ENV = { mode: 'view' as const, role: 'instructor' as const };
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // getCorrectness
94
+ // ---------------------------------------------------------------------------
95
+
96
+ describe('getCorrectness', () => {
97
+ it('G1: no session → unanswered', () => {
98
+ expect(getCorrectness(BASE_QUESTION, null as any)).toBe('unanswered');
99
+ });
100
+
101
+ it('G2: session with no choiceId → unanswered', () => {
102
+ expect(getCorrectness(BASE_QUESTION, {})).toBe('unanswered');
103
+ });
104
+
105
+ it('G3: correct choice selected → correct', () => {
106
+ expect(getCorrectness(BASE_QUESTION, { choiceId: 'b' })).toBe('correct');
107
+ });
108
+
109
+ it('G4: wrong choice selected → incorrect', () => {
110
+ expect(getCorrectness(BASE_QUESTION, { choiceId: 'a' })).toBe('incorrect');
111
+ });
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // outcome
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe('outcome', () => {
119
+ it('O1: empty session → score 0, empty: true', async () => {
120
+ const result = await outcome(BASE_QUESTION, {}, GATHER_ENV);
121
+ expect(result).toMatchObject({ score: 0, empty: true });
122
+ });
123
+
124
+ it('O2: session with no choiceId → score 0, empty: true', async () => {
125
+ const result = await outcome(BASE_QUESTION, { choiceId: '' }, GATHER_ENV);
126
+ expect(result).toMatchObject({ score: 0, empty: true });
127
+ });
128
+
129
+ it('O3: correct choice → score 1, empty: false', async () => {
130
+ const result = await outcome(BASE_QUESTION, { choiceId: 'b' }, EVALUATE_ENV);
131
+ expect(result).toMatchObject({ score: 1, empty: false });
132
+ });
133
+
134
+ it('O4: wrong choice → score 0, empty: false', async () => {
135
+ const result = await outcome(BASE_QUESTION, { choiceId: 'a' }, EVALUATE_ENV);
136
+ expect(result).toMatchObject({ score: 0, empty: false });
137
+ });
138
+
139
+ it('O5: traceLog includes mode, selected id, correct id, final score', async () => {
140
+ const result = (await outcome(BASE_QUESTION, { choiceId: 'a' }, EVALUATE_ENV)) as any;
141
+ const log = result.traceLog.join('\n');
142
+ expect(log).toContain('evaluate');
143
+ expect(log).toContain('a');
144
+ expect(log).toContain('b');
145
+ expect(log).toContain('0');
146
+ });
147
+ });
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // model — choice ordering
151
+ // ---------------------------------------------------------------------------
152
+
153
+ describe('model — choice ordering', () => {
154
+ it('M1: shuffle=false → original order preserved', async () => {
155
+ const result = (await model({ ...BASE_QUESTION, shuffle: false }, {}, GATHER_ENV)) as any;
156
+ expect(result.choices.map((c: any) => c.id)).toEqual(['a', 'b', 'c']);
157
+ });
158
+
159
+ it('M2: shuffle=true, instructor role → original order locked', async () => {
160
+ const result = (await model(
161
+ { ...BASE_QUESTION, shuffle: true },
162
+ {},
163
+ { mode: 'view', role: 'instructor' }
164
+ )) as any;
165
+ expect(result.choices.map((c: any) => c.id)).toEqual(['a', 'b', 'c']);
166
+ });
167
+
168
+ it('M3: shuffle=true, lockChoiceOrder=true → original order locked', async () => {
169
+ const result = (await model(
170
+ { ...BASE_QUESTION, shuffle: true, lockChoiceOrder: true },
171
+ {},
172
+ GATHER_ENV
173
+ )) as any;
174
+ expect(result.choices.map((c: any) => c.id)).toEqual(['a', 'b', 'c']);
175
+ });
176
+
177
+ it('M4: shuffle=true, stored shuffle in session.data.shuffledValues → order restored', async () => {
178
+ const session = { data: { shuffledValues: ['c', 'a', 'b'] } };
179
+ const result = (await model({ ...BASE_QUESTION, shuffle: true }, session, GATHER_ENV)) as any;
180
+ expect(result.choices.map((c: any) => c.id)).toEqual(['c', 'a', 'b']);
181
+ });
182
+
183
+ it('M5: shuffle=true, stored shuffle in session.shuffledValues (legacy) → order restored', async () => {
184
+ const session = { shuffledValues: ['b', 'c', 'a'] };
185
+ const result = (await model({ ...BASE_QUESTION, shuffle: true }, session, GATHER_ENV)) as any;
186
+ expect(result.choices.map((c: any) => c.id)).toEqual(['b', 'c', 'a']);
187
+ });
188
+
189
+ it('M6: shuffle=true, stored shuffle has unknown id → known ids first, extras appended', async () => {
190
+ const session = { data: { shuffledValues: ['c', 'a', 'unknown'] } };
191
+ const result = (await model({ ...BASE_QUESTION, shuffle: true }, session, GATHER_ENV)) as any;
192
+ const ids = result.choices.map((c: any) => c.id);
193
+ expect(ids[0]).toBe('c');
194
+ expect(ids[1]).toBe('a');
195
+ expect(ids[2]).toBe('b'); // leftover appended
196
+ expect(ids).not.toContain('unknown');
197
+ });
198
+
199
+ it('M7: shuffle=true, no stored shuffle → shuffled; updateSession called with new order', async () => {
200
+ const session = { id: 'sess-1', element: 'mc-populated-blank' };
201
+ const updateSession = vi.fn().mockResolvedValue(undefined);
202
+ await model({ ...BASE_QUESTION, shuffle: true }, session, GATHER_ENV, updateSession);
203
+ expect(updateSession).toHaveBeenCalledOnce();
204
+ const [id, element, data] = updateSession.mock.calls[0];
205
+ expect(id).toBe('sess-1');
206
+ expect(element).toBe('mc-populated-blank');
207
+ expect(data.shuffledValues).toHaveLength(3);
208
+ expect(new Set(data.shuffledValues)).toEqual(new Set(['a', 'b', 'c']));
209
+ });
210
+
211
+ it('M8: shuffle=true, no stored shuffle, no updateSession → shuffled silently without throw', async () => {
212
+ const session = { id: 'sess-1', element: 'mc-populated-blank' };
213
+ const result = (await model({ ...BASE_QUESTION, shuffle: true }, session, GATHER_ENV)) as any;
214
+ expect(result.choices).toHaveLength(3);
215
+ });
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // model — output fields
220
+ // ---------------------------------------------------------------------------
221
+
222
+ describe('model — output fields', () => {
223
+ it('M9: evaluate mode → correctness, responseCorrect, correctChoiceId in output', async () => {
224
+ const result = await model(BASE_QUESTION, { choiceId: 'b' }, EVALUATE_ENV);
225
+ expect(result.correctness).toBe('correct');
226
+ expect(result.responseCorrect).toBe(true);
227
+ expect(result.correctChoiceId).toBe('b');
228
+ });
229
+
230
+ it('M10: gather mode → correctness fields absent', async () => {
231
+ const result = await model(BASE_QUESTION, { choiceId: 'b' }, GATHER_ENV);
232
+ expect(result.correctness).toBeUndefined();
233
+ expect(result.responseCorrect).toBeUndefined();
234
+ expect(result.correctChoiceId).toBeUndefined();
235
+ });
236
+
237
+ it('M11: instructor + view mode → teacherInstructions included when enabled', async () => {
238
+ const q = {
239
+ ...BASE_QUESTION,
240
+ teacherInstructions: 'Do this.',
241
+ teacherInstructionsEnabled: true,
242
+ };
243
+ const result = await model(q, {}, INSTRUCTOR_VIEW_ENV);
244
+ expect(result.teacherInstructions).toBe('Do this.');
245
+ });
246
+
247
+ it('M12: student role → teacherInstructions always null', async () => {
248
+ const q = {
249
+ ...BASE_QUESTION,
250
+ teacherInstructions: 'Do this.',
251
+ teacherInstructionsEnabled: true,
252
+ };
253
+ const result = await model(q, {}, GATHER_ENV);
254
+ expect(result.teacherInstructions).toBeNull();
255
+ });
256
+
257
+ it('M13: promptEnabled=true → prompt included', async () => {
258
+ const q = { ...BASE_QUESTION, promptEnabled: true, prompt: '<p>Hello</p>' };
259
+ const result = await model(q, {}, GATHER_ENV);
260
+ expect(result.prompt).toBe('<p>Hello</p>');
261
+ });
262
+
263
+ it('M14: promptEnabled=false → prompt null', async () => {
264
+ const q = { ...BASE_QUESTION, promptEnabled: false, prompt: '<p>Hello</p>' };
265
+ const result = await model(q, {}, GATHER_ENV);
266
+ expect(result.prompt).toBeNull();
267
+ });
268
+
269
+ it('M15: hasAudio=false → audioUrl null', async () => {
270
+ const q = { ...BASE_QUESTION, hasAudio: false, audioUrl: 'https://example.com/a.mp3' };
271
+ const result = await model(q, {}, GATHER_ENV);
272
+ expect(result.audioUrl).toBeNull();
273
+ });
274
+
275
+ it('M16: hasAudio=true → audioUrl passed through', async () => {
276
+ const q = { ...BASE_QUESTION, hasAudio: true, audioUrl: 'https://example.com/a.mp3' };
277
+ const result = await model(q, {}, GATHER_ENV);
278
+ expect(result.audioUrl).toBe('https://example.com/a.mp3');
279
+ });
280
+
281
+ it('M17: disabled=false in gather mode, disabled=true in view mode', async () => {
282
+ const gather = await model(BASE_QUESTION, {}, GATHER_ENV);
283
+ const view = await model(BASE_QUESTION, {}, { mode: 'view', role: 'student' });
284
+ expect(gather.disabled).toBe(false);
285
+ expect(view.disabled).toBe(true);
286
+ });
287
+ });
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // validate
291
+ // ---------------------------------------------------------------------------
292
+
293
+ describe('validate', () => {
294
+ it('V1: valid question → empty errors', () => {
295
+ expect(validate(BASE_QUESTION)).toEqual({});
296
+ });
297
+
298
+ it('V2: promptEnabled + empty prompt → prompt error', () => {
299
+ const errors = validate({ ...BASE_QUESTION, promptEnabled: true, prompt: '' });
300
+ expect(errors.prompt).toBeTruthy();
301
+ });
302
+
303
+ it('V3: populate_blank with no blank token → template error', () => {
304
+ const errors = validate({ ...BASE_QUESTION, template: '<p>No token here.</p>' });
305
+ expect(errors.template).toBeTruthy();
306
+ });
307
+
308
+ it('V4: populate_blank with two blank tokens → template error', () => {
309
+ const errors = validate({ ...BASE_QUESTION, template: '<p>{{blank}} and {{blank}}</p>' });
310
+ expect(errors.template).toBeTruthy();
311
+ });
312
+
313
+ it('V5: audio_mc_only with a blank token in template → template error', () => {
314
+ const errors = validate({
315
+ ...BASE_QUESTION,
316
+ interactionMode: 'audio_mc_only',
317
+ template: '<p>{{blank}}</p>',
318
+ });
319
+ expect(errors.template).toBeTruthy();
320
+ });
321
+
322
+ it('V6: unknown interactionMode → interactionMode error', () => {
323
+ const errors = validate({ ...BASE_QUESTION, interactionMode: 'unknown' as any });
324
+ expect(errors.interactionMode).toBeTruthy();
325
+ });
326
+
327
+ it('V7: fewer than two choices → choices error', () => {
328
+ const errors = validate({ ...BASE_QUESTION, choices: [CHOICES[0]] });
329
+ expect(errors.choices).toBeTruthy();
330
+ });
331
+
332
+ it('V8: choice missing id → choices error', () => {
333
+ const errors = validate({
334
+ ...BASE_QUESTION,
335
+ choices: [{ id: '', labelHtml: '<p>A</p>' }, CHOICES[1]],
336
+ });
337
+ expect(errors.choices).toBeTruthy();
338
+ });
339
+
340
+ it('V9: text mode choice missing labelHtml → choices error', () => {
341
+ const errors = validate({
342
+ ...BASE_QUESTION,
343
+ choiceMode: 'text',
344
+ choices: [{ id: 'a', labelHtml: '' }, CHOICES[1]],
345
+ });
346
+ expect(errors.choices).toBeTruthy();
347
+ });
348
+
349
+ it('V10: image mode choice missing imageUrl → choices error', () => {
350
+ const errors = validate({
351
+ ...BASE_QUESTION,
352
+ choiceMode: 'image',
353
+ choices: [
354
+ { id: 'a', imageUrl: '', imageAlt: 'Alt' },
355
+ { id: 'b', imageUrl: 'https://example.com/b.png', imageAlt: 'B' },
356
+ ],
357
+ });
358
+ expect(errors.choices).toBeTruthy();
359
+ });
360
+
361
+ it('V11: image mode choice missing imageAlt → choices error', () => {
362
+ const errors = validate({
363
+ ...BASE_QUESTION,
364
+ choiceMode: 'image',
365
+ choices: [
366
+ { id: 'a', imageUrl: 'https://example.com/a.png', imageAlt: '' },
367
+ { id: 'b', imageUrl: 'https://example.com/b.png', imageAlt: 'B' },
368
+ ],
369
+ });
370
+ expect(errors.choices).toBeTruthy();
371
+ });
372
+
373
+ it('V12: correctChoiceId not in choices → correctChoiceId error', () => {
374
+ const errors = validate({ ...BASE_QUESTION, correctChoiceId: 'z' });
375
+ expect(errors.correctChoiceId).toBeTruthy();
376
+ });
377
+
378
+ it('V13: hasAudio=true with no audioUrl → audioUrl error', () => {
379
+ const errors = validate({ ...BASE_QUESTION, hasAudio: true, audioUrl: '' });
380
+ expect(errors.audioUrl).toBeTruthy();
381
+ });
382
+
383
+ it('V14: layoutLimits with non-positive value → layoutLimits error', () => {
384
+ const errors = validate({
385
+ ...BASE_QUESTION,
386
+ layoutLimits: { blankStandaloneWidthRem: -1 } as any,
387
+ });
388
+ expect(errors.layoutLimits).toBeTruthy();
389
+ });
390
+
391
+ it('V15: layoutLimits with valid positive value → no error', () => {
392
+ const errors = validate({
393
+ ...BASE_QUESTION,
394
+ layoutLimits: { blankStandaloneWidthRem: 8 } as any,
395
+ });
396
+ expect(errors.layoutLimits).toBeUndefined();
397
+ });
398
+ });
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // createCorrectResponseSession
402
+ // ---------------------------------------------------------------------------
403
+
404
+ describe('createCorrectResponseSession', () => {
405
+ it('CR1: instructor + mode≠evaluate → session with correct choiceId', async () => {
406
+ const result = await createCorrectResponseSession(BASE_QUESTION, {
407
+ mode: 'view',
408
+ role: 'instructor',
409
+ });
410
+ expect(result).toMatchObject({ choiceId: 'b' });
411
+ });
412
+
413
+ it('CR2: student role → null', async () => {
414
+ const result = await createCorrectResponseSession(BASE_QUESTION, {
415
+ mode: 'view',
416
+ role: 'student',
417
+ });
418
+ expect(result).toBeNull();
419
+ });
420
+
421
+ it('CR3: evaluate mode (instructor) → null', async () => {
422
+ const result = await createCorrectResponseSession(BASE_QUESTION, {
423
+ mode: 'evaluate',
424
+ role: 'instructor',
425
+ });
426
+ expect(result).toBeNull();
427
+ });
428
+ });