From 73ad631dfd8128554622a94082c3d01029bf6c1c Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Thu, 4 Dec 2025 00:25:18 -0800 Subject: [PATCH] feat(client): add pinyin tone input to fill-in-the-blank challenge (#64085) --- client/i18n/locales/english/translations.json | 3 +- .../components/fill-in-the-blanks.tsx | 21 +- .../components/pinyin-to-hanzi-input.tsx | 1 - .../components/pinyin-tone-input.test.tsx | 284 ++++++++++++++++++ .../components/pinyin-tone-input.tsx | 133 ++++++++ .../Challenges/fill-in-the-blank/show.tsx | 7 + 6 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 client/src/templates/Challenges/components/pinyin-tone-input.test.tsx create mode 100644 client/src/templates/Challenges/components/pinyin-tone-input.tsx diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 70fb537928a..3981b73989e 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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.", diff --git a/client/src/templates/Challenges/components/fill-in-the-blanks.tsx b/client/src/templates/Challenges/components/fill-in-the-blanks.tsx index 23295877cf3..ee315d9dd31 100644 --- a/client/src/templates/Challenges/components/fill-in-the-blanks.tsx +++ b/client/src/templates/Challenges/components/fill-in-the-blanks.tsx @@ -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 ( + + ); } // 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 ( <> -

{t(ariaInputDescription)}

+

{ariaInputDescription}

{paragraphs.map((p, i) => ( // both keys, i and j, are stable between renders, since diff --git a/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx index ec8d08d3cbc..c220bc4d60c 100644 --- a/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx +++ b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx @@ -226,7 +226,6 @@ function PinyinToHanziInput({ size={size} autoComplete='off' aria-label={ariaLabel} - aria-describedby={`pinyin-description-${index}`} {...(isCorrect === false ? { 'aria-invalid': 'true' } : {})} /> ); diff --git a/client/src/templates/Challenges/components/pinyin-tone-input.test.tsx b/client/src/templates/Challenges/components/pinyin-tone-input.test.tsx new file mode 100644 index 00000000000..989bbd7836b --- /dev/null +++ b/client/src/templates/Challenges/components/pinyin-tone-input.test.tsx @@ -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( + + ); + + const input = screen.getByLabelText('blank'); + + await userEvent.type(input, 'ni3'); + expect(input.value).toBe('nǐ'); + }); + + test('should chain multiple syllables without spaces', async () => { + const mockOnChange = vi.fn(); + + render( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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 = { + ni3hao: 'nǐhao', + ni3ha: 'nǐha', + ni3h: 'nǐh', + ni3: 'nǐ', + ni: 'ni', + n: 'n' + }; + + render( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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'); + }); +}); diff --git a/client/src/templates/Challenges/components/pinyin-tone-input.tsx b/client/src/templates/Challenges/components/pinyin-tone-input.tsx new file mode 100644 index 00000000000..1847445c72a --- /dev/null +++ b/client/src/templates/Challenges/components/pinyin-tone-input.tsx @@ -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) => { + 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 ( + + ); +} + +PinyinToneInput.displayName = 'PinyinToneInput'; + +export default PinyinToneInput; diff --git a/client/src/templates/Challenges/fill-in-the-blank/show.tsx b/client/src/templates/Challenges/fill-in-the-blank/show.tsx index 5b1cc32ff76..f761487ebe2 100644 --- a/client/src/templates/Challenges/fill-in-the-blank/show.tsx +++ b/client/src/templates/Challenges/fill-in-the-blank/show.tsx @@ -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();