feat(client): add pinyin tone input to fill-in-the-blank challenge (#64085)

This commit is contained in:
Huyen Nguyen
2025-12-04 00:25:18 -08:00
committed by GitHub
parent b9906c3d76
commit 73ad631dfd
6 changed files with 445 additions and 4 deletions
@@ -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();