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

This commit is contained in:
Huyen Nguyen
2025-12-01 10:35:06 -08:00
committed by GitHub
parent e93134d9b6
commit 5d6eacb615
8 changed files with 774 additions and 70 deletions

View File

@@ -963,7 +963,8 @@
"editor-a11y-on-non-macos": "{{editorName}} editor content. Accessibility mode set to 'on'. Press Ctrl+E to disable or press Alt+F1 for more options.",
"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."
"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."
},
"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.",

View File

@@ -94,6 +94,7 @@
"nanoid": "3.3.7",
"normalize-url": "6.1.0",
"path-browserify": "1.0.1",
"pinyin-tone": "2.4.0",
"postcss": "8.4.35",
"prismjs": "1.29.0",
"process": "0.11.10",
@@ -143,6 +144,7 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "14.6.1",
"@total-typescript/ts-reset": "^0.5.0",
"@types/canvas-confetti": "^1.6.0",
"@types/gatsbyjs__reach-router": "1.3.0",

View File

@@ -1,5 +1,8 @@
import { HandlerProps } from 'react-reflex';
import { SuperBlocks } from '../../../shared-dist/config/curriculum';
import {
ChallengeLang,
SuperBlocks
} from '../../../shared-dist/config/curriculum';
import type { Chapter } from '../../../shared-dist/config/chapters';
import { BlockLayouts, BlockLabel } from '../../../shared-dist/config/blocks';
import type { ChallengeFile, Ext } from '../../../shared-dist/utils/polyvinyl';
@@ -222,6 +225,7 @@ export type ChallengeNode = {
helpCategory: string;
hooks?: Hooks;
id: string;
lang?: ChallengeLang;
instructions: string;
internal?: {
content: string;

View File

@@ -6,6 +6,7 @@ import { parseBlanks, parseAnswer } from '../fill-in-the-blank/parse-blanks';
import PrismFormatted from '../components/prism-formatted';
import { FillInTheBlank } from '../../../redux/prop-types';
import ChallengeHeading from './challenge-heading';
import PinyinToHanziInput from './pinyin-to-hanzi-input';
type FillInTheBlankProps = {
fillInTheBlank: FillInTheBlank;
@@ -33,8 +34,63 @@ const AnswerText = ({ answer }: { answer: string }) => {
);
};
type BlankInputProps = {
blankIndex: number;
answer: string;
isCorrect: boolean | null;
className: string;
onChange: (index: number, value: string) => void;
ariaLabel: string;
inputType?: 'pinyin-to-hanzi' | 'pinyin-tone';
};
const BlankInput = ({
blankIndex,
answer,
isCorrect,
className,
onChange,
ariaLabel,
inputType
}: BlankInputProps) => {
const parsedAnswer = parseAnswer(answer);
const answerLength =
typeof parsedAnswer === 'string'
? parsedAnswer.length
: parsedAnswer.pinyin.length;
if (inputType === 'pinyin-to-hanzi' && typeof parsedAnswer === 'object') {
return (
<PinyinToHanziInput
index={blankIndex}
expectedAnswer={parsedAnswer}
isCorrect={isCorrect}
onChange={onChange}
className={className}
maxLength={answerLength + 3}
size={answerLength}
ariaLabel={ariaLabel}
/>
);
}
// Default text input
return (
<input
type='text'
maxLength={answerLength + 3}
className={className}
onChange={e => onChange(blankIndex, e.target.value)}
size={answerLength}
autoComplete='off'
aria-label={ariaLabel}
{...(isCorrect === false ? { 'aria-invalid': 'true' } : {})}
/>
);
};
function FillInTheBlanks({
fillInTheBlank: { sentence, blanks },
fillInTheBlank: { sentence, blanks, inputType },
answersCorrect,
showFeedback,
feedback,
@@ -53,76 +109,61 @@ function FillInTheBlanks({
return cls;
};
const getAnswerLength = (answer: string): number => {
const parsedAnswer = parseAnswer(answer);
if (typeof parsedAnswer === 'string') {
return parsedAnswer.length;
}
// TODO: This is a simplification. Revisit later to account for tones and spaces.
return parsedAnswer.pinyin.length;
};
const paragraphs = parseBlanks(sentence);
const blankAnswers = blanks.map(b => b.answer);
const ariaInputDescription =
inputType === 'pinyin-to-hanzi' ? t('aria.pinyin-to-hanzi-input-desc') : '';
return (
<>
<ChallengeHeading heading={t('learn.fill-in-the-blank.heading')} />
<Spacer size='xs' />
<p className='sr-only'>{t(ariaInputDescription)}</p>
<div className='fill-in-the-blank-wrap'>
{paragraphs.map((p, i) => {
return (
// both keys, i and j, are stable between renders, since
// the paragraphs are static.
<p key={i}>
{p.map((node, j) => {
const { type, value } = node;
if (type === 'text') {
return value;
}
{paragraphs.map((p, i) => (
// both keys, i and j, are stable between renders, since
// the paragraphs are static.
<p key={i}>
{p.map((node, j) => {
const { type, value } = node;
if (type === 'hanzi-pinyin') {
const { hanzi, pinyin } = value;
return (
<ruby key={j}>
{hanzi}
<rp>(</rp>
<rt>{pinyin}</rt>
<rp>)</rp>
</ruby>
);
}
// If a blank is answered correctly, render the answer as part of the sentence.
if (type === 'blank' && answersCorrect[value] === true) {
return <AnswerText key={j} answer={blankAnswers[value]} />;
}
const answerLength = getAnswerLength(blankAnswers[value]);
if (type === 'text') {
return value;
}
if (type === 'hanzi-pinyin') {
const { hanzi, pinyin } = value;
return (
<input
key={j}
type='text'
maxLength={answerLength + 3}
className={getInputClass(value)}
onChange={e =>
handleInputChange(node.value, e.target.value)
}
size={answerLength}
autoComplete='off'
aria-label={t('learn.fill-in-the-blank.blank')}
{...(answersCorrect[value] === false
? { 'aria-invalid': 'true' }
: {})}
/>
<ruby key={j}>
{hanzi}
<rp>(</rp>
<rt>{pinyin}</rt>
<rp>)</rp>
</ruby>
);
})}
</p>
);
})}
}
// If a blank is answered correctly, render the answer as part of the sentence.
if (answersCorrect[value] === true) {
return <AnswerText key={j} answer={blankAnswers[value]} />;
}
return (
<BlankInput
key={j}
blankIndex={value}
answer={blankAnswers[value]}
isCorrect={answersCorrect[value]}
className={getInputClass(value)}
onChange={handleInputChange}
ariaLabel={t('learn.fill-in-the-blank.blank')}
inputType={inputType}
/>
);
})}
</p>
))}
</div>
<Spacer size='m' />
<div aria-live='polite'>

View File

@@ -0,0 +1,353 @@
import React from 'react';
import { describe, test, expect, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import PinyinToHanziInput, { convertToHanzi } from './pinyin-to-hanzi-input';
describe('convertToHanzi', () => {
test('should convert when tone number appears after final letter', () => {
// Only the correct tone gets converted to hanzi
expect(convertToHanzi('shen2', { hanzi: '什', pinyin: 'shén' })).toBe('什');
// Incorrect tones stay as pinyin
expect(convertToHanzi('shen1', { hanzi: '什', pinyin: 'shén' })).toBe(
'shēn'
);
expect(convertToHanzi('shen3', { hanzi: '什', pinyin: 'shén' })).toBe(
'shěn'
);
expect(convertToHanzi('shen4', { hanzi: '什', pinyin: 'shén' })).toBe(
'shèn'
);
expect(convertToHanzi('shen5', { hanzi: '什', pinyin: 'shén' })).toBe(
'shen'
);
});
test('should convert when tone number appears before final letter', () => {
// Only the correct tone gets converted to hanzi
expect(convertToHanzi('she2n', { hanzi: '什', pinyin: 'shén' })).toBe('什');
// Incorrect tones stay as pinyin
expect(convertToHanzi('she1n', { hanzi: '什', pinyin: 'shén' })).toBe(
'shēn'
);
expect(convertToHanzi('she3n', { hanzi: '什', pinyin: 'shén' })).toBe(
'shěn'
);
expect(convertToHanzi('she4n', { hanzi: '什', pinyin: 'shén' })).toBe(
'shèn'
);
expect(convertToHanzi('she5n', { hanzi: '什', pinyin: 'shén' })).toBe(
'shen'
);
});
test('should convert both correct syllables to hanzi', () => {
expect(convertToHanzi('ni3hao3', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
'你好'
);
});
test('should handle multiple syllables with space', () => {
expect(
convertToHanzi('ni3 hao3', { hanzi: '你好', pinyin: 'nǐ hǎo' })
).toBe('你 好');
});
test('should allow extra syllables and render them as pinyin', () => {
expect(
convertToHanzi('ni3hao3ma3', { hanzi: '你好', pinyin: 'nǐ hǎo' })
).toBe('你好mǎ');
});
test('should show toned pinyin for wrong syllable and convert correct one', () => {
expect(convertToHanzi('ni4hao3', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
'nì好'
);
expect(convertToHanzi('ni3hao4', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
'你hào'
);
});
test('should only convert when input has tone 5', () => {
expect(
convertToHanzi('shen2me', { hanzi: '什么', pinyin: 'shén me' })
).toBe('什me');
expect(
convertToHanzi('shen2me5', { hanzi: '什么', pinyin: 'shén me' })
).toBe('什么');
});
test('should convert long phrase properly', () => {
const longPhrase = {
hanzi: '请问你叫什么名字',
pinyin: 'qǐng wèn nǐ jiào shén me míng zi'
};
expect(
convertToHanzi('qing3 wen4 ni3 jiao4 shen2 me5 ming2 zi5', longPhrase)
).toBe('请 问 你 叫 什 么 名 字');
});
test('should handle uppercase input case-insensitively', () => {
expect(convertToHanzi('NI3HAO3', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
'你好'
);
expect(convertToHanzi('Ni3hAO3', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
'你好'
);
});
});
describe('PinyinToHanziInput component', () => {
test.each([
[null, false],
[true, false],
[false, true]
])(
'should have aria-invalid="%s" when isCorrect is %s',
(isCorrect, expectedAriaInvalid) => {
const expectedAnswer = { hanzi: '你好', pinyin: 'nǐ hǎo' };
const mockOnChange = vi.fn();
render(
<PinyinToHanziInput
index={0}
expectedAnswer={expectedAnswer}
isCorrect={isCorrect}
onChange={mockOnChange}
maxLength={100}
size={20}
ariaLabel='blank'
/>
);
const input = screen.getByLabelText<HTMLInputElement>('blank');
if (expectedAriaInvalid) {
expect(input).toHaveAttribute('aria-invalid', 'true');
} else {
expect(input).not.toHaveAttribute('aria-invalid');
}
}
);
test('should convert when tone number appears before final letter (she2n me5)', async () => {
const expectedAnswer = { hanzi: '什么', pinyin: 'shén me' };
const mockOnChange = vi.fn();
render(
<PinyinToHanziInput
index={0}
expectedAnswer={expectedAnswer}
isCorrect={null}
onChange={mockOnChange}
maxLength={100}
size={20}
ariaLabel='blank'
/>
);
const input = screen.getByLabelText<HTMLInputElement>('blank');
await userEvent.type(input, 'she2nme');
expect(input.value).toBe('什me');
// Type the final tone digit to complete the pinyin
await userEvent.type(input, '5');
expect(input.value).toBe('什么');
});
test('should convert when tone number appears after final letter (shen2 me)', async () => {
const expectedAnswer = { hanzi: '什么', pinyin: 'shén me' };
const mockOnChange = vi.fn();
render(
<PinyinToHanziInput
index={0}
expectedAnswer={expectedAnswer}
isCorrect={null}
onChange={mockOnChange}
maxLength={100}
size={20}
ariaLabel='blank'
/>
);
const input = screen.getByLabelText<HTMLInputElement>('blank');
await userEvent.type(input, 'shen2me');
expect(input.value).toBe('什me');
// Type the final tone digit to complete the pinyin
await userEvent.type(input, '5');
expect(input.value).toBe('什么');
});
test('should revert hanzi back to toned pinyin and remove tone when backspacing', async () => {
const expectedAnswer = { hanzi: '什么', pinyin: 'shén me' };
const mockOnChange = vi.fn();
const expectedMap: Record<string, string> = {
she2nme: '什me',
she2nm: '什m',
she2n: '什',
she2: 'shé',
she: 'she'
};
render(
<PinyinToHanziInput
index={0}
expectedAnswer={expectedAnswer}
isCorrect={null}
onChange={mockOnChange}
maxLength={100}
size={20}
ariaLabel='blank'
/>
);
const input = screen.getByLabelText<HTMLInputElement>('blank');
await userEvent.type(input, 'she2nme5');
expect(input.value).toBe('什么');
const rawSteps = ['she2nme', 'she2nm', 'she2n', 'she2', 'she'];
for (const step of rawSteps) {
await userEvent.type(input, '{Backspace}');
expect(input.value).toBe(expectedMap[step]);
}
});
test('should clear the input when selecting all and pressing backspace', async () => {
const expectedAnswer = { hanzi: '你好', pinyin: 'nǐ hǎo' };
const mockOnChange = vi.fn();
render(
<PinyinToHanziInput
index={0}
expectedAnswer={expectedAnswer}
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('你好');
await userEvent.clear(input);
expect(input.value).toBe('');
});
test('should revert a single hanzi character to partial pinyin when backspacing', async () => {
const expectedAnswer = { hanzi: '你', pinyin: 'nǐ' };
const mockOnChange = vi.fn();
render(
<PinyinToHanziInput
index={0}
expectedAnswer={expectedAnswer}
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('你');
// Backspace to revert the character to partial pinyin
await userEvent.type(input, '{Backspace}');
expect(input.value).toBe('ni');
});
test('should allow changing the tone digit for a syllable (shen3 -> shěn -> shèn)', async () => {
const expectedAnswer = { hanzi: '什么', pinyin: 'shén me' };
const mockOnChange = vi.fn();
render(
<PinyinToHanziInput
index={0}
expectedAnswer={expectedAnswer}
isCorrect={null}
onChange={mockOnChange}
maxLength={100}
size={20}
ariaLabel='blank'
/>
);
const input = screen.getByLabelText<HTMLInputElement>('blank');
await userEvent.type(input, 'shen3');
expect(input.value).toBe('shěn');
// Replace tone 3 with 4
await userEvent.type(input, '4');
expect(input.value).toBe('shèn');
});
test('should allow extra syllables beyond expected answer', async () => {
const expectedAnswer = { hanzi: '你好', pinyin: 'nǐ hǎo' };
const mockOnChange = vi.fn();
render(
<PinyinToHanziInput
index={0}
expectedAnswer={expectedAnswer}
isCorrect={null}
onChange={mockOnChange}
maxLength={100}
size={20}
ariaLabel='blank'
/>
);
const input = screen.getByLabelText<HTMLInputElement>('blank');
await userEvent.type(input, 'ni3hao3ma3');
expect(input.value).toBe('你好mǎ');
});
test('should allow inserting mid-string and preserve converted hanzi', async () => {
const expectedAnswer = { hanzi: '你好', pinyin: 'nǐ hǎo' };
const mockOnChange = vi.fn();
render(
<PinyinToHanziInput
index={0}
expectedAnswer={expectedAnswer}
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('你好');
// Simulate mid-string edit: insert 'x' between the characters
await userEvent.type(input, 'x', {
initialSelectionStart: 1,
initialSelectionEnd: 1
});
expect(input.value).toBe('你x好');
});
});

View File

@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { convertUnspacedPinyin } from 'pinyin-tone/v2';
// Removing tone marks from pinyin for base letter comparison.
// Uses Unicode NFD to decompose accented characters, then removes combining marks
const normalize = (s: string) =>
s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
/**
* Converts raw pinyin input (with tone numbers) to hanzi characters when matching expected answer.
*
* Key behaviors:
* 1. When a complete syllable with tone matches expected pinyin -> convert to hanzi
* 2. When base letters match but tone differs -> show toned pinyin (incorrect)
* 3. When syllable is a prefix of expected -> wait for more letters (e.g., 'shé' is prefix of 'shén')
* 4. Spaces are preserved in the output
*/
export function convertToHanzi(
raw: string,
expectedAnswer: { hanzi: string; pinyin: string }
): string {
if (!raw.trim()) return raw;
const correctPinyins = expectedAnswer.pinyin.toLowerCase().split(/\s+/);
const correctHanzi = [...expectedAnswer.hanzi];
// The final string shown to the user.
// Example: '你好' for correct input, 'nǐhǎo' for incorrect
let displayOutput = '';
// Accumulates characters for the current pinyin syllable.
// Example: 'ni' while typing 'nǐ'
let currentPinyin = '';
// Index of the next expected pinyin syllable.
// Example: 0 for first syllable, 1 for second
let currentCorrectPinyinIndex = 0;
// Pinyin syllable waiting for more input to complete.
// Example: 'shé' when expecting 'shén' and waiting for 'n'
let pendingPinyin = '';
// Process each character in the raw input
for (const character of raw) {
// Handle spaces: flush current syllable to output and reset state
if (character === ' ') {
displayOutput += currentPinyin + ' ';
currentPinyin = '';
pendingPinyin = '';
continue;
}
// Add character to current syllable
currentPinyin += character;
// When a tone digit is encountered, process the completed syllable
if (/[1-5]/.test(character)) {
// Normalize to lowercase for case-insensitive handling
currentPinyin = currentPinyin.toLowerCase();
const diacriticPinyin = convertUnspacedPinyin(currentPinyin); // Add tone mark
// If all expected syllables have been processed and the user has typed more
// syllables than the expected answer contains, append the additional pinyin
// syllables as-is without attempting to convert them to hanzi.
if (currentCorrectPinyinIndex >= correctPinyins.length) {
displayOutput += diacriticPinyin;
currentPinyin = '';
continue;
}
const correctSyllable = correctPinyins[currentCorrectPinyinIndex];
// Check if the input matches the expected syllable exactly.
// If so, convert to hanzi.
if (diacriticPinyin.toLowerCase() === correctSyllable.toLowerCase()) {
displayOutput += correctHanzi[currentCorrectPinyinIndex]; // Convert to hanzi
currentCorrectPinyinIndex++;
currentPinyin = '';
pendingPinyin = '';
}
// Check if base letters match but tone differs.
// If so, show incorrect toned pinyin.
else if (normalize(diacriticPinyin) === normalize(correctSyllable)) {
displayOutput += diacriticPinyin;
currentCorrectPinyinIndex++;
currentPinyin = '';
pendingPinyin = '';
}
// Check if input is a prefix of expected (e.g., 'shé' for 'shén').
// If so, show pinyin and wait for more input.
else if (
normalize(correctSyllable).startsWith(normalize(diacriticPinyin))
) {
displayOutput += diacriticPinyin;
pendingPinyin = diacriticPinyin;
currentPinyin = '';
}
// No match: show pinyin and move to next expected syllable
else {
displayOutput += diacriticPinyin;
currentCorrectPinyinIndex++;
currentPinyin = '';
pendingPinyin = '';
}
}
// Handle non-tone characters when there's pending pinyin.
// Pending pinyin occurs when the user's input is a prefix of the expected syllable
// (e.g., 'shé' for 'shén'). In this case, combine the pending pinyin with the new
// non-tone characters and check if it now matches the expected syllable. If it does,
// replace the pending pinyin in the output with the correct hanzi character.
else if (
pendingPinyin &&
currentCorrectPinyinIndex < correctPinyins.length
) {
const combinedPinyin = pendingPinyin + currentPinyin;
const correctPinyin = correctPinyins[currentCorrectPinyinIndex];
// Check if combined input now matches the expected syllable exactly.
if (combinedPinyin.toLowerCase() === correctPinyin.toLowerCase()) {
// Replace the pending pinyin at the end of displayOutput with the correct hanzi character
const endIndex = displayOutput.length - pendingPinyin.length;
displayOutput =
displayOutput.slice(0, endIndex) +
correctHanzi[currentCorrectPinyinIndex];
currentCorrectPinyinIndex++;
currentPinyin = '';
pendingPinyin = '';
}
// Check if combined input matches the base letters but tone differs
else if (normalize(combinedPinyin) === normalize(correctPinyin)) {
// Replace the pending pinyin at the end of displayOutput with the combined pinyin
const endIndex = displayOutput.length - pendingPinyin.length;
displayOutput = displayOutput.slice(0, endIndex) + combinedPinyin;
currentCorrectPinyinIndex++;
currentPinyin = '';
pendingPinyin = '';
}
}
}
// Append any unfinished syllable at the end
return displayOutput + currentPinyin;
}
interface PinyinToHanziInputProps {
index: number;
expectedAnswer: { hanzi: string; pinyin: string };
isCorrect: boolean | null;
onChange: (index: number, value: string) => void;
className?: string;
maxLength: number;
size: number;
ariaLabel: string;
}
function PinyinToHanziInput({
index,
expectedAnswer,
isCorrect,
onChange,
className,
maxLength,
size,
ariaLabel
}: PinyinToHanziInputProps): 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 = convertToHanzi(newRawInput, expectedAnswer);
setDisplayValue(newDisplayValue);
onChange(index, newDisplayValue);
};
return (
<input
type='text'
value={displayValue}
onChange={handleChange}
className={className}
maxLength={maxLength}
size={size}
autoComplete='off'
aria-label={ariaLabel}
aria-describedby={`pinyin-description-${index}`}
{...(isCorrect === false ? { 'aria-invalid': 'true' } : {})}
/>
);
}
PinyinToHanziInput.displayName = 'PinyinToHanziInput';
export default PinyinToHanziInput;

View File

@@ -38,6 +38,7 @@ import { replaceAppleQuotes } from '../../../utils/replace-apple-quotes';
import { parseHanziPinyinPairs } from './parse-blanks';
import './show.css';
import { ChallengeLang } from '../../../../../shared-dist/config/curriculum';
// Redux Setup
const mapStateToProps = createSelector(
@@ -91,7 +92,8 @@ const ShowFillInTheBlank = ({
fillInTheBlank,
helpCategory,
scene,
tests
tests,
lang
}
}
},
@@ -133,7 +135,7 @@ const ShowFillInTheBlank = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit = () => {
const handleSubmitNonChinese = () => {
const blankAnswers = fillInTheBlank.blanks.map(b => b.answer);
const newAnswersCorrect = userAnswers.map((userAnswer, i) => {
@@ -144,14 +146,47 @@ const ShowFillInTheBlank = ({
userAnswer.trim()
).toLowerCase();
const pairs = parseHanziPinyinPairs(answer);
const hanziPinyin = pairs.length === 1 ? pairs[0] : null;
return normalizedUserAnswer === answer.toLowerCase();
});
if (hanziPinyin) {
const { hanzi } = hanziPinyin;
// TODO: Implement full hanzi-pinyin validation logic
// https://github.com/freeCodeCamp/language-curricula/issues/18
return normalizedUserAnswer === hanzi;
setAnswersCorrect(newAnswersCorrect);
const hasWrongAnswer = newAnswersCorrect.some(a => a === false);
if (!hasWrongAnswer) {
setShowFeedback(false);
setFeedback(null);
openCompletionModal();
} else {
const firstWrongIndex = newAnswersCorrect.findIndex(a => a === false);
const feedback =
firstWrongIndex >= 0
? fillInTheBlank.blanks[firstWrongIndex].feedback
: null;
setFeedback(feedback);
setShowWrong(true);
setShowFeedback(true);
}
};
const handleSubmitChinese = () => {
const blankAnswers = fillInTheBlank.blanks.map(b => b.answer);
const newAnswersCorrect = userAnswers.map((userAnswer, i) => {
if (!userAnswer) return false;
const answer = blankAnswers[i];
const normalizedUserAnswer = userAnswer.trim().toLowerCase();
if (fillInTheBlank.inputType === 'pinyin-to-hanzi') {
const pairs = parseHanziPinyinPairs(answer);
if (pairs.length === 1) {
const hanziPinyin = pairs[0];
const { hanzi } = hanziPinyin;
return (
normalizedUserAnswer.replace(/\s+/g, '') ===
hanzi.replace(/\s+/g, '')
);
}
}
return normalizedUserAnswer === answer.toLowerCase();
@@ -176,6 +211,14 @@ const ShowFillInTheBlank = ({
}
};
const handleSubmit = () => {
if (lang === ChallengeLang.Chinese) {
handleSubmitChinese();
} else {
handleSubmitNonChinese();
}
};
const handleInputChange = (inputIndex: number, value: string): void => {
const newUserAnswers = [...userAnswers];
newUserAnswers[inputIndex] = value;
@@ -301,6 +344,7 @@ export const query = graphql`
helpCategory
superBlock
block
lang
fields {
slug
}

22
pnpm-lock.yaml generated
View File

@@ -411,6 +411,9 @@ importers:
path-browserify:
specifier: 1.0.1
version: 1.0.1
pinyin-tone:
specifier: 2.4.0
version: 2.4.0
postcss:
specifier: 8.4.35
version: 8.4.35
@@ -553,6 +556,9 @@ importers:
'@testing-library/react-hooks':
specifier: ^8.0.1
version: 8.0.1(@types/react@17.0.83)(react-dom@17.0.2(react@17.0.2))(react-test-renderer@17.0.2(react@17.0.2))(react@17.0.2)
'@testing-library/user-event':
specifier: 14.6.1
version: 14.6.1(@testing-library/dom@10.4.0)
'@total-typescript/ts-reset':
specifier: ^0.5.0
version: 0.5.1
@@ -4525,6 +4531,12 @@ packages:
react: <18.0.0
react-dom: <18.0.0
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
@@ -10816,6 +10828,9 @@ packages:
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
hasBin: true
pinyin-tone@2.4.0:
resolution: {integrity: sha512-ATSA0WW81iOxTTePpY3FN2hXwh8OcDuO/xP5YwdkZLBGvZnkvhF1Nhbl03fS8CRtyvwvt1cBmzRnmHRR3p/7aw==}
pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
@@ -12625,6 +12640,7 @@ packages:
supertest@6.3.3:
resolution: {integrity: sha512-EMCG6G8gDu5qEqRQ3JjjPs6+FYT1a7Hv5ApHvtSghmOFJYtsU5S+pSb6Y2EUeCEY3CmEL3mmQ8YWlPOzQomabA==}
engines: {node: '>=6.4.0'}
deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
@@ -18952,6 +18968,10 @@ snapshots:
react: 17.0.2
react-dom: 17.0.2(react@17.0.2)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)':
dependencies:
'@testing-library/dom': 10.4.0
'@tokenizer/token@0.3.0': {}
'@tootallnate/once@1.1.2': {}
@@ -27138,6 +27158,8 @@ snapshots:
sonic-boom: 4.2.0
thread-stream: 3.1.0
pinyin-tone@2.4.0: {}
pirates@4.0.6: {}
pkg-dir@3.0.0: