@tinyweb_dev/oe-exam-sdk 0.2.5 → 0.2.7

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 (91) hide show
  1. package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
  2. package/dist/components/exams/take/components/QuestionRenderer.js +10 -2
  3. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +22 -0
  4. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +60 -0
  5. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
  6. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
  7. package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.d.ts +13 -0
  8. package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.js +31 -0
  9. package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
  10. package/dist/components/exams/take/components/question-renderers/index.js +2 -0
  11. package/dist/components/exams/take/types.d.ts +12 -5
  12. package/dist/components/exams/take/utils/answer-transformers.js +17 -1
  13. package/dist/components/exams/take/utils/question-transformers.d.ts +45 -1
  14. package/dist/components/exams/take/utils/question-transformers.js +109 -53
  15. package/dist/components/questions/_shared/config/question-types.config.js +12 -0
  16. package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +50 -0
  17. package/dist/components/questions/_shared/types/fill-in-blank-group.type.js +2 -0
  18. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
  19. package/dist/components/questions/_shared/types/index.d.ts +2 -0
  20. package/dist/components/questions/_shared/types/index.js +2 -0
  21. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +40 -0
  22. package/dist/components/questions/_shared/types/matching-with-lines-group.type.js +1 -0
  23. package/dist/components/questions/components/QuestionSearchDropdown.js +4 -0
  24. package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
  25. package/dist/components/questions/creator/question-type-registry.js +4 -0
  26. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
  27. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
  28. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
  29. package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
  30. package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
  31. package/dist/components/questions/types/fill-in-blank/register.js +19 -6
  32. package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
  33. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.d.ts +2 -0
  34. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +64 -0
  35. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.d.ts +2 -0
  36. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +158 -0
  37. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.d.ts +7 -0
  38. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.js +30 -0
  39. package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +20 -0
  40. package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +99 -0
  41. package/dist/components/questions/types/fill-in-blank-group/index.d.ts +5 -0
  42. package/dist/components/questions/types/fill-in-blank-group/index.js +5 -0
  43. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.d.ts +4 -0
  44. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +102 -0
  45. package/dist/components/questions/types/fill-in-blank-group/register.d.ts +3 -0
  46. package/dist/components/questions/types/fill-in-blank-group/register.js +37 -0
  47. package/dist/components/questions/types/fill-in-blank-group/transform.d.ts +2 -0
  48. package/dist/components/questions/types/fill-in-blank-group/transform.js +68 -0
  49. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +20 -0
  50. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +109 -0
  51. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.d.ts +2 -0
  52. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +30 -0
  53. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.d.ts +2 -0
  54. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +180 -0
  55. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.d.ts +6 -0
  56. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.js +26 -0
  57. package/dist/components/questions/types/matching-with-lines-group/index.d.ts +4 -0
  58. package/dist/components/questions/types/matching-with-lines-group/index.js +4 -0
  59. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.d.ts +8 -0
  60. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +104 -0
  61. package/dist/components/questions/types/matching-with-lines-group/register.d.ts +3 -0
  62. package/dist/components/questions/types/matching-with-lines-group/register.js +37 -0
  63. package/dist/components/questions/types/matching-with-lines-group/transform.d.ts +2 -0
  64. package/dist/components/questions/types/matching-with-lines-group/transform.js +44 -0
  65. package/dist/components/questions/viewer/FillInBlankGroupViewer.d.ts +10 -0
  66. package/dist/components/questions/viewer/FillInBlankGroupViewer.js +14 -0
  67. package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.d.ts +7 -0
  68. package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.js +48 -0
  69. package/dist/components/questions/viewer/QuestionViewer.js +6 -0
  70. package/dist/components/questions/viewer/index.d.ts +2 -0
  71. package/dist/components/questions/viewer/index.js +2 -0
  72. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
  73. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
  74. package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
  75. package/dist/components/results/renderers/review-question-body-movers.js +37 -2
  76. package/dist/components/results/review-data.js +37 -0
  77. package/dist/index.d.ts +1 -1
  78. package/dist/index.js +1 -1
  79. package/dist/shared/constants/question-skills.js +4 -0
  80. package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
  81. package/dist/shared/lib/utils/fill-in-blank.js +96 -0
  82. package/dist/shared/lib/utils/question-reverse-transform.js +150 -3
  83. package/dist/shared/lib/utils/question-transform.js +4 -0
  84. package/dist/shared/types/common.types.d.ts +1 -1
  85. package/dist/shared/types/questions/fill-in-blank-group.d.ts +45 -0
  86. package/dist/shared/types/questions/fill-in-blank-group.js +1 -0
  87. package/dist/shared/types/questions/index.d.ts +2 -0
  88. package/dist/shared/types/questions/index.js +4 -0
  89. package/dist/shared/types/questions/matching-with-lines-group.d.ts +39 -0
  90. package/dist/shared/types/questions/matching-with-lines-group.js +1 -0
  91. package/package.json +1 -1
@@ -0,0 +1,104 @@
1
+ import { MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/matching-with-lines-group.type';
2
+ function isRecord(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ function asRecord(value) {
6
+ return isRecord(value) ? value : {};
7
+ }
8
+ function asString(value, fallback = '') {
9
+ return typeof value === 'string' ? value : fallback;
10
+ }
11
+ export function nextRightId(existing) {
12
+ let n = existing.length + 1;
13
+ const used = new Set(existing);
14
+ while (used.has(`r${n}`))
15
+ n += 1;
16
+ return `r${n}`;
17
+ }
18
+ export function createEmptyMatchingPair(questionNumber, rightId) {
19
+ return {
20
+ rightItem: { id: rightId, text: '' },
21
+ item: {
22
+ imageUrl: '',
23
+ correctAnswer: rightId,
24
+ isExample: false,
25
+ points: 1,
26
+ questionNumber,
27
+ },
28
+ };
29
+ }
30
+ export function mapMatchingWithLinesGroupItem(item, index, fallbackAnswer) {
31
+ const record = asRecord(item);
32
+ const content = asRecord(record.content);
33
+ const correctAnswer = asRecord(record.correctAnswer);
34
+ const isExample = record.isExample === true;
35
+ const answer = asString(correctAnswer.answer) ||
36
+ (typeof fallbackAnswer === 'string' ? fallbackAnswer : '');
37
+ return {
38
+ imageUrl: asString(content.imageUrl) || asString(record.imageUrl),
39
+ correctAnswer: answer,
40
+ isExample,
41
+ points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
42
+ questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
43
+ };
44
+ }
45
+ function mapRightItems(raw) {
46
+ if (!Array.isArray(raw))
47
+ return [];
48
+ return raw.map((item, index) => {
49
+ const record = asRecord(item);
50
+ return {
51
+ id: asString(record.id, `r${index + 1}`),
52
+ text: asString(record.text),
53
+ };
54
+ });
55
+ }
56
+ export function mapQuestionToMatchingWithLinesGroupData(question) {
57
+ const source = asRecord(question);
58
+ const content = asRecord(source.content);
59
+ const answer = asRecord(source.answer);
60
+ const correctAnswer = asRecord(source.correctAnswer ?? source.correct_answer ?? answer.correctAnswer);
61
+ const fallbackAnswers = Array.isArray(correctAnswer.answer)
62
+ ? correctAnswer.answer
63
+ : Array.isArray(answer.answer)
64
+ ? answer.answer
65
+ : [];
66
+ const explanation = asString(source.explanation) || asString(answer.explanation);
67
+ const meta = asRecord(content.meta ?? answer.meta);
68
+ if (Array.isArray(content.items)) {
69
+ const items = content.items.map((item, index) => mapMatchingWithLinesGroupItem(item, index, fallbackAnswers[index]));
70
+ return {
71
+ title: asString(meta.title) || asString(answer.title),
72
+ instruction: asString(meta.instruction) ||
73
+ asString(answer.instruction) ||
74
+ MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
75
+ rightItems: mapRightItems(content.rightItems),
76
+ items,
77
+ explanation,
78
+ points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
79
+ };
80
+ }
81
+ if (Array.isArray(answer.items)) {
82
+ const items = answer.items.map((item, index) => mapMatchingWithLinesGroupItem(item, index, fallbackAnswers[index]));
83
+ return {
84
+ title: asString(answer.title),
85
+ instruction: asString(answer.instruction) || MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
86
+ rightItems: mapRightItems(answer.rightItems),
87
+ items,
88
+ explanation,
89
+ points: typeof answer.points === 'number'
90
+ ? answer.points
91
+ : items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
92
+ };
93
+ }
94
+ const first = createEmptyMatchingPair(1, 'r1');
95
+ const second = createEmptyMatchingPair(2, 'r2');
96
+ return {
97
+ title: '',
98
+ instruction: MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
99
+ rightItems: [first.rightItem, second.rightItem],
100
+ items: [first.item, second.item],
101
+ explanation,
102
+ points: 2,
103
+ };
104
+ }
@@ -0,0 +1,3 @@
1
+ import type { QuestionTypeRegistration } from '../../creator/question-type-registry';
2
+ export { createEmptyMatchingPair, mapMatchingWithLinesGroupItem, mapQuestionToMatchingWithLinesGroupData, nextRightId, } from './map-matching-with-lines-group-data';
3
+ export declare const matchingWithLinesGroupRegistration: QuestionTypeRegistration;
@@ -0,0 +1,37 @@
1
+ import { MatchingWithLinesGroupCreator } from './MatchingWithLinesGroupCreator';
2
+ import { mapQuestionToMatchingWithLinesGroupData } from './map-matching-with-lines-group-data';
3
+ export { createEmptyMatchingPair, mapMatchingWithLinesGroupItem, mapQuestionToMatchingWithLinesGroupData, nextRightId, } from './map-matching-with-lines-group-data';
4
+ export const matchingWithLinesGroupRegistration = {
5
+ component: MatchingWithLinesGroupCreator,
6
+ transformInitialData: (initialData) => mapQuestionToMatchingWithLinesGroupData(initialData),
7
+ getExtraProps: (ctx) => ({
8
+ onUnsavedChangesChange: ctx.onUnsavedChangesChange,
9
+ validationRef: ctx.validationRef,
10
+ }),
11
+ wrapOnSave: (data, ctx) => {
12
+ const payload = data;
13
+ return {
14
+ type: ctx.questionType,
15
+ content: ctx.state.content,
16
+ points: payload.points ?? ctx.state.points ?? 1,
17
+ level: ctx.state.level,
18
+ difficulty: ctx.state.difficulty,
19
+ skill: ctx.state.skill,
20
+ answer: payload,
21
+ explanation: payload.explanation || '',
22
+ };
23
+ },
24
+ wrapOnChange: (data, ctx) => {
25
+ const payload = data;
26
+ return {
27
+ type: ctx.questionType,
28
+ content: ctx.state.content,
29
+ points: payload.points ?? ctx.state.points ?? 1,
30
+ level: ctx.state.level,
31
+ difficulty: ctx.state.difficulty,
32
+ skill: ctx.state.skill,
33
+ answer: payload,
34
+ explanation: payload.explanation || '',
35
+ };
36
+ },
37
+ };
@@ -0,0 +1,2 @@
1
+ import type { TransformHandler } from '../../../../shared/lib/utils/question-transform-types';
2
+ export declare const transformMatchingWithLinesGroup: TransformHandler;
@@ -0,0 +1,44 @@
1
+ import { MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/matching-with-lines-group.type';
2
+ function sumGradablePoints(items) {
3
+ return items.reduce((total, item) => {
4
+ if (item.isExample)
5
+ return total;
6
+ const points = typeof item.points === 'number' ? item.points : 1;
7
+ return total + points;
8
+ }, 0);
9
+ }
10
+ export const transformMatchingWithLinesGroup = (question) => {
11
+ const answerData = question.answer;
12
+ const items = Array.isArray(answerData?.items) ? answerData.items : [];
13
+ const rightItems = Array.isArray(answerData?.rightItems) ? answerData.rightItems : [];
14
+ if (items.length === 0) {
15
+ return { apiContent: { rightItems: [], items: [] }, apiCorrectAnswer: { answer: [] } };
16
+ }
17
+ const apiItems = items.map((item, index) => ({
18
+ ...(item.isExample ? { isExample: true } : {}),
19
+ content: {
20
+ imageUrl: item.imageUrl || '',
21
+ },
22
+ correctAnswer: { answer: item.correctAnswer || '' },
23
+ points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
24
+ questionNumber: item.questionNumber || index + 1,
25
+ }));
26
+ const title = typeof answerData?.title === 'string' ? answerData.title.trim() : '';
27
+ return {
28
+ apiContent: {
29
+ meta: {
30
+ instruction: answerData?.instruction || MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
31
+ ...(title ? { title } : {}),
32
+ },
33
+ rightItems: rightItems.map((item) => ({
34
+ id: item.id || '',
35
+ text: item.text || '',
36
+ })),
37
+ items: apiItems,
38
+ },
39
+ apiCorrectAnswer: {
40
+ answer: apiItems.map((item) => item.correctAnswer.answer),
41
+ },
42
+ totalPoints: sumGradablePoints(items),
43
+ };
44
+ };
@@ -0,0 +1,10 @@
1
+ import type { Question } from '../../../shared/types/entities/question.types';
2
+ import type { FillInBlankGroupStudentItemAnswer } from '../_shared/types/fill-in-blank-group.type';
3
+ type FillInBlankGroupViewerProps = {
4
+ question: Question;
5
+ isReviewMode?: boolean;
6
+ userAnswer?: FillInBlankGroupStudentItemAnswer[];
7
+ onSubmitAnswer?: (answer: FillInBlankGroupStudentItemAnswer[]) => void;
8
+ };
9
+ export declare function FillInBlankGroupViewer({ question, isReviewMode, userAnswer, onSubmitAnswer, }: FillInBlankGroupViewerProps): import("react").JSX.Element;
10
+ export {};
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { FillInBlankGroupClient } from '../types/fill-in-blank-group/FillInBlankGroupClient';
4
+ import { mapQuestionToFillInBlankGroupData } from '../types/fill-in-blank-group/map-fill-in-blank-group-data';
5
+ export function FillInBlankGroupViewer({ question, isReviewMode = false, userAnswer, onSubmitAnswer, }) {
6
+ const questionData = mapQuestionToFillInBlankGroupData(question);
7
+ const studentAnswers = Array.isArray(userAnswer) ? userAnswer : undefined;
8
+ const reviewAnswers = isReviewMode && !studentAnswers
9
+ ? questionData.items.map((item) => ({
10
+ answers: item.answers.map((alts) => alts[0] || ''),
11
+ }))
12
+ : studentAnswers;
13
+ return (_jsx(FillInBlankGroupClient, { questionData: questionData, isReviewMode: isReviewMode, userAnswers: reviewAnswers, onAnswerChange: isReviewMode ? undefined : onSubmitAnswer }));
14
+ }
@@ -0,0 +1,7 @@
1
+ import type { Question } from '../../../shared/types/entities/question.types';
2
+ export declare function MatchingWithLinesGroupViewer({ question, isReviewMode, userAnswer, onSubmitAnswer, }: {
3
+ question: Question;
4
+ isReviewMode?: boolean;
5
+ userAnswer?: unknown;
6
+ onSubmitAnswer?: (answer: string[]) => void;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { MatchingWithLinesGroupClient } from '../types/matching-with-lines-group/MatchingWithLinesGroupClient';
5
+ import { mapQuestionToMatchingWithLinesGroupData } from '../types/matching-with-lines-group/map-matching-with-lines-group-data';
6
+ function isRecord(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ function emptyAnswers(length) {
10
+ return Array.from({ length }, () => '');
11
+ }
12
+ function parseMatchingAnswers(value, length) {
13
+ const source = Array.isArray(value)
14
+ ? value
15
+ : isRecord(value) && Array.isArray(value.answer)
16
+ ? value.answer
17
+ : null;
18
+ if (!source)
19
+ return undefined;
20
+ return Array.from({ length }, (_, index) => typeof source[index] === 'string' ? source[index] : '');
21
+ }
22
+ export function MatchingWithLinesGroupViewer({ question, isReviewMode = false, userAnswer, onSubmitAnswer, }) {
23
+ const questionData = mapQuestionToMatchingWithLinesGroupData(question);
24
+ const itemCount = questionData.items.length;
25
+ const parsedUser = parseMatchingAnswers(userAnswer, itemCount);
26
+ const questionKey = `${question.id}:${question.type}:${itemCount}:${questionData.rightItems
27
+ .map((item) => `${item.id}:${item.text}`)
28
+ .join(',')}`;
29
+ const [cachedKey, setCachedKey] = useState(questionKey);
30
+ const [localAnswers, setLocalAnswers] = useState(() => parsedUser ?? emptyAnswers(itemCount));
31
+ if (cachedKey !== questionKey) {
32
+ setCachedKey(questionKey);
33
+ setLocalAnswers(parsedUser ?? emptyAnswers(itemCount));
34
+ }
35
+ const userAnswers = isReviewMode && !parsedUser
36
+ ? questionData.items.map((item) => item.correctAnswer)
37
+ : parsedUser ?? localAnswers;
38
+ const handleAnswersChange = (next) => {
39
+ const padded = questionData.items.map((item, index) => {
40
+ if (item.isExample)
41
+ return item.correctAnswer || '';
42
+ return typeof next[index] === 'string' ? next[index] : '';
43
+ });
44
+ setLocalAnswers(padded);
45
+ onSubmitAnswer?.(padded);
46
+ };
47
+ return (_jsx(MatchingWithLinesGroupClient, { questionData: questionData, isReviewMode: isReviewMode, userAnswers: userAnswers, onAnswersChange: isReviewMode ? undefined : handleAnswersChange }));
48
+ }
@@ -26,6 +26,8 @@ import { WordFillStructuredFormViewer } from './WordFillStructuredFormViewer';
26
26
  import { ChooseTheCorrectAnswerGroupViewer } from './ChooseTheCorrectAnswerGroupViewer';
27
27
  import { TrueFalseGroupViewer } from './TrueFalseGroupViewer';
28
28
  import { TrueFalseCorrectGroupViewer } from './TrueFalseCorrectGroupViewer';
29
+ import { FillInBlankGroupViewer } from './FillInBlankGroupViewer';
30
+ import { MatchingWithLinesGroupViewer } from './MatchingWithLinesGroupViewer';
29
31
  import { CrossOutWordGroupViewer } from './CrossOutWordGroupViewer';
30
32
  import { SpontaneousQaGroupViewer } from './SpontaneousQaGroupViewer';
31
33
  import { WordOrderAndMatchGroupViewer } from './WordOrderAndMatchGroupViewer';
@@ -635,6 +637,10 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
635
637
  return (_jsx(TrueFalseGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer }));
636
638
  case 'TRUE_FALSE_CORRECT_GROUP':
637
639
  return (_jsx(TrueFalseCorrectGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer }));
640
+ case 'FILL_IN_BLANK_GROUP':
641
+ return (_jsx(FillInBlankGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
642
+ case 'MATCHING_WITH_LINES_GROUP':
643
+ return (_jsx(MatchingWithLinesGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
638
644
  case 'CROSS_OUT_WORD_GROUP':
639
645
  return (_jsx(CrossOutWordGroupViewer, { question: question, isReviewMode: isReviewMode }));
640
646
  case 'ANSWER_THE_QUESTION_GROUP':
@@ -4,6 +4,8 @@ export { WordFillStructuredFormViewer } from './WordFillStructuredFormViewer';
4
4
  export { ChooseTheCorrectAnswerGroupViewer } from './ChooseTheCorrectAnswerGroupViewer';
5
5
  export { TrueFalseGroupViewer } from './TrueFalseGroupViewer';
6
6
  export { TrueFalseCorrectGroupViewer } from './TrueFalseCorrectGroupViewer';
7
+ export { FillInBlankGroupViewer } from './FillInBlankGroupViewer';
8
+ export { MatchingWithLinesGroupViewer } from './MatchingWithLinesGroupViewer';
7
9
  export { CrossOutWordGroupViewer } from './CrossOutWordGroupViewer';
8
10
  export { SpontaneousQaGroupViewer } from './SpontaneousQaGroupViewer';
9
11
  export { WordOrderAndMatchGroupViewer } from './WordOrderAndMatchGroupViewer';
@@ -4,6 +4,8 @@ export { WordFillStructuredFormViewer } from './WordFillStructuredFormViewer';
4
4
  export { ChooseTheCorrectAnswerGroupViewer } from './ChooseTheCorrectAnswerGroupViewer';
5
5
  export { TrueFalseGroupViewer } from './TrueFalseGroupViewer';
6
6
  export { TrueFalseCorrectGroupViewer } from './TrueFalseCorrectGroupViewer';
7
+ export { FillInBlankGroupViewer } from './FillInBlankGroupViewer';
8
+ export { MatchingWithLinesGroupViewer } from './MatchingWithLinesGroupViewer';
7
9
  export { CrossOutWordGroupViewer } from './CrossOutWordGroupViewer';
8
10
  export { SpontaneousQaGroupViewer } from './SpontaneousQaGroupViewer';
9
11
  export { WordOrderAndMatchGroupViewer } from './WordOrderAndMatchGroupViewer';
@@ -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);
@@ -4,8 +4,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
4
4
  * Movers fallback review cases (isReviewMode=true).
5
5
  * Source: oe-exam-fe ReviewQuestionRenderer.tsx
6
6
  */
7
- import { MoversAnswerTheQuestionRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversLabelThePictureRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversCrossOutWordGroupRenderer, MoversWordFillParagraphRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderingRenderer, MoversWriteSentencesRenderer, MoversWritingRenderer, MoversCrosswordPuzzleRenderer, } from '../../../components/exams/take/components/question-renderers';
8
- import { transformAnswerTheQuestion, transformFillWordHint, transformGridFill, transformLabelPicture, transformMatchByWritingAnswer, transformMatchWordToPicture, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformTrueFalse, transformTrueFalseCorrectGroup, transformWordFillParagraph, transformWordFillStructuredForm, transformWordOrdering, transformWriteSentences, transformWriteShortLetter, transformCrosswordPuzzle, } from '../../../components/exams/take/utils/question-transformers';
7
+ import { MoversAnswerTheQuestionRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversLabelThePictureRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversFillInBlankGroupRenderer, MoversMatchingWithLinesGroupRenderer, MoversCrossOutWordGroupRenderer, MoversWordFillParagraphRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderingRenderer, MoversWriteSentencesRenderer, MoversWritingRenderer, MoversCrosswordPuzzleRenderer, } from '../../../components/exams/take/components/question-renderers';
8
+ import { transformAnswerTheQuestion, transformFillWordHint, transformGridFill, transformLabelPicture, transformMatchByWritingAnswer, transformMatchWordToPicture, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformTrueFalse, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformMatchingWithLinesGroup, transformWordFillParagraph, transformWordFillStructuredForm, transformWordOrdering, transformWriteSentences, transformWriteShortLetter, transformCrosswordPuzzle, } from '../../../components/exams/take/utils/question-transformers';
9
9
  import { normalizeCrossOutWordGroupAnswer, transformCrossOutWordGroup, } from '../../../components/exams/take/utils/cross-out-word-group.transformer';
10
10
  function asApiQuestions(questions) {
11
11
  return questions;
@@ -142,6 +142,41 @@ export function renderMoversReviewBody(ctx) {
142
142
  });
143
143
  return (_jsx(MoversTrueFalseCorrectGroupRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: questionCount, questions: data.questions, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true, correctAnswerMap: correctAnswerMap }));
144
144
  }
145
+ case 'FILL_IN_BLANK_GROUP': {
146
+ const data = transformFillInBlankGroup(apiQuestions);
147
+ const correctAnswerMap = {};
148
+ const parent = apiQuestions[0];
149
+ const rawCorrect = parent?.id ? rawCorrectAnswerMap[parent.id] : undefined;
150
+ const rawCorrectRecord = rawCorrect && typeof rawCorrect === 'object' && !Array.isArray(rawCorrect)
151
+ ? rawCorrect
152
+ : undefined;
153
+ const correctArr = Array.isArray(rawCorrectRecord?.answer)
154
+ ? rawCorrectRecord.answer
155
+ : Array.isArray(parent?.correct_answer?.answer)
156
+ ? parent.correct_answer.answer
157
+ : [];
158
+ data.questions.forEach((question, index) => {
159
+ const rawItem = correctArr[index];
160
+ if (!rawItem || typeof rawItem !== 'object' || Array.isArray(rawItem)) {
161
+ return;
162
+ }
163
+ const obj = rawItem;
164
+ if (!Array.isArray(obj.answers)) {
165
+ return;
166
+ }
167
+ correctAnswerMap[question.id] = {
168
+ answers: obj.answers.map((blank) => (Array.isArray(blank)
169
+ ? blank.map((alt) => String(alt ?? '').trim()).filter(Boolean)
170
+ : [String(blank ?? '').trim()].filter(Boolean))),
171
+ caseSensitive: obj.caseSensitive === true,
172
+ };
173
+ });
174
+ return (_jsx(MoversFillInBlankGroupRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: data.questions.filter((question) => !question.isExample).length, audioUrl: data.audioUrl, imageUrl: data.imageUrl, showHints: data.showHints, hints: data.hints, questions: data.questions, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true, correctAnswerMap: correctAnswerMap }));
175
+ }
176
+ case 'MATCHING_WITH_LINES_GROUP': {
177
+ const data = transformMatchingWithLinesGroup(apiQuestions);
178
+ return (_jsx(MoversMatchingWithLinesGroupRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: questionCount, data: data, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true }));
179
+ }
145
180
  case 'TRUE_FALSE': {
146
181
  const data = transformTrueFalse(apiQuestions);
147
182
  return (_jsx(MoversTrueFalseRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: questionCount, imageUrl: data.imageUrl, questions: data.questions, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true, correctAnswerTextMap: correctAnswerTextMap }));
@@ -9,6 +9,8 @@ const FLATTENED_ANSWER_TYPES = new Set([
9
9
  'ANSWER_THE_QUESTION_GROUP',
10
10
  'CROSS_OUT_WORD_GROUP',
11
11
  'TRUE_FALSE_CORRECT_GROUP',
12
+ 'FILL_IN_BLANK_GROUP',
13
+ 'MATCHING_WITH_LINES_GROUP',
12
14
  ]);
13
15
  export function transformResultAnswersForReview(answers, templateParts = []) {
14
16
  const groups = groupAnswers(answers);
@@ -205,6 +207,41 @@ function transformStudentAnswer(questionId, questionType, value) {
205
207
  return [`${questionId}-item-${index}`, { isTrue: null, correction: '' }];
206
208
  }));
207
209
  }
210
+ if (questionType === 'FILL_IN_BLANK_GROUP') {
211
+ const answer = Array.isArray(record.answer)
212
+ ? record.answer
213
+ : Array.isArray(value)
214
+ ? value
215
+ : [];
216
+ return Object.fromEntries(answer.map((item, index) => {
217
+ if (item && typeof item === 'object' && !Array.isArray(item) && Array.isArray(item.answers)) {
218
+ return [
219
+ `${questionId}-item-${index}`,
220
+ {
221
+ answers: item.answers.map((value) => String(value ?? '')),
222
+ },
223
+ ];
224
+ }
225
+ if (Array.isArray(item)) {
226
+ return [`${questionId}-item-${index}`, { answers: item.map((value) => String(value ?? '')) }];
227
+ }
228
+ if (typeof item === 'string') {
229
+ return [`${questionId}-item-${index}`, { answers: [item] }];
230
+ }
231
+ return [`${questionId}-item-${index}`, { answers: [] }];
232
+ }));
233
+ }
234
+ if (questionType === 'MATCHING_WITH_LINES_GROUP') {
235
+ const answer = Array.isArray(record.answer)
236
+ ? record.answer
237
+ : Array.isArray(value)
238
+ ? value
239
+ : [];
240
+ return Object.fromEntries(answer.map((item, index) => [
241
+ `${questionId}-item-${index}`,
242
+ typeof item === 'string' ? item : '',
243
+ ]));
244
+ }
208
245
  if (questionType === 'CHOOSE_THE_CORRECT_ANSWER' || questionType === 'CHOOSE_CORRECT_ADJECTIVE') {
209
246
  const rawAns = record.answer ?? record.optionId ?? record.optionIds;
210
247
  const ids = getOptionIds(questionId, rawAns);
package/dist/index.d.ts CHANGED
@@ -26,4 +26,4 @@ export { validateQuestion } from './components/questions/_shared/utils/question-
26
26
  export { buildAnswerPayload } from './components/questions/_shared/utils/answer-builder';
27
27
  export type { QuestionFormState } from './components/questions/_shared/hooks/useQuestionFormState';
28
28
  export * from './components/questions/_shared/types';
29
- export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, } from './components/questions/viewer';
29
+ export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, FillInBlankGroupViewer, MatchingWithLinesGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, } from './components/questions/viewer';
package/dist/index.js CHANGED
@@ -21,4 +21,4 @@ export { QUESTION_TYPE_CONFIG, QUESTION_TYPES_WITH_DEDICATED_COMPONENTS } from '
21
21
  export { validateQuestion } from './components/questions/_shared/utils/question-validation';
22
22
  export { buildAnswerPayload } from './components/questions/_shared/utils/answer-builder';
23
23
  export * from './components/questions/_shared/types';
24
- export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, } from './components/questions/viewer';
24
+ export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, FillInBlankGroupViewer, MatchingWithLinesGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, } from './components/questions/viewer';
@@ -45,6 +45,7 @@ export const QUESTION_TYPE_SKILLS = {
45
45
  'CHOOSE_THE_CORRECT_ANSWER_GROUP': SkillEnum.READING,
46
46
  'CHOOSE_CORRECT_ADJECTIVE': SkillEnum.READING,
47
47
  'FILL_IN_BLANK': SkillEnum.READING,
48
+ 'FILL_IN_BLANK_GROUP': SkillEnum.READING,
48
49
  'FILL_MISSING_WORDS_IN_GRID': SkillEnum.READING,
49
50
  'READ_PASSAGE_AND_ANSWER_QUESTIONS': SkillEnum.READING,
50
51
  'ANSWER_THE_QUESTION': SkillEnum.READING,
@@ -53,6 +54,7 @@ export const QUESTION_TYPE_SKILLS = {
53
54
  'WORD_FILL_PARAGRAPH': SkillEnum.READING,
54
55
  'MATCH_BY_WRITING_ANSWER': SkillEnum.READING,
55
56
  'MATCH_WORD_TO_PICTURE': SkillEnum.READING,
57
+ 'MATCHING_WITH_LINES_GROUP': SkillEnum.READING,
56
58
  // Visual-based reading questions
57
59
  'LABEL_THE_PICTURE': SkillEnum.READING,
58
60
  'LOOK_PICTURE_CHOOSE_CORRECT_ANSWER': SkillEnum.READING,
@@ -177,6 +179,7 @@ export const QUESTION_TYPE_LABELS = {
177
179
  'CHOOSE_THE_CORRECT_ANSWER_GROUP': 'Chọn đáp án đúng (nhóm)',
178
180
  'CHOOSE_CORRECT_ADJECTIVE': 'Chọn tính từ',
179
181
  'FILL_IN_BLANK': 'Điền vào chỗ trống',
182
+ 'FILL_IN_BLANK_GROUP': 'Điền chỗ trống (nhóm)',
180
183
  'FILL_MISSING_WORDS_IN_GRID': 'Điền từ vào lưới',
181
184
  'READ_PASSAGE_AND_ANSWER_QUESTIONS': 'Đọc hiểu - Trả lời',
182
185
  'LABEL_THE_PICTURE': 'Gắn nhãn hình ảnh',
@@ -209,6 +212,7 @@ export const QUESTION_TYPE_LABELS = {
209
212
  'WORD_FILL_PARAGRAPH': 'Điền từ vào đoạn văn',
210
213
  'MATCH_BY_WRITING_ANSWER': 'Ghép đáp án bằng cách viết',
211
214
  'MATCH_WORD_TO_PICTURE': 'Nối từ với tranh',
215
+ 'MATCHING_WITH_LINES_GROUP': 'Nối bằng đường kẻ (nhóm)',
212
216
  'WORD_FILL_STRUCTURED_FORM': 'Điền từ vào biểu mẫu có cấu trúc',
213
217
  'CROSSWORD_PUZZLE': 'Giải ô chữ (Crossword)',
214
218
  'FIND_WORDS_IN_MATRIX': 'Tìm từ trong ma trận (Word Search)',
@@ -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;