@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.
- package/README.md +0 -2
- package/dist/author/index.js +186 -152
- package/dist/author/index.js.map +1 -1
- package/dist/controller/defaults.d.ts +41 -73
- package/dist/controller/defaults.d.ts.map +1 -1
- package/dist/controller/index.d.ts +154 -10
- package/dist/controller/index.d.ts.map +1 -1
- package/dist/controller/index.js +29 -30
- package/dist/controller/index.js.map +1 -1
- package/dist/controller/index.test.d.ts +63 -0
- package/dist/controller/index.test.d.ts.map +1 -0
- package/dist/delivery/AudioPlayer.test.d.ts +2 -0
- package/dist/delivery/AudioPlayer.test.d.ts.map +1 -0
- package/dist/delivery/ChoiceRow.test.d.ts +2 -0
- package/dist/delivery/ChoiceRow.test.d.ts.map +1 -0
- package/dist/delivery/ClozeMarker.test.d.ts +2 -0
- package/dist/delivery/ClozeMarker.test.d.ts.map +1 -0
- package/dist/delivery/McPopulatedBlank.transcript.test.d.ts +2 -0
- package/dist/delivery/McPopulatedBlank.transcript.test.d.ts.map +1 -0
- package/dist/delivery/computeAudioMode.d.ts +7 -0
- package/dist/delivery/computeAudioMode.d.ts.map +1 -0
- package/dist/delivery/computeAudioMode.test.d.ts +2 -0
- package/dist/delivery/computeAudioMode.test.d.ts.map +1 -0
- package/dist/delivery/computeChoiceCorrectness.d.ts +18 -0
- package/dist/delivery/computeChoiceCorrectness.d.ts.map +1 -0
- package/dist/delivery/computeChoiceCorrectness.test.d.ts +19 -0
- package/dist/delivery/computeChoiceCorrectness.test.d.ts.map +1 -0
- package/dist/delivery/computeDisplayState.d.ts +47 -0
- package/dist/delivery/computeDisplayState.d.ts.map +1 -0
- package/dist/delivery/computeDisplayState.test.d.ts +38 -0
- package/dist/delivery/computeDisplayState.test.d.ts.map +1 -0
- package/dist/delivery/computeLayoutProfile.d.ts +18 -0
- package/dist/delivery/computeLayoutProfile.d.ts.map +1 -0
- package/dist/delivery/computeLayoutProfile.test.d.ts +2 -0
- package/dist/delivery/computeLayoutProfile.test.d.ts.map +1 -0
- package/dist/delivery/computeLayoutStyle.d.ts +27 -0
- package/dist/delivery/computeLayoutStyle.d.ts.map +1 -0
- package/dist/delivery/computeLayoutStyle.test.d.ts +2 -0
- package/dist/delivery/computeLayoutStyle.test.d.ts.map +1 -0
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +1591 -927
- package/dist/delivery/index.js.map +1 -1
- package/dist/delivery/variant-css-map.d.ts.map +1 -1
- package/dist/index.iife.d.ts +151 -10
- package/dist/index.iife.d.ts.map +1 -1
- package/dist/index.iife.js +233 -114
- package/dist/index.js +1591 -927
- package/dist/index.js.map +1 -1
- package/dist/print/index.js +194 -166
- package/dist/print/index.js.map +1 -1
- package/dist/shared/layoutLimits.d.ts +40 -0
- package/dist/shared/layoutLimits.d.ts.map +1 -0
- package/dist/shared/types.d.ts +82 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/package.json +4 -2
- package/src/controller/defaults.ts +9 -39
- package/src/controller/index.test.ts +428 -0
- package/src/controller/index.ts +63 -50
- package/src/delivery/AudioPlayer.svelte +246 -0
- package/src/delivery/AudioPlayer.test.ts +149 -0
- package/src/delivery/ChoiceRow.svelte +222 -0
- package/src/delivery/ChoiceRow.test.ts +179 -0
- package/src/delivery/ClozeMarker.svelte +83 -0
- package/src/delivery/ClozeMarker.test.ts +128 -0
- package/src/delivery/McPopulatedBlank.svelte +225 -732
- package/src/delivery/McPopulatedBlank.transcript.test.ts +154 -0
- package/src/delivery/computeAudioMode.test.ts +40 -0
- package/src/delivery/computeAudioMode.ts +11 -0
- package/src/delivery/computeChoiceCorrectness.test.ts +120 -0
- package/src/delivery/computeChoiceCorrectness.ts +42 -0
- package/src/delivery/computeDisplayState.test.ts +274 -0
- package/src/delivery/computeDisplayState.ts +101 -0
- package/src/delivery/computeLayoutProfile.test.ts +163 -0
- package/src/delivery/computeLayoutProfile.ts +72 -0
- package/src/delivery/computeLayoutStyle.test.ts +168 -0
- package/src/delivery/computeLayoutStyle.ts +128 -0
- package/src/delivery/cqt-css/sel-r1-base.css +59 -0
- package/src/delivery/cqt-css/sel-r1-g-stem.css +5 -20
- package/src/delivery/cqt-css/sel-r1-gg-plus.css +3 -20
- package/src/delivery/cqt-css/sel-r1-ggplus.css +4 -21
- package/src/delivery/cqt-css/sel-r1-gplusggg.css +16 -11
- package/src/delivery/cqt-css/sel-r1-plusggg.css +8 -11
- package/src/delivery/cqt-css/sel-r1-s3.css +3 -9
- package/src/delivery/cqt-css/sel-vic.css +61 -0
- package/src/delivery/cqt-css/sr-vic.css +49 -0
- package/src/delivery/index.ts +14 -23
- package/src/delivery/variant-css-map.ts +26 -12
- package/src/shared/layoutLimits.ts +76 -0
- package/src/shared/types.ts +84 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export type AudioButtonSkin = { silentUrl: string; playingUrl: string };
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_AUDIO_BUTTON_SKINS: Record<string, AudioButtonSkin> = {
|
|
4
|
+
default: {
|
|
5
|
+
silentUrl:
|
|
6
|
+
'https://assets.learnosity.com/organisations/844/0c9f2aa3-3cd5-4de7-93ef-541c24ca35da.svg',
|
|
7
|
+
playingUrl:
|
|
8
|
+
'https://assets.learnosity.com/organisations/844/231dfdc2-c113-4be5-91fb-e75a0ca5994b.svg',
|
|
9
|
+
},
|
|
10
|
+
es: {
|
|
11
|
+
silentUrl:
|
|
12
|
+
'https://assets.learnosity.com/organisations/844/27a9d5b5-d873-4bd5-b9ba-22748782d8ba.svg',
|
|
13
|
+
playingUrl:
|
|
14
|
+
'https://assets.learnosity.com/organisations/844/120f216d-96b7-4560-94b8-1d90710216b7.svg',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the audio button skin to display.
|
|
20
|
+
* Priority: byLocale[exact] > byLocale[lang] > byLocale.default > customSingle > built-in default
|
|
21
|
+
*/
|
|
22
|
+
export function computeFeatureAudioSkin(params: {
|
|
23
|
+
locale: unknown;
|
|
24
|
+
audioButtonSkin: unknown;
|
|
25
|
+
audioButtonSkinsByLocale: unknown;
|
|
26
|
+
}): AudioButtonSkin {
|
|
27
|
+
const locale = String(params.locale || '').toLowerCase();
|
|
28
|
+
const lang = locale.slice(0, 2);
|
|
29
|
+
const byLocale =
|
|
30
|
+
params.audioButtonSkinsByLocale && typeof params.audioButtonSkinsByLocale === 'object'
|
|
31
|
+
? (params.audioButtonSkinsByLocale as Record<string, unknown>)
|
|
32
|
+
: {};
|
|
33
|
+
const customSingle =
|
|
34
|
+
params.audioButtonSkin && typeof params.audioButtonSkin === 'object'
|
|
35
|
+
? (params.audioButtonSkin as AudioButtonSkin)
|
|
36
|
+
: null;
|
|
37
|
+
const defaultSkin = locale.startsWith('es')
|
|
38
|
+
? DEFAULT_AUDIO_BUTTON_SKINS.es
|
|
39
|
+
: DEFAULT_AUDIO_BUTTON_SKINS.default;
|
|
40
|
+
return (byLocale[locale] ||
|
|
41
|
+
byLocale[lang] ||
|
|
42
|
+
byLocale.default ||
|
|
43
|
+
customSingle ||
|
|
44
|
+
defaultSkin) as AudioButtonSkin;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the choice id that the ClozeMarker and selected-state highlight should display.
|
|
49
|
+
* When the correct answer is revealed (alwaysShowCorrect or evaluate+showCorrectAnswer),
|
|
50
|
+
* it shows the correct choice rather than the student's selection.
|
|
51
|
+
*/
|
|
52
|
+
export function computeDisplayChoiceId(params: {
|
|
53
|
+
selectedId: string;
|
|
54
|
+
isEvaluateMode: boolean;
|
|
55
|
+
showCorrectAnswer: boolean;
|
|
56
|
+
alwaysShowCorrect: boolean;
|
|
57
|
+
correctChoiceId: string;
|
|
58
|
+
}): string {
|
|
59
|
+
const { selectedId, isEvaluateMode, showCorrectAnswer, alwaysShowCorrect, correctChoiceId } =
|
|
60
|
+
params;
|
|
61
|
+
if (alwaysShowCorrect && correctChoiceId) return correctChoiceId;
|
|
62
|
+
if (isEvaluateMode && showCorrectAnswer && correctChoiceId) return correctChoiceId;
|
|
63
|
+
return selectedId;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns the screen-reader-only result announcement text shown after evaluate mode scoring.
|
|
68
|
+
* Empty string means nothing is announced.
|
|
69
|
+
*/
|
|
70
|
+
export function computeResultText(params: {
|
|
71
|
+
isEvaluateMode: boolean;
|
|
72
|
+
showCorrectAnswer: boolean;
|
|
73
|
+
isCorrect: boolean;
|
|
74
|
+
isIncorrect: boolean;
|
|
75
|
+
selectedId: string;
|
|
76
|
+
}): string {
|
|
77
|
+
const { isEvaluateMode, showCorrectAnswer, isCorrect, isIncorrect, selectedId } = params;
|
|
78
|
+
if (!isEvaluateMode || showCorrectAnswer) return '';
|
|
79
|
+
if (isCorrect) return 'Correct answer selected';
|
|
80
|
+
if (isIncorrect && selectedId) return 'Incorrect answer selected';
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns the visible legend text for the choices fieldset.
|
|
86
|
+
* Strips HTML, truncates to legendMaxChars with an ellipsis, falls back to answerChoicesLabel.
|
|
87
|
+
*/
|
|
88
|
+
export function computeLegendText(params: {
|
|
89
|
+
prompt: string;
|
|
90
|
+
legendMaxChars: number;
|
|
91
|
+
answerChoicesLabel: string;
|
|
92
|
+
}): string {
|
|
93
|
+
const { prompt, legendMaxChars, answerChoicesLabel } = params;
|
|
94
|
+
const plain = prompt
|
|
95
|
+
.replace(/<[^>]+>/g, ' ')
|
|
96
|
+
.replace(/\s+/g, ' ')
|
|
97
|
+
.trim();
|
|
98
|
+
const maxChars = Math.max(8, legendMaxChars);
|
|
99
|
+
if (!plain) return answerChoicesLabel;
|
|
100
|
+
return plain.length > maxChars ? `${plain.slice(0, Math.max(1, maxChars - 1))}…` : plain;
|
|
101
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { computeLayoutProfile } from './computeLayoutProfile';
|
|
3
|
+
|
|
4
|
+
describe('computeLayoutProfile — isAudioOnlyMode', () => {
|
|
5
|
+
it('is true when interactionMode is audio_mc_only', () => {
|
|
6
|
+
const { isAudioOnlyMode } = computeLayoutProfile({ interactionMode: 'audio_mc_only' });
|
|
7
|
+
expect(isAudioOnlyMode).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('is false for any other interactionMode', () => {
|
|
11
|
+
expect(computeLayoutProfile({ interactionMode: 'populate_blank' }).isAudioOnlyMode).toBe(false);
|
|
12
|
+
expect(computeLayoutProfile({ interactionMode: '' }).isAudioOnlyMode).toBe(false);
|
|
13
|
+
expect(computeLayoutProfile({}).isAudioOnlyMode).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('computeLayoutProfile — isBlankOnlyTemplate', () => {
|
|
18
|
+
it('is true when template contains only the blank token', () => {
|
|
19
|
+
expect(computeLayoutProfile({ template: '{{blank}}' }).isBlankOnlyTemplate).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('is true when blank token is wrapped in HTML tags', () => {
|
|
23
|
+
expect(computeLayoutProfile({ template: '<p>{{blank}}</p>' }).isBlankOnlyTemplate).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('is true when blank token is surrounded by whitespace after stripping', () => {
|
|
27
|
+
expect(computeLayoutProfile({ template: ' <p> {{blank}} </p> ' }).isBlankOnlyTemplate).toBe(
|
|
28
|
+
true
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('is true when template contains around the token', () => {
|
|
33
|
+
expect(computeLayoutProfile({ template: ' {{blank}} ' }).isBlankOnlyTemplate).toBe(
|
|
34
|
+
true
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('is false when template has text before the blank token', () => {
|
|
39
|
+
expect(computeLayoutProfile({ template: 'The word is {{blank}}.' }).isBlankOnlyTemplate).toBe(
|
|
40
|
+
false
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('is false when template has text after the blank token', () => {
|
|
45
|
+
expect(
|
|
46
|
+
computeLayoutProfile({ template: '<p>{{blank}} is the answer.</p>' }).isBlankOnlyTemplate
|
|
47
|
+
).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('is false for empty template', () => {
|
|
51
|
+
expect(computeLayoutProfile({ template: '' }).isBlankOnlyTemplate).toBe(false);
|
|
52
|
+
expect(computeLayoutProfile({}).isBlankOnlyTemplate).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('computeLayoutProfile — choiceLayout and isHorizontalChoices', () => {
|
|
57
|
+
it('uses configured choiceLayout=horizontal when provided', () => {
|
|
58
|
+
const r = computeLayoutProfile({ choiceLayout: 'horizontal' });
|
|
59
|
+
expect(r.choiceLayout).toBe('horizontal');
|
|
60
|
+
expect(r.isHorizontalChoices).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('uses configured choiceLayout=vertical when provided', () => {
|
|
64
|
+
const r = computeLayoutProfile({ choiceLayout: 'vertical' });
|
|
65
|
+
expect(r.choiceLayout).toBe('vertical');
|
|
66
|
+
expect(r.isHorizontalChoices).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('defaults to horizontal when isAudioOnlyMode is true and no choiceLayout configured', () => {
|
|
70
|
+
const r = computeLayoutProfile({ interactionMode: 'audio_mc_only' });
|
|
71
|
+
expect(r.choiceLayout).toBe('horizontal');
|
|
72
|
+
expect(r.isHorizontalChoices).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('defaults to horizontal when isBlankOnlyTemplate is true and no choiceLayout configured', () => {
|
|
76
|
+
const r = computeLayoutProfile({ template: '<p>{{blank}}</p>' });
|
|
77
|
+
expect(r.choiceLayout).toBe('horizontal');
|
|
78
|
+
expect(r.isHorizontalChoices).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('defaults to vertical for a normal sentence template', () => {
|
|
82
|
+
const r = computeLayoutProfile({ template: '<p>The word is {{blank}}.</p>' });
|
|
83
|
+
expect(r.choiceLayout).toBe('vertical');
|
|
84
|
+
expect(r.isHorizontalChoices).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('configured choiceLayout overrides audio-only default', () => {
|
|
88
|
+
const r = computeLayoutProfile({ interactionMode: 'audio_mc_only', choiceLayout: 'vertical' });
|
|
89
|
+
expect(r.choiceLayout).toBe('vertical');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('computeLayoutProfile — hasInlineSentenceAudioLayout', () => {
|
|
94
|
+
it('is true when layoutProfile is inline_sentence and hasAudio is true', () => {
|
|
95
|
+
const r = computeLayoutProfile({ layoutProfile: 'inline_sentence', hasAudio: true });
|
|
96
|
+
expect(r.hasInlineSentenceAudioLayout).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('is false when layoutProfile is inline_sentence but hasAudio is false', () => {
|
|
100
|
+
const r = computeLayoutProfile({ layoutProfile: 'inline_sentence', hasAudio: false });
|
|
101
|
+
expect(r.hasInlineSentenceAudioLayout).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('is false for other profiles even when hasAudio is true', () => {
|
|
105
|
+
expect(
|
|
106
|
+
computeLayoutProfile({ layoutProfile: 'audio_blank_only', hasAudio: true })
|
|
107
|
+
.hasInlineSentenceAudioLayout
|
|
108
|
+
).toBe(false);
|
|
109
|
+
expect(
|
|
110
|
+
computeLayoutProfile({ layoutProfile: '', hasAudio: true }).hasInlineSentenceAudioLayout
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('computeLayoutProfile — useFeatureButtonAudio', () => {
|
|
116
|
+
it('uses explicit boolean true when configured', () => {
|
|
117
|
+
const r = computeLayoutProfile({ useFeatureButtonAudio: true, hasAudio: false });
|
|
118
|
+
expect(r.useFeatureButtonAudio).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('uses explicit boolean false when configured', () => {
|
|
122
|
+
const r = computeLayoutProfile({
|
|
123
|
+
useFeatureButtonAudio: false,
|
|
124
|
+
hasAudio: true,
|
|
125
|
+
layoutProfile: 'audio_blank_only',
|
|
126
|
+
});
|
|
127
|
+
expect(r.useFeatureButtonAudio).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('derives true for audio_blank_only profile with hasAudio=true', () => {
|
|
131
|
+
const r = computeLayoutProfile({ layoutProfile: 'audio_blank_only', hasAudio: true });
|
|
132
|
+
expect(r.useFeatureButtonAudio).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('derives true for stimulus_image_blank profile with hasAudio=true', () => {
|
|
136
|
+
const r = computeLayoutProfile({ layoutProfile: 'stimulus_image_blank', hasAudio: true });
|
|
137
|
+
expect(r.useFeatureButtonAudio).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('derives true for token_sequence profile with hasAudio=true', () => {
|
|
141
|
+
const r = computeLayoutProfile({ layoutProfile: 'token_sequence', hasAudio: true });
|
|
142
|
+
expect(r.useFeatureButtonAudio).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('derives false for inline_sentence profile (not in feature-button set)', () => {
|
|
146
|
+
const r = computeLayoutProfile({ layoutProfile: 'inline_sentence', hasAudio: true });
|
|
147
|
+
expect(r.useFeatureButtonAudio).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('derives false when hasAudio is false regardless of profile', () => {
|
|
151
|
+
const r = computeLayoutProfile({ layoutProfile: 'audio_blank_only', hasAudio: false });
|
|
152
|
+
expect(r.useFeatureButtonAudio).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('treats null as unconfigured and falls back to derived logic', () => {
|
|
156
|
+
const r = computeLayoutProfile({
|
|
157
|
+
useFeatureButtonAudio: null,
|
|
158
|
+
hasAudio: true,
|
|
159
|
+
layoutProfile: 'audio_blank_only',
|
|
160
|
+
});
|
|
161
|
+
expect(r.useFeatureButtonAudio).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const BLANK_TOKEN = '{{blank}}';
|
|
2
|
+
|
|
3
|
+
export interface LayoutProfileParams {
|
|
4
|
+
interactionMode?: string;
|
|
5
|
+
template?: string;
|
|
6
|
+
choiceLayout?: string;
|
|
7
|
+
layoutProfile?: string;
|
|
8
|
+
hasAudio?: boolean;
|
|
9
|
+
useFeatureButtonAudio?: boolean | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LayoutProfileResult {
|
|
13
|
+
isAudioOnlyMode: boolean;
|
|
14
|
+
isBlankOnlyTemplate: boolean;
|
|
15
|
+
choiceLayout: 'horizontal' | 'vertical';
|
|
16
|
+
isHorizontalChoices: boolean;
|
|
17
|
+
hasInlineSentenceAudioLayout: boolean;
|
|
18
|
+
useFeatureButtonAudio: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FEATURE_BUTTON_PROFILES = new Set([
|
|
22
|
+
'audio_blank_only',
|
|
23
|
+
'stimulus_image_blank',
|
|
24
|
+
'token_sequence',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export function computeLayoutProfile(params: LayoutProfileParams): LayoutProfileResult {
|
|
28
|
+
const {
|
|
29
|
+
interactionMode = '',
|
|
30
|
+
template = '',
|
|
31
|
+
choiceLayout: configuredChoiceLayout,
|
|
32
|
+
layoutProfile = '',
|
|
33
|
+
hasAudio = false,
|
|
34
|
+
useFeatureButtonAudio: configuredFeatureButton,
|
|
35
|
+
} = params;
|
|
36
|
+
|
|
37
|
+
const isAudioOnlyMode = interactionMode === 'audio_mc_only';
|
|
38
|
+
|
|
39
|
+
const isBlankOnlyTemplate = (() => {
|
|
40
|
+
if (!template) return false;
|
|
41
|
+
const plain = template
|
|
42
|
+
.replace(/<[^>]+>/g, '')
|
|
43
|
+
.replace(/ /g, ' ')
|
|
44
|
+
.trim();
|
|
45
|
+
return plain === BLANK_TOKEN;
|
|
46
|
+
})();
|
|
47
|
+
|
|
48
|
+
const choiceLayout: 'horizontal' | 'vertical' =
|
|
49
|
+
configuredChoiceLayout === 'horizontal' || configuredChoiceLayout === 'vertical'
|
|
50
|
+
? (configuredChoiceLayout as 'horizontal' | 'vertical')
|
|
51
|
+
: isAudioOnlyMode || isBlankOnlyTemplate
|
|
52
|
+
? 'horizontal'
|
|
53
|
+
: 'vertical';
|
|
54
|
+
|
|
55
|
+
const isHorizontalChoices = choiceLayout === 'horizontal';
|
|
56
|
+
|
|
57
|
+
const hasInlineSentenceAudioLayout = layoutProfile === 'inline_sentence' && hasAudio;
|
|
58
|
+
|
|
59
|
+
const useFeatureButtonAudio =
|
|
60
|
+
typeof configuredFeatureButton === 'boolean'
|
|
61
|
+
? configuredFeatureButton
|
|
62
|
+
: hasAudio && FEATURE_BUTTON_PROFILES.has(layoutProfile);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
isAudioOnlyMode,
|
|
66
|
+
isBlankOnlyTemplate,
|
|
67
|
+
choiceLayout,
|
|
68
|
+
isHorizontalChoices,
|
|
69
|
+
hasInlineSentenceAudioLayout,
|
|
70
|
+
useFeatureButtonAudio,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computeLayoutStyle, DEFAULT_LAYOUT_LIMITS } from './computeLayoutStyle';
|
|
3
|
+
|
|
4
|
+
const NO_CORRECT_ANSWER_VARS = '';
|
|
5
|
+
const base = {
|
|
6
|
+
configuredLimits: {},
|
|
7
|
+
customProfilePresets: {},
|
|
8
|
+
correctAnswerStyleVars: NO_CORRECT_ANSWER_VARS,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// blankWidth
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
describe('blankWidth', () => {
|
|
15
|
+
it('audio_blank_only → blankWideWidthRem', () => {
|
|
16
|
+
const { blankWidth } = computeLayoutStyle({
|
|
17
|
+
...base,
|
|
18
|
+
layoutProfile: 'audio_blank_only',
|
|
19
|
+
isBlankOnlyTemplate: false,
|
|
20
|
+
});
|
|
21
|
+
expect(blankWidth).toBe(`${DEFAULT_LAYOUT_LIMITS.blankWideWidthRem}rem`);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('stimulus_image_blank → blankWideWidthRem', () => {
|
|
25
|
+
const { blankWidth } = computeLayoutStyle({
|
|
26
|
+
...base,
|
|
27
|
+
layoutProfile: 'stimulus_image_blank',
|
|
28
|
+
isBlankOnlyTemplate: false,
|
|
29
|
+
});
|
|
30
|
+
expect(blankWidth).toBe(`${DEFAULT_LAYOUT_LIMITS.blankWideWidthRem}rem`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('other profile + isBlankOnlyTemplate → blankStandaloneWidthRem', () => {
|
|
34
|
+
const { blankWidth } = computeLayoutStyle({
|
|
35
|
+
...base,
|
|
36
|
+
layoutProfile: 'token_sequence',
|
|
37
|
+
isBlankOnlyTemplate: true,
|
|
38
|
+
});
|
|
39
|
+
expect(blankWidth).toBe(`${DEFAULT_LAYOUT_LIMITS.blankStandaloneWidthRem}rem`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('other profile + not blank-only → auto', () => {
|
|
43
|
+
const { blankWidth } = computeLayoutStyle({
|
|
44
|
+
...base,
|
|
45
|
+
layoutProfile: 'inline_sentence',
|
|
46
|
+
isBlankOnlyTemplate: false,
|
|
47
|
+
});
|
|
48
|
+
expect(blankWidth).toBe('auto');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// blankBorderWidth
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
describe('blankBorderWidth', () => {
|
|
56
|
+
it.each([
|
|
57
|
+
'audio_blank_only',
|
|
58
|
+
'stimulus_image_blank',
|
|
59
|
+
'token_sequence',
|
|
60
|
+
])('%s → blankUnderlineWideWidthPx (profile preset overrides to 6)', (profile) => {
|
|
61
|
+
const { blankBorderWidth } = computeLayoutStyle({
|
|
62
|
+
...base,
|
|
63
|
+
layoutProfile: profile,
|
|
64
|
+
isBlankOnlyTemplate: false,
|
|
65
|
+
});
|
|
66
|
+
// All three wide-underline profiles have preset blankUnderlineWideWidthPx: 6
|
|
67
|
+
expect(blankBorderWidth).toBe('6px');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('other profile → blankUnderlineWidthPx (default 2)', () => {
|
|
71
|
+
const { blankBorderWidth } = computeLayoutStyle({
|
|
72
|
+
...base,
|
|
73
|
+
layoutProfile: 'inline_sentence',
|
|
74
|
+
isBlankOnlyTemplate: false,
|
|
75
|
+
});
|
|
76
|
+
expect(blankBorderWidth).toBe(`${DEFAULT_LAYOUT_LIMITS.blankUnderlineWidthPx}px`);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// legendMaxChars — uses resolved limits so overrides are respected
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
describe('legendMaxChars', () => {
|
|
84
|
+
it('defaults to DEFAULT_LAYOUT_LIMITS.legendMaxChars', () => {
|
|
85
|
+
const { legendMaxChars } = computeLayoutStyle({
|
|
86
|
+
...base,
|
|
87
|
+
layoutProfile: '',
|
|
88
|
+
isBlankOnlyTemplate: false,
|
|
89
|
+
});
|
|
90
|
+
expect(legendMaxChars).toBe(DEFAULT_LAYOUT_LIMITS.legendMaxChars);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('configuredLimits override is respected', () => {
|
|
94
|
+
const { legendMaxChars } = computeLayoutStyle({
|
|
95
|
+
...base,
|
|
96
|
+
layoutProfile: '',
|
|
97
|
+
isBlankOnlyTemplate: false,
|
|
98
|
+
configuredLimits: { legendMaxChars: 60 },
|
|
99
|
+
});
|
|
100
|
+
expect(legendMaxChars).toBe(60);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// limit resolution order: defaults < configuredLimits < profile preset < custom preset
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
describe('limit resolution order', () => {
|
|
108
|
+
it('profile preset overrides configuredLimits', () => {
|
|
109
|
+
// audio_blank_only preset sets choiceGroupGapRem: 1; configured sets it to 0.1
|
|
110
|
+
const { rootStyle } = computeLayoutStyle({
|
|
111
|
+
...base,
|
|
112
|
+
layoutProfile: 'audio_blank_only',
|
|
113
|
+
isBlankOnlyTemplate: false,
|
|
114
|
+
configuredLimits: { choiceGroupGapRem: 0.1 },
|
|
115
|
+
});
|
|
116
|
+
expect(rootStyle).toContain('--mpb-choice-group-gap:1rem');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('customProfilePresets override the built-in profile preset', () => {
|
|
120
|
+
const { rootStyle } = computeLayoutStyle({
|
|
121
|
+
...base,
|
|
122
|
+
layoutProfile: 'audio_blank_only',
|
|
123
|
+
isBlankOnlyTemplate: false,
|
|
124
|
+
customProfilePresets: { audio_blank_only: { choiceGroupGapRem: 2 } },
|
|
125
|
+
});
|
|
126
|
+
expect(rootStyle).toContain('--mpb-choice-group-gap:2rem');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('ignores customProfilePresets for a different profile', () => {
|
|
130
|
+
const { rootStyle } = computeLayoutStyle({
|
|
131
|
+
...base,
|
|
132
|
+
layoutProfile: 'audio_blank_only',
|
|
133
|
+
isBlankOnlyTemplate: false,
|
|
134
|
+
customProfilePresets: { token_sequence: { choiceGroupGapRem: 99 } },
|
|
135
|
+
});
|
|
136
|
+
// audio_blank_only preset value (1) should win, not 99
|
|
137
|
+
expect(rootStyle).toContain('--mpb-choice-group-gap:1rem');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// rootStyle contains all expected CSS var groups
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
describe('rootStyle structure', () => {
|
|
145
|
+
it('includes blank, choice, audio, and grid vars', () => {
|
|
146
|
+
const { rootStyle } = computeLayoutStyle({
|
|
147
|
+
...base,
|
|
148
|
+
layoutProfile: '',
|
|
149
|
+
isBlankOnlyTemplate: false,
|
|
150
|
+
});
|
|
151
|
+
expect(rootStyle).toContain('--mpb-blank-standalone-width:');
|
|
152
|
+
expect(rootStyle).toContain('--mpb-choice-width-px:');
|
|
153
|
+
expect(rootStyle).toContain('--mpb-listen-button-size:');
|
|
154
|
+
expect(rootStyle).toContain('--mpb-stimulus-grid-column-gap:');
|
|
155
|
+
expect(rootStyle).toContain('--mpb-token-grid-column-gap:');
|
|
156
|
+
expect(rootStyle).toContain('--mpb-inline-grid-column-gap:');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('appends correctAnswerStyleVars at the end', () => {
|
|
160
|
+
const { rootStyle } = computeLayoutStyle({
|
|
161
|
+
...base,
|
|
162
|
+
layoutProfile: '',
|
|
163
|
+
isBlankOnlyTemplate: false,
|
|
164
|
+
correctAnswerStyleVars: '--pie-foo:bar',
|
|
165
|
+
});
|
|
166
|
+
expect(rootStyle).toMatch(/--pie-foo:bar$/);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type LayoutLimits, DEFAULT_LAYOUT_LIMITS } from '../shared/layoutLimits';
|
|
2
|
+
export type { LayoutLimits };
|
|
3
|
+
export { DEFAULT_LAYOUT_LIMITS };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Maps every LayoutLimits key (except legendMaxChars, which is a return value not a CSS var)
|
|
7
|
+
* to its --mpb-* CSS variable name and CSS unit.
|
|
8
|
+
* Add a single entry here when adding a new layout limit.
|
|
9
|
+
*/
|
|
10
|
+
export const CSS_VAR_SPEC: Record<
|
|
11
|
+
Exclude<keyof LayoutLimits, 'legendMaxChars'>,
|
|
12
|
+
{ varName: string; unit: string }
|
|
13
|
+
> = {
|
|
14
|
+
blankStandaloneWidthRem: { varName: '--mpb-blank-standalone-width', unit: 'rem' },
|
|
15
|
+
blankWideWidthRem: { varName: '--mpb-blank-wide-width', unit: 'rem' },
|
|
16
|
+
blankUnderlineWidthPx: { varName: '--mpb-blank-underline-width', unit: 'px' },
|
|
17
|
+
blankUnderlineWideWidthPx: { varName: '--mpb-blank-underline-wide-width', unit: 'px' },
|
|
18
|
+
horizontalChoiceWidthPx: { varName: '--mpb-choice-width-px', unit: 'px' },
|
|
19
|
+
horizontalChoiceWidthVw: { varName: '--mpb-choice-width-vw', unit: 'vw' },
|
|
20
|
+
horizontalChoiceTileMinHeightRem: { varName: '--mpb-choice-tile-min-height', unit: 'rem' },
|
|
21
|
+
horizontalChoiceContentMinHeightRem: { varName: '--mpb-choice-content-min-height', unit: 'rem' },
|
|
22
|
+
choiceImageMaxHeightRem: { varName: '--mpb-choice-image-max-height', unit: 'rem' },
|
|
23
|
+
selectedImageMaxHeightRem: { varName: '--mpb-selected-image-max-height', unit: 'rem' },
|
|
24
|
+
choiceGroupGapRem: { varName: '--mpb-choice-group-gap', unit: 'rem' },
|
|
25
|
+
choiceRowGapRem: { varName: '--mpb-choice-row-gap', unit: 'rem' },
|
|
26
|
+
horizontalChoiceRadioTopMarginRem: {
|
|
27
|
+
varName: '--mpb-horizontal-choice-radio-top-margin',
|
|
28
|
+
unit: 'rem',
|
|
29
|
+
},
|
|
30
|
+
narrowHorizontalChoiceMaxWidthPx: { varName: '--mpb-narrow-choice-max-width', unit: 'px' },
|
|
31
|
+
toggleButtonGapRem: { varName: '--mpb-toggle-button-gap', unit: 'rem' },
|
|
32
|
+
listenButtonSizePx: { varName: '--mpb-listen-button-size', unit: 'px' },
|
|
33
|
+
audioBlankTemplateMarginTopRem: { varName: '--mpb-audio-blank-template-margin-top', unit: 'rem' },
|
|
34
|
+
audioBlankTemplateMarginBottomRem: {
|
|
35
|
+
varName: '--mpb-audio-blank-template-margin-bottom',
|
|
36
|
+
unit: 'rem',
|
|
37
|
+
},
|
|
38
|
+
audioInstructionsMaxWidthPx: { varName: '--mpb-audio-instructions-max-width', unit: 'px' },
|
|
39
|
+
stimulusMinColumnPx: { varName: '--mpb-stimulus-min-column', unit: 'px' },
|
|
40
|
+
textMinColumnPx: { varName: '--mpb-text-min-column', unit: 'px' },
|
|
41
|
+
stimulusGridColumnGapRem: { varName: '--mpb-stimulus-grid-column-gap', unit: 'rem' },
|
|
42
|
+
stimulusGridRowGapRem: { varName: '--mpb-stimulus-grid-row-gap', unit: 'rem' },
|
|
43
|
+
stimulusSentenceMarginTopRem: { varName: '--mpb-stimulus-sentence-margin-top', unit: 'rem' },
|
|
44
|
+
stimulusChoicesMarginTopRem: { varName: '--mpb-stimulus-choices-margin-top', unit: 'rem' },
|
|
45
|
+
tokenGridColumnGapRem: { varName: '--mpb-token-grid-column-gap', unit: 'rem' },
|
|
46
|
+
tokenGridRowGapRem: { varName: '--mpb-token-grid-row-gap', unit: 'rem' },
|
|
47
|
+
tokenTemplateMarginTopRem: { varName: '--mpb-token-template-margin-top', unit: 'rem' },
|
|
48
|
+
tokenInlineTokenGapRem: { varName: '--mpb-token-inline-token-gap', unit: 'rem' },
|
|
49
|
+
tokenChoicesMarginTopRem: { varName: '--mpb-token-choices-margin-top', unit: 'rem' },
|
|
50
|
+
inlineGridColumnGapRem: { varName: '--mpb-inline-grid-column-gap', unit: 'rem' },
|
|
51
|
+
inlineGridRowGapRem: { varName: '--mpb-inline-grid-row-gap', unit: 'rem' },
|
|
52
|
+
inlineTemplateMarginTopRem: { varName: '--mpb-inline-template-margin-top', unit: 'rem' },
|
|
53
|
+
inlineChoicesMarginTopRem: { varName: '--mpb-inline-choices-margin-top', unit: 'rem' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const DEFAULT_LAYOUT_PROFILE_PRESETS: Record<string, Partial<LayoutLimits>> = {
|
|
57
|
+
audio_blank_only: {
|
|
58
|
+
blankWideWidthRem: 10,
|
|
59
|
+
blankUnderlineWideWidthPx: 6,
|
|
60
|
+
horizontalChoiceTileMinHeightRem: 11.25,
|
|
61
|
+
horizontalChoiceContentMinHeightRem: 9.375,
|
|
62
|
+
choiceGroupGapRem: 1,
|
|
63
|
+
audioBlankTemplateMarginBottomRem: 1.875,
|
|
64
|
+
},
|
|
65
|
+
stimulus_image_blank: {
|
|
66
|
+
blankWideWidthRem: 10,
|
|
67
|
+
blankUnderlineWideWidthPx: 6,
|
|
68
|
+
},
|
|
69
|
+
token_sequence: {
|
|
70
|
+
blankStandaloneWidthRem: 7,
|
|
71
|
+
blankUnderlineWideWidthPx: 6,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export interface LayoutStyleResult {
|
|
76
|
+
rootStyle: string;
|
|
77
|
+
blankWidth: string;
|
|
78
|
+
blankBorderWidth: string;
|
|
79
|
+
legendMaxChars: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function computeLayoutStyle(params: {
|
|
83
|
+
layoutProfile: string;
|
|
84
|
+
isBlankOnlyTemplate: boolean;
|
|
85
|
+
configuredLimits: unknown;
|
|
86
|
+
customProfilePresets: unknown;
|
|
87
|
+
correctAnswerStyleVars: string;
|
|
88
|
+
}): LayoutStyleResult {
|
|
89
|
+
const { layoutProfile, isBlankOnlyTemplate, correctAnswerStyleVars } = params;
|
|
90
|
+
|
|
91
|
+
const configured =
|
|
92
|
+
params.configuredLimits && typeof params.configuredLimits === 'object'
|
|
93
|
+
? (params.configuredLimits as Partial<LayoutLimits>)
|
|
94
|
+
: {};
|
|
95
|
+
const customPresets =
|
|
96
|
+
params.customProfilePresets && typeof params.customProfilePresets === 'object'
|
|
97
|
+
? (params.customProfilePresets as Record<string, unknown>)
|
|
98
|
+
: {};
|
|
99
|
+
const customForProfile =
|
|
100
|
+
customPresets[layoutProfile] && typeof customPresets[layoutProfile] === 'object'
|
|
101
|
+
? (customPresets[layoutProfile] as Partial<LayoutLimits>)
|
|
102
|
+
: {};
|
|
103
|
+
const profilePreset: Partial<LayoutLimits> = {
|
|
104
|
+
...(DEFAULT_LAYOUT_PROFILE_PRESETS[layoutProfile] ?? {}),
|
|
105
|
+
...customForProfile,
|
|
106
|
+
};
|
|
107
|
+
const l: LayoutLimits = { ...DEFAULT_LAYOUT_LIMITS, ...configured, ...profilePreset };
|
|
108
|
+
|
|
109
|
+
const wideBlankProfiles = ['audio_blank_only', 'stimulus_image_blank'];
|
|
110
|
+
const wideUnderlineProfiles = ['audio_blank_only', 'stimulus_image_blank', 'token_sequence'];
|
|
111
|
+
|
|
112
|
+
const blankWidth = wideBlankProfiles.includes(layoutProfile)
|
|
113
|
+
? `${l.blankWideWidthRem}rem`
|
|
114
|
+
: isBlankOnlyTemplate
|
|
115
|
+
? `${l.blankStandaloneWidthRem}rem`
|
|
116
|
+
: 'auto';
|
|
117
|
+
|
|
118
|
+
const blankBorderWidth = wideUnderlineProfiles.includes(layoutProfile)
|
|
119
|
+
? `${l.blankUnderlineWideWidthPx}px`
|
|
120
|
+
: `${l.blankUnderlineWidthPx}px`;
|
|
121
|
+
|
|
122
|
+
const cssVars = (Object.keys(CSS_VAR_SPEC) as (keyof typeof CSS_VAR_SPEC)[]).map(
|
|
123
|
+
(key) => `${CSS_VAR_SPEC[key].varName}:${l[key]}${CSS_VAR_SPEC[key].unit}`
|
|
124
|
+
);
|
|
125
|
+
const rootStyle = [...cssVars, correctAnswerStyleVars].join(';');
|
|
126
|
+
|
|
127
|
+
return { rootStyle, blankWidth, blankBorderWidth, legendMaxChars: l.legendMaxChars };
|
|
128
|
+
}
|