@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.
Files changed (30) hide show
  1. package/dist/components/exams/take/components/QuestionRenderer.js +1 -1
  2. package/dist/components/exams/take/components/question-renderers/MoversChooseBestAnswerRenderer.js +2 -2
  3. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
  4. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
  5. package/dist/components/exams/take/types.d.ts +12 -5
  6. package/dist/components/exams/take/utils/answer-transformers.js +1 -1
  7. package/dist/components/exams/take/utils/question-transformers.d.ts +1 -0
  8. package/dist/components/exams/take/utils/question-transformers.js +29 -53
  9. package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +2 -0
  10. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
  11. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +13 -3
  12. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +7 -3
  13. package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +13 -0
  14. package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +5 -0
  15. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
  16. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
  17. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
  18. package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
  19. package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
  20. package/dist/components/questions/types/fill-in-blank/register.js +19 -6
  21. package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
  22. package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.js +1 -1
  23. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
  24. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
  25. package/dist/components/results/renderers/review-question-body-dedicated.js +3 -3
  26. package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
  27. package/dist/shared/lib/utils/fill-in-blank.js +96 -0
  28. package/dist/shared/lib/utils/question-reverse-transform.js +68 -3
  29. package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +2 -0
  30. 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] || blank.correctAnswer || '',
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
- function extractPlaceholderIndices(question) {
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: validAnswers,
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 && blank.correctAnswer) {
71
- acc[blank.id] = blank.correctAnswer;
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, 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, correctAnswerTextMap, }: ReviewListenAndWriteRendererProps): import("react").JSX.Element;
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
- export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, correctAnswerTextMap, }) {
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 === null || userAnswer === undefined || 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 = (questionId) => {
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 userAnswer = answers[question.id] || '';
44
- return (_jsxs("div", { className: "flex flex-col gap-2", children: [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: "flex items-center gap-2", children: [question.textBefore && (_jsx("span", { children: question.textBefore })), _jsxs("span", { className: "inline-block min-w-[120px]", style: {
45
- borderBottom: `2px solid ${getUnderlineColor(question.id)}`,
46
- }, children: [status === 'correct' && (_jsx("span", { className: "block w-full text-center", children: userAnswer })), status === 'unanswered' && (_jsx("span", { className: "block w-full text-center text-blue-600", children: correctAnswerTextMap[question.id] })), status === 'incorrect' && (_jsxs("span", { className: "flex items-center justify-center gap-2", children: [_jsx("span", { className: "text-red-500 line-through", children: userAnswer }), _jsx("span", { className: "text-gray-400", children: "\u2192" }), _jsx("span", { className: "text-blue-600", children: correctAnswerTextMap[question.id] })] }))] }), question.textAfter && (_jsx("span", { children: question.textAfter })), status === 'correct' && (_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-green-500", children: _jsx(Check, { className: "h-4 w-4 text-white", strokeWidth: 3 }) })), status === 'incorrect' && (_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-red-500", children: _jsx(X, { className: "h-4 w-4 text-white", strokeWidth: 3 }) })), status === 'unanswered' && (_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-500", children: _jsx(Check, { className: "h-4 w-4 text-white", strokeWidth: 3 }) }))] }) })] })] }, question.id));
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, correctAnswerTextMap: correctAnswerTextMap }));
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, correctAnswerTextMap: correctAnswerTextMap }));
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 firstBlank = asRecord(asArray(content.blanks)[0]);
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: Array.isArray(answer.answers) ? answer.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: asArray(content.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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyweb_dev/oe-exam-sdk",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Reusable OceanEdu question components.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",