From 5d6eacb6156eb1a8e3904d683fcf957b4e48719f Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:35:06 -0800 Subject: [PATCH] feat(client): add pinyin-to-hanzi input to fill in the blank challenge (#63986) --- client/i18n/locales/english/translations.json | 3 +- client/package.json | 2 + client/src/redux/prop-types.ts | 6 +- .../components/fill-in-the-blanks.tsx | 159 +++++--- .../components/pinyin-to-hanzi-input.test.tsx | 353 ++++++++++++++++++ .../components/pinyin-to-hanzi-input.tsx | 237 ++++++++++++ .../Challenges/fill-in-the-blank/show.tsx | 62 ++- pnpm-lock.yaml | 22 ++ 8 files changed, 774 insertions(+), 70 deletions(-) create mode 100644 client/src/templates/Challenges/components/pinyin-to-hanzi-input.test.tsx create mode 100644 client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 8965e8b6527..3051be48c2b 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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.", diff --git a/client/package.json b/client/package.json index 6131bcab33a..e21795978e9 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 4a953cce415..f2681450719 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -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; 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 41a840e6c7d..23295877cf3 100644 --- a/client/src/templates/Challenges/components/fill-in-the-blanks.tsx +++ b/client/src/templates/Challenges/components/fill-in-the-blanks.tsx @@ -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 ( + + ); + } + + // Default text input + return ( + 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 ( <> +

{t(ariaInputDescription)}

- {paragraphs.map((p, i) => { - return ( - // both keys, i and j, are stable between renders, since - // the paragraphs are static. -

- {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.map((node, j) => { + const { type, value } = node; - if (type === 'hanzi-pinyin') { - const { hanzi, pinyin } = value; - return ( - - {hanzi} - ( - {pinyin} - ) - - ); - } - - // If a blank is answered correctly, render the answer as part of the sentence. - if (type === 'blank' && answersCorrect[value] === true) { - return ; - } - - const answerLength = getAnswerLength(blankAnswers[value]); + if (type === 'text') { + return value; + } + if (type === 'hanzi-pinyin') { + const { hanzi, pinyin } = value; return ( - - 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' } - : {})} - /> + + {hanzi} + ( + {pinyin} + ) + ); - })} -

- ); - })} + } + + // If a blank is answered correctly, render the answer as part of the sentence. + if (answersCorrect[value] === true) { + return ; + } + + return ( + + ); + })} +

+ ))}
diff --git a/client/src/templates/Challenges/components/pinyin-to-hanzi-input.test.tsx b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.test.tsx new file mode 100644 index 00000000000..da71228028a --- /dev/null +++ b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.test.tsx @@ -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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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 = { + she2nme: '什me', + she2nm: '什m', + she2n: '什', + she2: 'shé', + she: 'she' + }; + + render( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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( + + ); + + const input = screen.getByLabelText('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好'); + }); +}); diff --git a/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx new file mode 100644 index 00000000000..ec8d08d3cbc --- /dev/null +++ b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx @@ -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) => { + 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 ( + + ); +} + +PinyinToHanziInput.displayName = 'PinyinToHanziInput'; + +export default PinyinToHanziInput; 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 4c95ab32630..5b1cc32ff76 100644 --- a/client/src/templates/Challenges/fill-in-the-blank/show.tsx +++ b/client/src/templates/Challenges/fill-in-the-blank/show.tsx @@ -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 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da2968f9daf..7ccf96f48b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: