@tinyweb_dev/oe-exam-sdk 0.2.6 → 0.2.8
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/dist/components/exams/take/components/QuestionRenderer.js +1 -1
- package/dist/components/exams/take/components/question-renderers/MoversChooseBestAnswerRenderer.js +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
- package/dist/components/exams/take/types.d.ts +12 -5
- package/dist/components/exams/take/utils/answer-transformers.js +1 -1
- package/dist/components/exams/take/utils/question-transformers.d.ts +1 -0
- package/dist/components/exams/take/utils/question-transformers.js +29 -53
- package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +2 -0
- package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +13 -3
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +7 -3
- package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +13 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +5 -0
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
- package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
- package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
- package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
- package/dist/components/questions/types/fill-in-blank/register.js +19 -6
- package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
- package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.js +1 -1
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
- package/dist/components/results/renderers/review-question-body-dedicated.js +3 -3
- package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
- package/dist/shared/lib/utils/fill-in-blank.js +96 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +68 -3
- package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FillInBlankCreator } from './FillInBlankCreator';
|
|
2
|
+
import { normalizeFillInBlankAnswers } from './answer-utils';
|
|
2
3
|
export const fillInBlankRegistration = {
|
|
3
4
|
component: FillInBlankCreator,
|
|
4
5
|
transformInitialData: (initialData) => {
|
|
@@ -18,14 +19,16 @@ export const fillInBlankRegistration = {
|
|
|
18
19
|
const groupTitle = groupPayload?.title || answer?.title || '';
|
|
19
20
|
const groupContent = groupPayload?.content || groupPayload?.passage || answer?.groupContent || '';
|
|
20
21
|
const groupNumber = questionGroup?.groupNumber || answer?.groupNumber || 1;
|
|
22
|
+
const audioUrl = answer?.audioUrl || content?.audioUrl || '';
|
|
21
23
|
// NEW FORMAT: question with {0}, {1}, {2}... placeholders and answers array
|
|
22
24
|
if (answer?.question && Array.isArray(answer?.answers)) {
|
|
23
25
|
creatorData = {
|
|
24
26
|
question: answer.question,
|
|
25
|
-
answers: answer.answers,
|
|
27
|
+
answers: normalizeFillInBlankAnswers(answer.answers),
|
|
26
28
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
27
29
|
imageUrl: questionImageUrl,
|
|
28
30
|
groupImageUrl,
|
|
31
|
+
audioUrl,
|
|
29
32
|
showHints: answer?.showHints || false,
|
|
30
33
|
hints: answer?.hints || [],
|
|
31
34
|
explanation,
|
|
@@ -40,10 +43,11 @@ export const fillInBlankRegistration = {
|
|
|
40
43
|
else if (content?.question && Array.isArray(answer?.answers)) {
|
|
41
44
|
creatorData = {
|
|
42
45
|
question: content.question,
|
|
43
|
-
answers: answer.answers,
|
|
46
|
+
answers: normalizeFillInBlankAnswers(answer.answers),
|
|
44
47
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
45
48
|
imageUrl: questionImageUrl,
|
|
46
49
|
groupImageUrl,
|
|
50
|
+
audioUrl,
|
|
47
51
|
showHints: content?.showHints || false,
|
|
48
52
|
hints: content?.hints || [],
|
|
49
53
|
explanation,
|
|
@@ -60,7 +64,7 @@ export const fillInBlankRegistration = {
|
|
|
60
64
|
const firstBlank = answer.blanks[0];
|
|
61
65
|
creatorData = {
|
|
62
66
|
question: firstBlank?.text || '',
|
|
63
|
-
answers: answer.blanks.map((b) => b.correctAnswer
|
|
67
|
+
answers: normalizeFillInBlankAnswers(answer.blanks.map((b) => b.correctAnswers ?? b.correctAnswer ?? '')),
|
|
64
68
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
65
69
|
showHints: answer?.showHints || false,
|
|
66
70
|
hints: answer?.hints || [],
|
|
@@ -69,6 +73,7 @@ export const fillInBlankRegistration = {
|
|
|
69
73
|
blanks: answer.blanks,
|
|
70
74
|
imageUrl: questionImageUrl,
|
|
71
75
|
groupImageUrl,
|
|
76
|
+
audioUrl,
|
|
72
77
|
title: groupTitle,
|
|
73
78
|
groupContent,
|
|
74
79
|
groupNumber,
|
|
@@ -79,7 +84,7 @@ export const fillInBlankRegistration = {
|
|
|
79
84
|
// LEGACY Format 1: Single blank
|
|
80
85
|
creatorData = {
|
|
81
86
|
question: content.text || '',
|
|
82
|
-
answers: [answer?.correctAnswer || ''],
|
|
87
|
+
answers: normalizeFillInBlankAnswers([answer?.correctAnswer || '']),
|
|
83
88
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
84
89
|
showHints: content?.showHints || false,
|
|
85
90
|
hints: content?.hints || [],
|
|
@@ -87,6 +92,7 @@ export const fillInBlankRegistration = {
|
|
|
87
92
|
isExample: initialData?.isExample || answer?.isExample || false,
|
|
88
93
|
imageUrl: questionImageUrl,
|
|
89
94
|
groupImageUrl,
|
|
95
|
+
audioUrl,
|
|
90
96
|
title: groupTitle,
|
|
91
97
|
groupContent,
|
|
92
98
|
groupNumber,
|
|
@@ -98,12 +104,17 @@ export const fillInBlankRegistration = {
|
|
|
98
104
|
const answersMap = answer?.answers || {};
|
|
99
105
|
const mergedBlanks = content.blanks.map((blank) => ({
|
|
100
106
|
...blank,
|
|
101
|
-
correctAnswer: answersMap[blank.id]
|
|
107
|
+
correctAnswer: Array.isArray(answersMap[blank.id])
|
|
108
|
+
? (answersMap[blank.id][0] || blank.correctAnswer || '')
|
|
109
|
+
: (answersMap[blank.id] || blank.correctAnswer || ''),
|
|
110
|
+
correctAnswers: Array.isArray(answersMap[blank.id])
|
|
111
|
+
? answersMap[blank.id]
|
|
112
|
+
: undefined,
|
|
102
113
|
}));
|
|
103
114
|
const firstBlank = mergedBlanks[0];
|
|
104
115
|
creatorData = {
|
|
105
116
|
question: firstBlank?.text || '',
|
|
106
|
-
answers: mergedBlanks.map((b) => b.correctAnswer
|
|
117
|
+
answers: normalizeFillInBlankAnswers(mergedBlanks.map((b) => b.correctAnswers ?? b.correctAnswer ?? '')),
|
|
107
118
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
108
119
|
showHints: content?.showHints || false,
|
|
109
120
|
hints: content?.hints || [],
|
|
@@ -112,6 +123,7 @@ export const fillInBlankRegistration = {
|
|
|
112
123
|
blanks: mergedBlanks,
|
|
113
124
|
imageUrl: questionImageUrl,
|
|
114
125
|
groupImageUrl,
|
|
126
|
+
audioUrl,
|
|
115
127
|
title: groupTitle,
|
|
116
128
|
groupContent,
|
|
117
129
|
groupNumber,
|
|
@@ -126,6 +138,7 @@ export const fillInBlankRegistration = {
|
|
|
126
138
|
groups: ctx.groups,
|
|
127
139
|
questionIndexInPart: ctx.questionIndexInPart,
|
|
128
140
|
partId: ctx.partId,
|
|
141
|
+
questionConfig: ctx.questionConfig,
|
|
129
142
|
}),
|
|
130
143
|
wrapOnSave: (data, ctx) => ({
|
|
131
144
|
type: ctx.questionType,
|
|
@@ -1,21 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
if (!question)
|
|
3
|
-
return [];
|
|
4
|
-
const regex = /\{(\d+)\}/g;
|
|
5
|
-
const indices = [];
|
|
6
|
-
let match;
|
|
7
|
-
while ((match = regex.exec(question)) !== null) {
|
|
8
|
-
const index = parseInt(match[1], 10);
|
|
9
|
-
if (!indices.includes(index)) {
|
|
10
|
-
indices.push(index);
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
return indices.sort((a, b) => a - b);
|
|
14
|
-
}
|
|
15
|
-
function getMaxPlaceholderIndex(question) {
|
|
16
|
-
const indices = extractPlaceholderIndices(question);
|
|
17
|
-
return indices.length > 0 ? Math.max(...indices) : -1;
|
|
18
|
-
}
|
|
1
|
+
import { compactFillInBlankAnswers, normalizeFillInBlankAnswers, getMaxPlaceholderIndex } from './answer-utils';
|
|
19
2
|
export const transformFillInBlank = (question) => {
|
|
20
3
|
const answerData = question.answer;
|
|
21
4
|
// Helper: build basicGroupData if any group field is present.
|
|
@@ -32,12 +15,8 @@ export const transformFillInBlank = (question) => {
|
|
|
32
15
|
: undefined;
|
|
33
16
|
const groupNumber = answerData?.groupNumber || 1;
|
|
34
17
|
// NEW FORMAT: question with {0}, {1}, {2}... placeholders and answers array
|
|
18
|
+
// answers: string[][] (canonical) or legacy string[]
|
|
35
19
|
if (answerData?.question && Array.isArray(answerData?.answers)) {
|
|
36
|
-
const maxIndex = getMaxPlaceholderIndex(answerData.question || '');
|
|
37
|
-
const requiredLength = maxIndex >= 0 ? maxIndex + 1 : 1;
|
|
38
|
-
const validAnswers = answerData.answers.length > requiredLength
|
|
39
|
-
? answerData.answers.slice(0, requiredLength)
|
|
40
|
-
: answerData.answers;
|
|
41
20
|
const apiContent = {
|
|
42
21
|
question: answerData.question,
|
|
43
22
|
showHints: answerData.showHints || false,
|
|
@@ -47,10 +26,17 @@ export const transformFillInBlank = (question) => {
|
|
|
47
26
|
if (answerData.imageUrl) {
|
|
48
27
|
apiContent.imageUrl = answerData.imageUrl;
|
|
49
28
|
}
|
|
29
|
+
// Per-question audio (when questionConfig includes "audio")
|
|
30
|
+
if (answerData.audioUrl) {
|
|
31
|
+
apiContent.audioUrl = answerData.audioUrl;
|
|
32
|
+
}
|
|
33
|
+
const maxIndex = getMaxPlaceholderIndex(answerData.question || '');
|
|
34
|
+
const requiredLength = maxIndex >= 0 ? maxIndex + 1 : undefined;
|
|
35
|
+
const normalizedAnswers = compactFillInBlankAnswers(normalizeFillInBlankAnswers(answerData.answers, requiredLength), requiredLength);
|
|
50
36
|
return {
|
|
51
37
|
apiContent,
|
|
52
38
|
apiCorrectAnswer: {
|
|
53
|
-
answers:
|
|
39
|
+
answers: normalizedAnswers,
|
|
54
40
|
caseSensitive: answerData.caseSensitive || false,
|
|
55
41
|
},
|
|
56
42
|
isExample: answerData.isExample || false,
|
|
@@ -66,9 +52,17 @@ export const transformFillInBlank = (question) => {
|
|
|
66
52
|
id: blank.id,
|
|
67
53
|
text: blank.text,
|
|
68
54
|
}));
|
|
55
|
+
// Prefer correctAnswers[]; fall back to single correctAnswer string
|
|
69
56
|
const answers = (answerData.blanks || []).reduce((acc, blank) => {
|
|
70
|
-
if (blank.id
|
|
71
|
-
acc
|
|
57
|
+
if (!blank.id)
|
|
58
|
+
return acc;
|
|
59
|
+
const alts = blank.correctAnswers ?? blank.correctAnswer;
|
|
60
|
+
if (alts == null)
|
|
61
|
+
return acc;
|
|
62
|
+
const normalized = normalizeFillInBlankAnswers(Array.isArray(alts) ? [alts] : [alts])[0];
|
|
63
|
+
const cleaned = normalized.map((a) => a.trim()).filter(Boolean);
|
|
64
|
+
if (cleaned.length > 0) {
|
|
65
|
+
acc[blank.id] = cleaned;
|
|
72
66
|
}
|
|
73
67
|
return acc;
|
|
74
68
|
}, {});
|
|
@@ -76,6 +70,9 @@ export const transformFillInBlank = (question) => {
|
|
|
76
70
|
if (answerData.imageUrl) {
|
|
77
71
|
legacyContent.imageUrl = answerData.imageUrl;
|
|
78
72
|
}
|
|
73
|
+
if (answerData.audioUrl) {
|
|
74
|
+
legacyContent.audioUrl = answerData.audioUrl;
|
|
75
|
+
}
|
|
79
76
|
return {
|
|
80
77
|
apiContent: legacyContent,
|
|
81
78
|
apiCorrectAnswer: {
|
|
@@ -43,7 +43,7 @@ function ReviewQuestionItem({ question, selectedOptionIds, isCorrect, correctOpt
|
|
|
43
43
|
}
|
|
44
44
|
return _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 border-gray-300 bg-white ${shapeClass}` });
|
|
45
45
|
};
|
|
46
|
-
return (_jsxs("div", { className: "mb-4", children: [_jsxs("p", { className: "mb-2 text-lg font-bold text-gray-900", children: [question.questionNumber, ". ", question.questionText] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), isImageOptions ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-lg border-2 p-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsx("span", { className: "text-xs font-medium text-gray-600", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "h-28 w-28 rounded object-contain" }), renderIndicator(option.id)] }, option.id))) })) : (
|
|
46
|
+
return (_jsxs("div", { className: "mb-4", children: [_jsxs("p", { className: "mb-2 text-lg font-bold text-gray-900", children: [question.questionNumber, ". ", question.questionText] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), question.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), isImageOptions ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-lg border-2 p-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsx("span", { className: "text-xs font-medium text-gray-600", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "h-28 w-28 rounded object-contain" }), renderIndicator(option.id)] }, option.id))) })) : (
|
|
47
47
|
/* Options — text variant */
|
|
48
48
|
_jsx("div", { className: "flex flex-col gap-2 pl-2", children: question.options.map((option) => (_jsxs("div", { className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx("span", { className: "flex-1 text-sm text-gray-700", children: option.text }), renderIndicator(option.id)] }, option.id))) }))] }));
|
|
49
49
|
}
|
|
@@ -10,9 +10,8 @@ interface ReviewListenAndWriteRendererProps {
|
|
|
10
10
|
exampleText?: string;
|
|
11
11
|
exampleHighlight?: string;
|
|
12
12
|
questions: FillBlankQuestion[];
|
|
13
|
-
answers: Record<string,
|
|
13
|
+
answers: Record<string, unknown>;
|
|
14
14
|
isCorrectMap: Record<string, boolean | null>;
|
|
15
|
-
correctAnswerTextMap: Record<string, string>;
|
|
16
15
|
}
|
|
17
|
-
export declare function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap,
|
|
16
|
+
export declare function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, }: ReviewListenAndWriteRendererProps): import("react").JSX.Element;
|
|
18
17
|
export {};
|
|
@@ -5,7 +5,8 @@ import { ResolvedImage } from '../../../components/common/ResolvedImage';
|
|
|
5
5
|
import CambridgeYlePartBanner from '../../../components/themes/cambridge-yle/CambridgeYlePartBanner';
|
|
6
6
|
import CambridgeYleInstructionBanner from '../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
|
|
7
7
|
import CambridgeYleAudioPlayer from '../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
|
|
8
|
-
|
|
8
|
+
import { countBlanks, formatAcceptedAnswers, hasAnyBlankValue, isBlankCorrect, toBlankValues, } from '../../../shared/lib/utils/fill-in-blank';
|
|
9
|
+
export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, }) {
|
|
9
10
|
/**
|
|
10
11
|
* Get answer status from API's isCorrect field
|
|
11
12
|
* Returns: 'correct' | 'incorrect' | 'unanswered'
|
|
@@ -14,7 +15,7 @@ export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instru
|
|
|
14
15
|
const isCorrect = isCorrectMap[questionId];
|
|
15
16
|
const userAnswer = answers[questionId];
|
|
16
17
|
// No answer from student (null or undefined or empty string)
|
|
17
|
-
if (userAnswer
|
|
18
|
+
if (!hasAnyBlankValue(userAnswer)) {
|
|
18
19
|
return 'unanswered';
|
|
19
20
|
}
|
|
20
21
|
// Use API's isCorrect value
|
|
@@ -25,8 +26,7 @@ export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instru
|
|
|
25
26
|
// isCorrect is null but student answered - treat as unanswered/pending
|
|
26
27
|
return 'unanswered';
|
|
27
28
|
};
|
|
28
|
-
const getUnderlineColor = (
|
|
29
|
-
const status = getAnswerStatus(questionId);
|
|
29
|
+
const getUnderlineColor = (status) => {
|
|
30
30
|
switch (status) {
|
|
31
31
|
case 'correct':
|
|
32
32
|
return '#22c55e'; // green-500
|
|
@@ -38,11 +38,20 @@ export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instru
|
|
|
38
38
|
return '#9ca3af'; // gray-400
|
|
39
39
|
}
|
|
40
40
|
};
|
|
41
|
+
const renderBlankAnswer = (status, userValue, correctValue) => (_jsxs("span", { className: "mx-1 inline-block min-w-[120px] align-baseline", style: { borderBottom: `2px solid ${getUnderlineColor(status)}` }, children: [status === 'correct' && (_jsx("span", { className: "block w-full text-center", children: userValue })), status === 'unanswered' && (_jsx("span", { className: "block w-full text-center text-blue-600", children: correctValue })), status === 'incorrect' && (_jsxs("span", { className: "flex items-center justify-center gap-2", children: [_jsx("span", { className: "text-red-500 line-through", children: userValue }), _jsx("span", { className: "text-gray-400", children: "\u00E2\u2020\u2019" }), _jsx("span", { className: "text-blue-600", children: correctValue })] }))] }));
|
|
42
|
+
const getPerBlankStatus = (overall, userValue, accepted) => {
|
|
43
|
+
if (overall === 'unanswered' || !userValue.trim())
|
|
44
|
+
return 'unanswered';
|
|
45
|
+
return isBlankCorrect(userValue, accepted) ? 'correct' : 'incorrect';
|
|
46
|
+
};
|
|
41
47
|
return (_jsxs("div", { className: "flex flex-col gap-6 h-full", children: [_jsxs("div", { className: "flex flex-col gap-4 pb-0 pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: true }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, hasExample: !!exampleText, isReviewMode: true }), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true })] }), _jsxs("div", { className: "flex flex-col gap-6 pb-6 pl-2 pt-2", children: [_jsx("h2", { className: "text-center text-3xl font-bold uppercase text-coral-500", children: title }), _jsx("div", { className: "flex flex-col gap-4", children: questions.map((question) => {
|
|
42
48
|
const status = getAnswerStatus(question.id);
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
const userValues = toBlankValues(answers[question.id], countBlanks(question.segments));
|
|
50
|
+
const renderBlank = (blankIndex) => {
|
|
51
|
+
const userValue = userValues[blankIndex] || '';
|
|
52
|
+
const accepted = question.acceptedAnswers[blankIndex];
|
|
53
|
+
return renderBlankAnswer(getPerBlankStatus(status, userValue, accepted), userValue, formatAcceptedAnswers(accepted));
|
|
54
|
+
};
|
|
55
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [question.audioUrl && (_jsx("div", { className: "w-full max-w-md", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true }) })), question.imageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: `Question ${question.questionNumber} illustration`, className: "max-h-[150px] rounded-lg object-contain shadow-md" }) })), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-sm font-bold text-white", children: question.questionNumber }), _jsx("div", { className: "flex-1 text-base leading-relaxed text-gray-800", children: _jsxs("div", { className: "leading-loose", children: [question.segments.map((segment, segmentIndex) => segment.type === 'text' ? (_jsx("span", { children: segment.value }, `text-${segmentIndex}`)) : (_jsx("span", { children: renderBlank(segment.index) }, `blank-${segment.index}-${segmentIndex}`))), status === 'correct' && (_jsx("div", { className: "ml-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-green-500 align-middle", children: _jsx(Check, { className: "h-4 w-4 text-white", strokeWidth: 3 }) })), status === 'incorrect' && (_jsx("div", { className: "ml-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-red-500 align-middle", children: _jsx(X, { className: "h-4 w-4 text-white", strokeWidth: 3 }) })), status === 'unanswered' && (_jsx("div", { className: "ml-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-500 align-middle", children: _jsx(Check, { className: "h-4 w-4 text-white", strokeWidth: 3 }) }))] }) })] })] }, question.id));
|
|
47
56
|
}) })] })] }));
|
|
48
57
|
}
|
|
@@ -28,11 +28,11 @@ export function renderDedicatedReviewBody(ctx) {
|
|
|
28
28
|
switch (questionType) {
|
|
29
29
|
case 'FILL_IN_BLANK': {
|
|
30
30
|
const data = transformFillInBlank(apiQuestions);
|
|
31
|
-
return (_jsx(ReviewListenAndWriteRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.imageUrl, audioUrl: data.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap
|
|
31
|
+
return (_jsx(ReviewListenAndWriteRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.imageUrl, audioUrl: data.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap }));
|
|
32
32
|
}
|
|
33
33
|
case 'WRITE_CORRECT_VERB_FORM': {
|
|
34
34
|
const data = transformWriteCorrectVerbForm(apiQuestions);
|
|
35
|
-
return (_jsx(ReviewListenAndWriteRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, title: data.title, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap
|
|
35
|
+
return (_jsx(ReviewListenAndWriteRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, title: data.title, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap }));
|
|
36
36
|
}
|
|
37
37
|
case 'CHOOSE_THE_CORRECT_ANSWER': {
|
|
38
38
|
const data = transformChooseCorrectAnswer(apiQuestions);
|
|
@@ -40,7 +40,7 @@ export function renderDedicatedReviewBody(ctx) {
|
|
|
40
40
|
}
|
|
41
41
|
case 'CHOOSE_THE_CORRECT_ANSWER_GROUP': {
|
|
42
42
|
const data = transformChooseTheCorrectAnswerGroup(apiQuestions);
|
|
43
|
-
return (_jsx(ReviewChooseBestAnswerRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, correctOptionIdMap: correctOptionIdMap, example: data.example }));
|
|
43
|
+
return (_jsx(ReviewChooseBestAnswerRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, correctOptionIdMap: correctOptionIdMap, example: data.example }));
|
|
44
44
|
}
|
|
45
45
|
case 'CHOOSE_CORRECT_ADJECTIVE': {
|
|
46
46
|
const data = transformChooseCorrectAdjective(apiQuestions);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FILL_IN_BLANK — single source of truth for the whole flow.
|
|
3
|
+
*
|
|
4
|
+
* Wire format (never change one side without the other):
|
|
5
|
+
* content.question "A dog {0} swim, but it {1} fly."
|
|
6
|
+
* correct_answer.answers string[][] — answers[i] = accepted equivalents for blank {i}
|
|
7
|
+
* student answer { answers: string[] } — one entry per blank, index-aligned
|
|
8
|
+
*
|
|
9
|
+
* Matching rule (mirrors the API `normalizeText`): trim, lowercase, collapse inner
|
|
10
|
+
* whitespace, keep diacritics. A blank is correct when the input equals ANY equivalent.
|
|
11
|
+
*/
|
|
12
|
+
export type FillBlankSegment = {
|
|
13
|
+
type: 'text';
|
|
14
|
+
value: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'blank';
|
|
17
|
+
index: number;
|
|
18
|
+
};
|
|
19
|
+
/** Split a stem into the text / blank segments used by every renderer. */
|
|
20
|
+
export declare function parseFillBlankSegments(text: string): FillBlankSegment[];
|
|
21
|
+
/**
|
|
22
|
+
* Renderer-facing parse: like `parseFillBlankSegments`, but guarantees at least
|
|
23
|
+
* one blank so a stem authored without `{0}` still gets an input box.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseFillBlankStem(text: string): FillBlankSegment[];
|
|
26
|
+
/** Number of input boxes a stem needs (highest placeholder index + 1). */
|
|
27
|
+
export declare function countBlanks(segments: FillBlankSegment[]): number;
|
|
28
|
+
/** Placeholder indices present in a stem, ascending and de-duplicated. */
|
|
29
|
+
export declare function extractPlaceholderIndices(text: string): number[];
|
|
30
|
+
/** Normalize one blank's stored value into a list of accepted equivalents. */
|
|
31
|
+
export declare function toAcceptedAnswers(value: unknown): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Normalize `correct_answer.answers` into `string[][]` padded to `blankCount`.
|
|
34
|
+
* Accepts the canonical `string[][]` and the legacy flat `string[]`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function normalizeAcceptedAnswers(raw: unknown, blankCount: number): string[][];
|
|
37
|
+
/** Normalize a stored student answer into one string per blank. */
|
|
38
|
+
export declare function toBlankValues(raw: unknown, blankCount: number): string[];
|
|
39
|
+
/** True when the student typed something into at least one blank. */
|
|
40
|
+
export declare function hasAnyBlankValue(raw: unknown): boolean;
|
|
41
|
+
/** Compare a student input against the accepted equivalents of one blank. */
|
|
42
|
+
export declare function isBlankCorrect(value: string, accepted: string[] | undefined): boolean;
|
|
43
|
+
/** Join equivalents for display, e.g. "is / 's". */
|
|
44
|
+
export declare function formatAcceptedAnswers(accepted: string[] | undefined): string;
|
|
45
|
+
/** First equivalent — used to prefill example questions and review hints. */
|
|
46
|
+
export declare function getPrimaryAnswer(accepted: string[] | undefined): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FILL_IN_BLANK — single source of truth for the whole flow.
|
|
3
|
+
*
|
|
4
|
+
* Wire format (never change one side without the other):
|
|
5
|
+
* content.question "A dog {0} swim, but it {1} fly."
|
|
6
|
+
* correct_answer.answers string[][] — answers[i] = accepted equivalents for blank {i}
|
|
7
|
+
* student answer { answers: string[] } — one entry per blank, index-aligned
|
|
8
|
+
*
|
|
9
|
+
* Matching rule (mirrors the API `normalizeText`): trim, lowercase, collapse inner
|
|
10
|
+
* whitespace, keep diacritics. A blank is correct when the input equals ANY equivalent.
|
|
11
|
+
*/
|
|
12
|
+
const PLACEHOLDER_PATTERN = /\{(\d+)\}/g;
|
|
13
|
+
/** Split a stem into the text / blank segments used by every renderer. */
|
|
14
|
+
export function parseFillBlankSegments(text) {
|
|
15
|
+
const segments = [];
|
|
16
|
+
const regex = new RegExp(PLACEHOLDER_PATTERN);
|
|
17
|
+
let lastIndex = 0;
|
|
18
|
+
let match;
|
|
19
|
+
while ((match = regex.exec(text)) !== null) {
|
|
20
|
+
if (match.index > lastIndex) {
|
|
21
|
+
segments.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
|
22
|
+
}
|
|
23
|
+
segments.push({ type: 'blank', index: Number.parseInt(match[1], 10) });
|
|
24
|
+
lastIndex = regex.lastIndex;
|
|
25
|
+
}
|
|
26
|
+
if (lastIndex < text.length) {
|
|
27
|
+
segments.push({ type: 'text', value: text.slice(lastIndex) });
|
|
28
|
+
}
|
|
29
|
+
return segments;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Renderer-facing parse: like `parseFillBlankSegments`, but guarantees at least
|
|
33
|
+
* one blank so a stem authored without `{0}` still gets an input box.
|
|
34
|
+
*/
|
|
35
|
+
export function parseFillBlankStem(text) {
|
|
36
|
+
const segments = parseFillBlankSegments(text);
|
|
37
|
+
if (!segments.some((segment) => segment.type === 'blank')) {
|
|
38
|
+
segments.push({ type: 'blank', index: 0 });
|
|
39
|
+
}
|
|
40
|
+
return segments;
|
|
41
|
+
}
|
|
42
|
+
/** Number of input boxes a stem needs (highest placeholder index + 1). */
|
|
43
|
+
export function countBlanks(segments) {
|
|
44
|
+
const highest = segments.reduce((max, segment) => (segment.type === 'blank' ? Math.max(max, segment.index) : max), -1);
|
|
45
|
+
return highest + 1 || 1;
|
|
46
|
+
}
|
|
47
|
+
/** Placeholder indices present in a stem, ascending and de-duplicated. */
|
|
48
|
+
export function extractPlaceholderIndices(text) {
|
|
49
|
+
const indices = new Set();
|
|
50
|
+
for (const segment of parseFillBlankSegments(text || '')) {
|
|
51
|
+
if (segment.type === 'blank')
|
|
52
|
+
indices.add(segment.index);
|
|
53
|
+
}
|
|
54
|
+
return [...indices].sort((a, b) => a - b);
|
|
55
|
+
}
|
|
56
|
+
/** Normalize one blank's stored value into a list of accepted equivalents. */
|
|
57
|
+
export function toAcceptedAnswers(value) {
|
|
58
|
+
const list = Array.isArray(value) ? value : [value];
|
|
59
|
+
return list.map((item) => String(item ?? '').trim()).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Normalize `correct_answer.answers` into `string[][]` padded to `blankCount`.
|
|
63
|
+
* Accepts the canonical `string[][]` and the legacy flat `string[]`.
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeAcceptedAnswers(raw, blankCount) {
|
|
66
|
+
const source = Array.isArray(raw) ? raw : [raw];
|
|
67
|
+
return Array.from({ length: blankCount }, (_, index) => toAcceptedAnswers(source[index]));
|
|
68
|
+
}
|
|
69
|
+
/** Normalize a stored student answer into one string per blank. */
|
|
70
|
+
export function toBlankValues(raw, blankCount) {
|
|
71
|
+
const source = Array.isArray(raw) ? raw : [raw];
|
|
72
|
+
return Array.from({ length: blankCount }, (_, index) => String(source[index] ?? ''));
|
|
73
|
+
}
|
|
74
|
+
/** True when the student typed something into at least one blank. */
|
|
75
|
+
export function hasAnyBlankValue(raw) {
|
|
76
|
+
const source = Array.isArray(raw) ? raw : [raw];
|
|
77
|
+
return source.some((value) => String(value ?? '').trim().length > 0);
|
|
78
|
+
}
|
|
79
|
+
function normalizeForCompare(value) {
|
|
80
|
+
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
81
|
+
}
|
|
82
|
+
/** Compare a student input against the accepted equivalents of one blank. */
|
|
83
|
+
export function isBlankCorrect(value, accepted) {
|
|
84
|
+
const input = normalizeForCompare(value ?? '');
|
|
85
|
+
if (!input || !accepted?.length)
|
|
86
|
+
return false;
|
|
87
|
+
return accepted.some((answer) => normalizeForCompare(answer) === input);
|
|
88
|
+
}
|
|
89
|
+
/** Join equivalents for display, e.g. "is / 's". */
|
|
90
|
+
export function formatAcceptedAnswers(accepted) {
|
|
91
|
+
return (accepted ?? []).map((answer) => answer.trim()).filter(Boolean).join(' / ');
|
|
92
|
+
}
|
|
93
|
+
/** First equivalent — used to prefill example questions and review hints. */
|
|
94
|
+
export function getPrimaryAnswer(accepted) {
|
|
95
|
+
return (accepted ?? []).map((answer) => answer.trim()).find(Boolean) || '';
|
|
96
|
+
}
|
|
@@ -66,19 +66,80 @@ const reversePictureChoice = (apiQuestion) => {
|
|
|
66
66
|
const reverseFillInBlank = (apiQuestion) => {
|
|
67
67
|
const content = asRecord(apiQuestion.content);
|
|
68
68
|
const answer = readAnswer(apiQuestion);
|
|
69
|
-
const
|
|
69
|
+
const toBlankAlts = (value) => {
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
const cleaned = value
|
|
72
|
+
.map((item) => (item == null ? '' : String(item).trim()))
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
return cleaned.length > 0 ? cleaned : [''];
|
|
75
|
+
}
|
|
76
|
+
if (value == null)
|
|
77
|
+
return [''];
|
|
78
|
+
const text = String(value).trim();
|
|
79
|
+
return text ? [text] : [''];
|
|
80
|
+
};
|
|
81
|
+
const toAnswersMatrix = (raw, blankCount) => {
|
|
82
|
+
let result = [];
|
|
83
|
+
if (Array.isArray(raw)) {
|
|
84
|
+
result = raw.map((item) => toBlankAlts(item));
|
|
85
|
+
}
|
|
86
|
+
if (typeof blankCount === 'number' && blankCount > 0) {
|
|
87
|
+
while (result.length < blankCount) {
|
|
88
|
+
result.push(['']);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
};
|
|
93
|
+
if (content.question && Array.isArray(answer.answers)) {
|
|
94
|
+
return {
|
|
95
|
+
content: '',
|
|
96
|
+
answer: {
|
|
97
|
+
question: asString(content.question),
|
|
98
|
+
answers: toAnswersMatrix(answer.answers),
|
|
99
|
+
caseSensitive: Boolean(answer.caseSensitive ?? false),
|
|
100
|
+
imageUrl: asString(content.imageUrl),
|
|
101
|
+
audioUrl: asString(content.audioUrl),
|
|
102
|
+
showHints: Boolean(content.showHints ?? false),
|
|
103
|
+
hints: asArray(content.hints),
|
|
104
|
+
explanation: asString(apiQuestion.explanation),
|
|
105
|
+
points: readPoints(apiQuestion),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const blanks = asArray(content.blanks);
|
|
110
|
+
const firstBlank = asRecord(blanks[0]);
|
|
111
|
+
const answersData = answer.answers ?? {};
|
|
112
|
+
let answersMatrix = [];
|
|
113
|
+
if (Array.isArray(answersData)) {
|
|
114
|
+
answersMatrix = toAnswersMatrix(answersData, blanks.length || undefined);
|
|
115
|
+
}
|
|
116
|
+
else if (typeof answersData === 'object' && answersData !== null) {
|
|
117
|
+
answersMatrix = blanks.map((blank) => toBlankAlts(asRecord(answersData)[asString(asRecord(blank).id)]));
|
|
118
|
+
}
|
|
70
119
|
return {
|
|
71
120
|
content: '',
|
|
72
121
|
answer: {
|
|
73
122
|
question: asString(content.question ?? firstBlank.text),
|
|
74
|
-
answers:
|
|
123
|
+
answers: answersMatrix,
|
|
75
124
|
caseSensitive: Boolean(answer.caseSensitive ?? false),
|
|
76
125
|
imageUrl: asString(content.imageUrl),
|
|
126
|
+
audioUrl: asString(content.audioUrl),
|
|
77
127
|
showHints: Boolean(content.showHints ?? false),
|
|
78
128
|
hints: asArray(content.hints),
|
|
79
129
|
explanation: asString(apiQuestion.explanation),
|
|
80
130
|
points: readPoints(apiQuestion),
|
|
81
|
-
blanks:
|
|
131
|
+
blanks: blanks.map((rawBlank, index) => {
|
|
132
|
+
const blank = asRecord(rawBlank);
|
|
133
|
+
const alts = typeof answersData === 'object' && !Array.isArray(answersData)
|
|
134
|
+
? toBlankAlts(asRecord(answersData)[asString(blank.id)])
|
|
135
|
+
: answersMatrix[index] || [''];
|
|
136
|
+
return {
|
|
137
|
+
id: blank.id,
|
|
138
|
+
text: blank.text,
|
|
139
|
+
correctAnswer: alts[0] || '',
|
|
140
|
+
correctAnswers: alts,
|
|
141
|
+
};
|
|
142
|
+
}),
|
|
82
143
|
},
|
|
83
144
|
};
|
|
84
145
|
};
|
|
@@ -181,6 +242,7 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
|
|
|
181
242
|
});
|
|
182
243
|
const isExample = item.isExample === true;
|
|
183
244
|
const imageUrl = normalizeChooseAnswerGroupImageUrl(itemContent.imageUrl);
|
|
245
|
+
const audioUrl = asString(itemContent.audioUrl).trim();
|
|
184
246
|
return {
|
|
185
247
|
question: asString(itemContent.question),
|
|
186
248
|
optionType: asString(itemContent.optionType, 'text') === 'image' ? 'image' : 'text',
|
|
@@ -194,13 +256,16 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
|
|
|
194
256
|
points: isExample ? 0 : asNumber(item.points, 1),
|
|
195
257
|
questionNumber: asNumber(item.questionNumber, index + 1),
|
|
196
258
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
259
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
197
260
|
};
|
|
198
261
|
});
|
|
262
|
+
const audioUrl = asString(asRecord(content.meta).audioUrl).trim();
|
|
199
263
|
return {
|
|
200
264
|
content: '',
|
|
201
265
|
answer: {
|
|
202
266
|
instruction: asString(asRecord(content.meta).instruction, 'Choose the correct answer.'),
|
|
203
267
|
passage: asString(asRecord(content.meta).passage),
|
|
268
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
204
269
|
items,
|
|
205
270
|
explanation: asString(apiQuestion.explanation),
|
|
206
271
|
points: items.reduce((total, item) => (item.isExample ? total : total + item.points), 0),
|
|
@@ -8,6 +8,7 @@ export interface ChooseTheCorrectAnswerGroupItemContent {
|
|
|
8
8
|
options: ChooseTheCorrectAnswerGroupOption[];
|
|
9
9
|
optionType?: 'text' | 'image';
|
|
10
10
|
imageUrl?: string | string[];
|
|
11
|
+
audioUrl?: string;
|
|
11
12
|
}
|
|
12
13
|
export interface ChooseTheCorrectAnswerGroupItem {
|
|
13
14
|
questionType?: 'CHOOSE_THE_CORRECT_ANSWER';
|
|
@@ -23,6 +24,7 @@ export interface ChooseTheCorrectAnswerGroupContent {
|
|
|
23
24
|
meta?: {
|
|
24
25
|
instruction?: string;
|
|
25
26
|
passage?: string;
|
|
27
|
+
audioUrl?: string;
|
|
26
28
|
};
|
|
27
29
|
items: ChooseTheCorrectAnswerGroupItem[];
|
|
28
30
|
}
|