Skip to content

Commit 33325b9

Browse files
feat(client,challenge-parser): update fill-in-the-blank to support Chinese (freeCodeCamp#63741)
1 parent b6fff6e commit 33325b9

24 files changed

+964
-176
lines changed

client/gatsby-node.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ exports.createSchemaCustomization = ({ actions }) => {
410410
type FillInTheBlank {
411411
sentence: String
412412
blanks: [Blank]
413+
inputType: String
413414
}
414415
type Blank {
415416
answer: String

client/src/redux/prop-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type Question = {
4949
export type FillInTheBlank = {
5050
sentence: string;
5151
blanks: MultipleChoiceAnswer[];
52+
inputType?: 'pinyin-tone' | 'pinyin-to-hanzi';
5253
};
5354

5455
export type Fields = {

client/src/templates/Challenges/components/fill-in-the-blanks.tsx

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { Spacer } from '@freecodecamp/ui';
44

5-
import { parseBlanks } from '../fill-in-the-blank/parse-blanks';
5+
import { parseBlanks, parseAnswer } from '../fill-in-the-blank/parse-blanks';
66
import PrismFormatted from '../components/prism-formatted';
77
import { FillInTheBlank } from '../../../redux/prop-types';
88
import ChallengeHeading from './challenge-heading';
@@ -16,6 +16,23 @@ type FillInTheBlankProps = {
1616
handleInputChange: (inputIndex: number, value: string) => void;
1717
};
1818

19+
const AnswerText = ({ answer }: { answer: string }) => {
20+
const parsedAnswer = parseAnswer(answer);
21+
22+
if (typeof parsedAnswer === 'string') {
23+
return <span className='correct-blank-answer'>{parsedAnswer}</span>;
24+
}
25+
26+
return (
27+
<ruby className='correct-blank-answer'>
28+
{parsedAnswer.hanzi}
29+
<rp>(</rp>
30+
<rt>{parsedAnswer.pinyin}</rt>
31+
<rp>)</rp>
32+
</ruby>
33+
);
34+
};
35+
1936
function FillInTheBlanks({
2037
fillInTheBlank: { sentence, blanks },
2138
answersCorrect,
@@ -36,6 +53,17 @@ function FillInTheBlanks({
3653
return cls;
3754
};
3855

56+
const getAnswerLength = (answer: string): number => {
57+
const parsedAnswer = parseAnswer(answer);
58+
59+
if (typeof parsedAnswer === 'string') {
60+
return parsedAnswer.length;
61+
}
62+
63+
// TODO: This is a simplification. Revisit later to account for tones and spaces.
64+
return parsedAnswer.pinyin.length;
65+
};
66+
3967
const paragraphs = parseBlanks(sentence);
4068
const blankAnswers = blanks.map(b => b.answer);
4169

@@ -55,25 +83,35 @@ function FillInTheBlanks({
5583
return value;
5684
}
5785

58-
// If a blank is answered correctly, render the answer as part of the sentence.
59-
if (type === 'blank' && answersCorrect[value] === true) {
86+
if (type === 'hanzi-pinyin') {
87+
const { hanzi, pinyin } = value;
6088
return (
61-
<span key={j} className='correct-blank-answer'>
62-
{blankAnswers[value]}
63-
</span>
89+
<ruby key={j}>
90+
{hanzi}
91+
<rp>(</rp>
92+
<rt>{pinyin}</rt>
93+
<rp>)</rp>
94+
</ruby>
6495
);
6596
}
6697

98+
// If a blank is answered correctly, render the answer as part of the sentence.
99+
if (type === 'blank' && answersCorrect[value] === true) {
100+
return <AnswerText key={j} answer={blankAnswers[value]} />;
101+
}
102+
103+
const answerLength = getAnswerLength(blankAnswers[value]);
104+
67105
return (
68106
<input
69107
key={j}
70108
type='text'
71-
maxLength={blankAnswers[value].length + 3}
109+
maxLength={answerLength + 3}
72110
className={getInputClass(value)}
73111
onChange={e =>
74112
handleInputChange(node.value, e.target.value)
75113
}
76-
size={blankAnswers[value].length}
114+
size={answerLength}
77115
autoComplete='off'
78116
aria-label={t('learn.fill-in-the-blank.blank')}
79117
{...(answersCorrect[value] === false

client/src/templates/Challenges/fill-in-the-blank/parse-blanks.test.ts

Lines changed: 222 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, it, expect } from 'vitest';
2-
import { parseBlanks } from './parse-blanks';
2+
import {
3+
parseBlanks,
4+
parseHanziPinyinPairs,
5+
parseAnswer
6+
} from './parse-blanks';
37

48
describe('parseBlanks', () => {
59
it('handles strings without blanks', () => {
@@ -129,4 +133,221 @@ describe('parseBlanks', () => {
129133
expect(() => parseBlanks('<p>hello BLANK!</p>hello BLANK!')).toThrow();
130134
expect(() => parseBlanks('hello BLANK!<p>hello</p>')).toThrow();
131135
});
136+
137+
it('handles Chinese with single BLANK', () => {
138+
expect(
139+
parseBlanks('<p>BLANK<ruby>好<rp>(</rp><rt>hǎo</rt><rp>)</rp></ruby></p>')
140+
).toEqual([
141+
[
142+
{ type: 'blank', value: 0 },
143+
{
144+
type: 'hanzi-pinyin',
145+
value: { hanzi: '好', pinyin: 'hǎo' }
146+
}
147+
]
148+
]);
149+
});
150+
151+
it('handles Chinese without pinyin', () => {
152+
expect(parseBlanks('<p>你BLANK好</p>')).toEqual([
153+
[
154+
{ type: 'text', value: '你' },
155+
{ type: 'blank', value: 0 },
156+
{ type: 'text', value: '好' }
157+
]
158+
]);
159+
});
160+
161+
it('handles Chinese with multiple BLANKs', () => {
162+
expect(
163+
parseBlanks(
164+
'<p>BLANK<ruby>好<rp>(</rp><rt>hǎo</rt><rp>)</rp></ruby>,BLANK<ruby>是王华<rp>(</rp><rt>shì Wang Hua</rt><rp>)</rp></ruby></p>'
165+
)
166+
).toEqual([
167+
[
168+
{ type: 'blank', value: 0 },
169+
{
170+
type: 'hanzi-pinyin',
171+
value: { hanzi: '好', pinyin: 'hǎo' }
172+
},
173+
{ type: 'text', value: ',' },
174+
{ type: 'blank', value: 1 },
175+
{
176+
type: 'hanzi-pinyin',
177+
value: { hanzi: '是王华', pinyin: 'shì Wang Hua' }
178+
}
179+
]
180+
]);
181+
});
182+
183+
it('handles Chinese with multiple adjacent BLANKs', () => {
184+
expect(
185+
parseBlanks(
186+
'<p>BLANK BLANK<ruby>好<rp>(</rp><rt>hǎo</rt><rp>)</rp></ruby></p>'
187+
)
188+
).toEqual([
189+
[
190+
{ type: 'blank', value: 0 },
191+
{ type: 'text', value: ' ' },
192+
{ type: 'blank', value: 1 },
193+
{
194+
type: 'hanzi-pinyin',
195+
value: { hanzi: '好', pinyin: 'hǎo' }
196+
}
197+
]
198+
]);
199+
});
200+
201+
it('handles Chinese with BLANK at the end', () => {
202+
expect(
203+
parseBlanks(
204+
'<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby>BLANK</p>'
205+
)
206+
).toEqual([
207+
[
208+
{
209+
type: 'hanzi-pinyin',
210+
value: { hanzi: '你好', pinyin: 'nǐ hǎo' }
211+
},
212+
{ type: 'blank', value: 0 }
213+
]
214+
]);
215+
});
216+
217+
it('handles Chinese with spaces around BLANK', () => {
218+
expect(
219+
parseBlanks(
220+
'<p><ruby>你<rp>(</rp><rt>nǐ</rt><rp>)</rp></ruby> BLANK <ruby>我<rp>(</rp><rt>wǒ</rt><rp>)</rp></ruby></p>'
221+
)
222+
).toEqual([
223+
[
224+
{
225+
type: 'hanzi-pinyin',
226+
value: { hanzi: '你', pinyin: 'nǐ' }
227+
},
228+
{ type: 'text', value: ' ' },
229+
{ type: 'blank', value: 0 },
230+
{ type: 'text', value: ' ' },
231+
{
232+
type: 'hanzi-pinyin',
233+
value: { hanzi: '我', pinyin: 'wǒ' }
234+
}
235+
]
236+
]);
237+
});
238+
239+
it('handles Latin text adjacent to BLANK', () => {
240+
expect(
241+
parseBlanks(
242+
'<p><ruby>我<rp>(</rp><rt>wǒ</rt><rp>)</rp></ruby> BLANK UI <ruby>设计师<rp>(</rp><rt>shè jì shī</rt><rp>)</rp></ruby> 。</p>'
243+
)
244+
).toEqual([
245+
[
246+
{
247+
type: 'hanzi-pinyin',
248+
value: { hanzi: '我', pinyin: 'wǒ' }
249+
},
250+
{ type: 'text', value: ' ' },
251+
{ type: 'blank', value: 0 },
252+
{ type: 'text', value: ' UI ' },
253+
{
254+
type: 'hanzi-pinyin',
255+
value: { hanzi: '设计师', pinyin: 'shè jì shī' }
256+
},
257+
{ type: 'text', value: ' 。' }
258+
]
259+
]);
260+
});
261+
262+
it('handles Chinese with multiple separate groups', () => {
263+
expect(
264+
parseBlanks(
265+
'<p>BLANK<ruby>好<rp>(</rp><rt>hǎo</rt><rp>)</rp></ruby>,<ruby>我是王华<rp>(</rp><rt>wǒ shì Wang Hua</rt><rp>)</rp></ruby>,<ruby>请问你<rp>(</rp><rt>qǐng wèn nǐ</rt><rp>)</rp></ruby>BLANK<ruby>什么名字<rp>(</rp><rt>shén me míng zi</rt><rp>)</rp></ruby>?</p>'
266+
)
267+
).toEqual([
268+
[
269+
{ type: 'blank', value: 0 },
270+
{
271+
type: 'hanzi-pinyin',
272+
value: { hanzi: '好', pinyin: 'hǎo' }
273+
},
274+
{ type: 'text', value: ',' },
275+
{
276+
type: 'hanzi-pinyin',
277+
value: { hanzi: '我是王华', pinyin: 'wǒ shì Wang Hua' }
278+
},
279+
{ type: 'text', value: ',' },
280+
{
281+
type: 'hanzi-pinyin',
282+
value: { hanzi: '请问你', pinyin: 'qǐng wèn nǐ' }
283+
},
284+
{ type: 'blank', value: 1 },
285+
{
286+
type: 'hanzi-pinyin',
287+
value: { hanzi: '什么名字', pinyin: 'shén me míng zi' }
288+
},
289+
{ type: 'text', value: '?' }
290+
]
291+
]);
292+
});
293+
294+
it('handles Chinese ruby with trailing punctuation', () => {
295+
expect(
296+
parseBlanks(
297+
'<p><ruby>你是刘明吗<rp>(</rp><rt>nǐ shì Liu Ming ma</rt><rp>)</rp></ruby>?</p>'
298+
)
299+
).toEqual([
300+
[
301+
{
302+
type: 'hanzi-pinyin',
303+
value: { hanzi: '你是刘明吗', pinyin: 'nǐ shì Liu Ming ma' }
304+
},
305+
{ type: 'text', value: '?' }
306+
]
307+
]);
308+
});
309+
});
310+
311+
describe('parseHanziPinyinPairs', () => {
312+
it('parseHanziPinyinPairs returns array with one pair for well-formed input', () => {
313+
const result = parseHanziPinyinPairs('你好 (nǐ hǎo)');
314+
expect(result).toHaveLength(1);
315+
expect(result[0]).toEqual({
316+
hanzi: '你好',
317+
pinyin: 'nǐ hǎo'
318+
});
319+
});
320+
321+
it('parseHanziPinyinPairs handles parentheses without a space', () => {
322+
const result = parseHanziPinyinPairs('你好(nǐ hǎo)');
323+
expect(result).toHaveLength(1);
324+
expect(result[0]).toEqual({
325+
hanzi: '你好',
326+
pinyin: 'nǐ hǎo'
327+
});
328+
});
329+
330+
it('parseHanziPinyinPairs returns empty array for non-matching input', () => {
331+
expect(parseHanziPinyinPairs('hello')).toEqual([]);
332+
});
333+
334+
it('parseAnswer returns parsed object when pattern matches', () => {
335+
expect(parseAnswer('你好 (nǐ hǎo)')).toEqual({
336+
hanzi: '你好',
337+
pinyin: 'nǐ hǎo'
338+
});
339+
});
340+
});
341+
342+
describe('parseAnswer', () => {
343+
it('parseAnswer returns hanzi-pinyin string when pattern matches', () => {
344+
expect(parseAnswer('你好(nǐ hǎo)')).toEqual({
345+
hanzi: '你好',
346+
pinyin: 'nǐ hǎo'
347+
});
348+
});
349+
350+
it('parseAnswer returns original string when pattern does not match', () => {
351+
expect(parseAnswer('just some text')).toBe('just some text');
352+
});
132353
});

0 commit comments

Comments
 (0)