mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client): add pinyin tone input to fill-in-the-blank challenge (#64085)
This commit is contained in:
@@ -965,7 +965,8 @@
|
||||
"terminal-output": "Terminal output",
|
||||
"not-available": "Not available",
|
||||
"interactive-editor-desc": "Turn static code examples into interactive editors. This allows you to edit and run the code directly on the page.",
|
||||
"pinyin-to-hanzi-input-desc": "This task uses Pinyin-to-Hanzi inputs. Type pinyin with tone numbers (1 to 5). When you enter a correct syllable, it will turn into a Chinese character. If you press backspace after a Chinese character, it will change back to pinyin and remove the last thing you typed: if it's a tone number, the tone is removed; if it's a letter, the letter is removed."
|
||||
"pinyin-to-hanzi-input-desc": "This task uses Pinyin-to-Hanzi inputs. Type pinyin with tone numbers (1 to 5). When you enter a correct syllable, it will turn into a Chinese character. If you press backspace after a Chinese character, it will change back to pinyin and remove the last thing you typed: if it's a tone number, the tone is removed; if it's a letter, the letter is removed.",
|
||||
"pinyin-tone-input-desc": "This task uses Pinyin Tone inputs. Type pinyin with tone numbers (1 to 5). When you enter a tone number, it will be converted to a tone mark. If you press backspace, the last thing you typed is removed: if it's a tone number, the tone is removed; if it's a letter, the letter is removed."
|
||||
},
|
||||
"flash": {
|
||||
"no-email-in-userinfo": "We could not retrieve an email from your chosen provider. Please try another provider or use the 'Continue with Email' option.",
|
||||
|
||||
@@ -7,6 +7,7 @@ import PrismFormatted from '../components/prism-formatted';
|
||||
import { FillInTheBlank } from '../../../redux/prop-types';
|
||||
import ChallengeHeading from './challenge-heading';
|
||||
import PinyinToHanziInput from './pinyin-to-hanzi-input';
|
||||
import PinyinToneInput from './pinyin-tone-input';
|
||||
|
||||
type FillInTheBlankProps = {
|
||||
fillInTheBlank: FillInTheBlank;
|
||||
@@ -72,6 +73,18 @@ const BlankInput = ({
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
);
|
||||
} else if (inputType === 'pinyin-tone' && typeof parsedAnswer === 'string') {
|
||||
return (
|
||||
<PinyinToneInput
|
||||
index={blankIndex}
|
||||
isCorrect={isCorrect}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
maxLength={answerLength + 3}
|
||||
size={answerLength}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default text input
|
||||
@@ -113,13 +126,17 @@ function FillInTheBlanks({
|
||||
const blankAnswers = blanks.map(b => b.answer);
|
||||
|
||||
const ariaInputDescription =
|
||||
inputType === 'pinyin-to-hanzi' ? t('aria.pinyin-to-hanzi-input-desc') : '';
|
||||
inputType === 'pinyin-to-hanzi'
|
||||
? t('aria.pinyin-to-hanzi-input-desc')
|
||||
: inputType === 'pinyin-tone'
|
||||
? t('aria.pinyin-tone-input-desc')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChallengeHeading heading={t('learn.fill-in-the-blank.heading')} />
|
||||
<Spacer size='xs' />
|
||||
<p className='sr-only'>{t(ariaInputDescription)}</p>
|
||||
<p className='sr-only'>{ariaInputDescription}</p>
|
||||
<div className='fill-in-the-blank-wrap'>
|
||||
{paragraphs.map((p, i) => (
|
||||
// both keys, i and j, are stable between renders, since
|
||||
|
||||
@@ -226,7 +226,6 @@ function PinyinToHanziInput({
|
||||
size={size}
|
||||
autoComplete='off'
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={`pinyin-description-${index}`}
|
||||
{...(isCorrect === false ? { 'aria-invalid': 'true' } : {})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import React from 'react';
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import PinyinToneInput, { convertToPinyinWithTones } from './pinyin-tone-input';
|
||||
|
||||
describe('convertToPinyinWithTones', () => {
|
||||
test('should convert single syllable with tone number to pinyin with tone mark', () => {
|
||||
expect(convertToPinyinWithTones('ni3')).toBe('nǐ');
|
||||
expect(convertToPinyinWithTones('hao3')).toBe('hǎo');
|
||||
});
|
||||
|
||||
test('should handle all five tones correctly', () => {
|
||||
expect(convertToPinyinWithTones('ma1')).toBe('mā');
|
||||
expect(convertToPinyinWithTones('ma2')).toBe('má');
|
||||
expect(convertToPinyinWithTones('ma3')).toBe('mǎ');
|
||||
expect(convertToPinyinWithTones('ma4')).toBe('mà');
|
||||
expect(convertToPinyinWithTones('ma5')).toBe('ma');
|
||||
|
||||
expect(convertToPinyinWithTones('v1')).toBe('ǖ');
|
||||
expect(convertToPinyinWithTones('v2')).toBe('ǘ');
|
||||
expect(convertToPinyinWithTones('v3')).toBe('ǚ');
|
||||
expect(convertToPinyinWithTones('v4')).toBe('ǜ');
|
||||
expect(convertToPinyinWithTones('v5')).toBe('ü');
|
||||
});
|
||||
|
||||
test('should convert tone number before final letter', () => {
|
||||
expect(convertToPinyinWithTones('she2n')).toBe('shén');
|
||||
});
|
||||
|
||||
test('should convert tone number after final letter', () => {
|
||||
expect(convertToPinyinWithTones('shen2')).toBe('shén');
|
||||
expect(convertToPinyinWithTones('dian3')).toBe('diǎn');
|
||||
});
|
||||
|
||||
test('should chain multiple syllables without spaces', () => {
|
||||
expect(convertToPinyinWithTones('qing3wen4ni3jiao4shen2me5ming2zi5')).toBe(
|
||||
'qǐngwènnǐjiàoshénmemíngzi'
|
||||
);
|
||||
});
|
||||
|
||||
test('should preserve spaces between syllables', () => {
|
||||
expect(
|
||||
convertToPinyinWithTones('qing3 wen4 ni3 jiao4 shen2 me5 ming2 zi5')
|
||||
).toBe('qǐng wèn nǐ jiào shén me míng zi');
|
||||
expect(convertToPinyinWithTones('ni3 hao3')).toBe('nǐ hǎo');
|
||||
});
|
||||
|
||||
test('should handle uppercase input case-insensitively', () => {
|
||||
expect(convertToPinyinWithTones('NI3HAO3')).toBe('nǐhǎo');
|
||||
expect(convertToPinyinWithTones('Ni3hAO3')).toBe('nǐhǎo');
|
||||
});
|
||||
|
||||
test('should handle incomplete syllables without tone numbers', () => {
|
||||
expect(convertToPinyinWithTones('ni')).toBe('ni');
|
||||
expect(convertToPinyinWithTones('niha')).toBe('niha');
|
||||
});
|
||||
|
||||
test('should handle mixed complete and incomplete syllables', () => {
|
||||
expect(convertToPinyinWithTones('ni3ha')).toBe('nǐha');
|
||||
expect(convertToPinyinWithTones('ni3hao')).toBe('nǐhao');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PinyinToneInput component', () => {
|
||||
test('should convert syllable when tone number is typed', async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToneInput
|
||||
index={0}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3');
|
||||
expect(input.value).toBe('nǐ');
|
||||
});
|
||||
|
||||
test('should chain multiple syllables without spaces', async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToneInput
|
||||
index={0}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3hao3');
|
||||
expect(input.value).toBe('nǐhǎo');
|
||||
});
|
||||
|
||||
test('should convert syllables with spaces', async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToneInput
|
||||
index={0}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3 hao3');
|
||||
expect(input.value).toBe('nǐ hǎo');
|
||||
});
|
||||
|
||||
test('should allow changing the tone digit for a syllable', async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToneInput
|
||||
index={0}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3');
|
||||
expect(input.value).toBe('nǐ');
|
||||
|
||||
// Replace tone 3 with tone 4
|
||||
await userEvent.type(input, '4');
|
||||
expect(input.value).toBe('nì');
|
||||
});
|
||||
|
||||
test('should clear the input when selecting all and pressing backspace', async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToneInput
|
||||
index={0}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3hao3');
|
||||
expect(input.value).toBe('nǐhǎo');
|
||||
|
||||
await userEvent.clear(input);
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
test('should handle tone number before final letter', async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToneInput
|
||||
index={0}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'she2n');
|
||||
expect(input.value).toBe('shén');
|
||||
});
|
||||
|
||||
test('should remove tone mark when backspacing', async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
const expectedMap: Record<string, string> = {
|
||||
ni3hao: 'nǐhao',
|
||||
ni3ha: 'nǐha',
|
||||
ni3h: 'nǐh',
|
||||
ni3: 'nǐ',
|
||||
ni: 'ni',
|
||||
n: 'n'
|
||||
};
|
||||
|
||||
render(
|
||||
<PinyinToneInput
|
||||
index={0}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3hao3');
|
||||
expect(input.value).toBe('nǐhǎo');
|
||||
|
||||
const rawSteps = ['ni3hao', 'ni3ha', 'ni3h', 'ni3', 'ni', 'n'];
|
||||
for (const step of rawSteps) {
|
||||
await userEvent.type(input, '{Backspace}');
|
||||
expect(input.value).toBe(expectedMap[step]);
|
||||
}
|
||||
});
|
||||
|
||||
test('should remove final letter before tone mark when backspacing syllables with tone before final letter', async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToneInput
|
||||
index={0}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
// Type 'she2n' - tone digit before final letter
|
||||
await userEvent.type(input, 'she2n');
|
||||
expect(input.value).toBe('shén');
|
||||
|
||||
// First backspace removes the final letter 'n'
|
||||
await userEvent.type(input, '{Backspace}');
|
||||
expect(input.value).toBe('shé');
|
||||
|
||||
// Second backspace removes the tone mark
|
||||
await userEvent.type(input, '{Backspace}');
|
||||
expect(input.value).toBe('she');
|
||||
});
|
||||
|
||||
test('should allow inserting mid-string', async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToneInput
|
||||
index={0}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3hao3');
|
||||
expect(input.value).toBe('nǐhǎo');
|
||||
|
||||
// Simulate mid-string edit: insert 'x' between the syllables
|
||||
await userEvent.type(input, 'x', {
|
||||
initialSelectionStart: 1,
|
||||
initialSelectionEnd: 1
|
||||
});
|
||||
|
||||
expect(input.value).toBe('nxǐhǎo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react';
|
||||
import { convertUnspacedPinyin } from 'pinyin-tone/v2';
|
||||
|
||||
/**
|
||||
* Converts raw pinyin input (with tone numbers) to pinyin with tone marks.
|
||||
*
|
||||
* Key behaviors:
|
||||
* 1. When a tone digit (1-5) is encountered -> convert syllable to toned pinyin
|
||||
* 2. Spaces are preserved in the output
|
||||
* 3. Incomplete syllables (without tone numbers) remain as-is
|
||||
*/
|
||||
export function convertToPinyinWithTones(raw: string): string {
|
||||
if (!raw.trim()) return raw;
|
||||
|
||||
let displayOutput = '';
|
||||
let currentSyllable = '';
|
||||
|
||||
// Process each character in the raw input
|
||||
for (const character of raw) {
|
||||
// Handle spaces: flush current syllable and preserve space
|
||||
if (character === ' ') {
|
||||
displayOutput += currentSyllable + ' ';
|
||||
currentSyllable = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add character to current syllable
|
||||
currentSyllable += character;
|
||||
|
||||
// When a tone digit is encountered, convert the syllable
|
||||
if (/[1-5]/.test(character)) {
|
||||
// Normalize to lowercase for conversion
|
||||
const normalizedSyllable = currentSyllable.toLowerCase();
|
||||
const convertedPinyin = convertUnspacedPinyin(normalizedSyllable);
|
||||
|
||||
displayOutput += convertedPinyin;
|
||||
currentSyllable = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Append any unfinished syllable at the end
|
||||
return displayOutput + currentSyllable;
|
||||
}
|
||||
|
||||
interface PinyinToneInputProps {
|
||||
index: number;
|
||||
isCorrect: boolean | null;
|
||||
onChange: (index: number, value: string) => void;
|
||||
className?: string;
|
||||
maxLength: number;
|
||||
size: number;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
function PinyinToneInput({
|
||||
index,
|
||||
isCorrect,
|
||||
onChange,
|
||||
className,
|
||||
maxLength,
|
||||
size,
|
||||
ariaLabel
|
||||
}: PinyinToneInputProps): JSX.Element {
|
||||
const [rawInput, setRawInput] = useState('');
|
||||
const [displayValue, setDisplayValue] = useState('');
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
const prevLength = displayValue.length;
|
||||
const inputLength = inputValue.length;
|
||||
|
||||
const isAppendingAtEnd =
|
||||
inputLength > prevLength && inputValue.startsWith(displayValue);
|
||||
const isDeletingFromEnd =
|
||||
inputLength < prevLength && displayValue.startsWith(inputValue);
|
||||
|
||||
let newRawInput: string;
|
||||
|
||||
if (isAppendingAtEnd) {
|
||||
const added = inputValue.substring(prevLength);
|
||||
|
||||
// Handle tone digit replacement
|
||||
if (
|
||||
added.length === 1 &&
|
||||
/[1-5]/.test(added) &&
|
||||
/[1-5]$/.test(rawInput)
|
||||
) {
|
||||
newRawInput = rawInput.slice(0, -1) + added;
|
||||
} else {
|
||||
newRawInput = rawInput + added;
|
||||
}
|
||||
} else if (isDeletingFromEnd) {
|
||||
if (inputLength === 0) {
|
||||
// When clearing the entire input:
|
||||
// - If the previous display was a single character,
|
||||
// assume the user wants to remove the last character from raw input
|
||||
// (e.g., undo the tone digit that converted it, like 'ni3' -> 'ni').
|
||||
// - Otherwise, fully clear raw input to an empty string.
|
||||
newRawInput = prevLength === 1 ? rawInput.slice(0, -1) : '';
|
||||
} else {
|
||||
// Remove characters from raw input
|
||||
const charsToRemove = prevLength - inputLength;
|
||||
newRawInput = rawInput.slice(0, -charsToRemove);
|
||||
}
|
||||
} else {
|
||||
// Mid-string edit - update new raw input directly
|
||||
newRawInput = inputValue;
|
||||
}
|
||||
|
||||
setRawInput(newRawInput);
|
||||
const newDisplayValue = convertToPinyinWithTones(newRawInput);
|
||||
setDisplayValue(newDisplayValue);
|
||||
onChange(index, newDisplayValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type='text'
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
className={className}
|
||||
maxLength={maxLength}
|
||||
size={size}
|
||||
autoComplete='off'
|
||||
aria-label={ariaLabel}
|
||||
{...(isCorrect === false ? { 'aria-invalid': 'true' } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
PinyinToneInput.displayName = 'PinyinToneInput';
|
||||
|
||||
export default PinyinToneInput;
|
||||
@@ -187,6 +187,13 @@ const ShowFillInTheBlank = ({
|
||||
hanzi.replace(/\s+/g, '')
|
||||
);
|
||||
}
|
||||
} else if (fillInTheBlank.inputType === 'pinyin-tone') {
|
||||
// Ignore spaces to allow both syllable formats:
|
||||
// spaced (e.g., 'nǐ hǎo') and unspaced (e.g., 'nǐhǎo').
|
||||
return (
|
||||
normalizedUserAnswer.replace(/\s+/g, '') ===
|
||||
answer.toLowerCase().replace(/\s+/g, '')
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedUserAnswer === answer.toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user