├── 404.md
├── __mocks__
├── fileMock.js
└── react-redux.js
├── jest.setup.js
├── jsconfig.json
├── assets
├── images
│ ├── title.png
│ ├── wrong.png
│ ├── arrowup.png
│ ├── correct.png
│ ├── replay.png
│ └── arrowdown.png
└── sounds
│ ├── CorrectAnswer.mp3
│ └── IncorrectAnswer.mp3
├── src
├── pages
│ ├── MainPage
│ │ ├── index.js
│ │ ├── styled.js
│ │ ├── MainPage.jsx
│ │ └── MainPage.test.jsx
│ ├── SelectPage
│ │ ├── index.js
│ │ ├── styled.js
│ │ ├── SelectPage.jsx
│ │ └── SelectPage.test.jsx
│ ├── SpeechPage.jsx
│ ├── SpeechPage.test.jsx
│ ├── SentenceSpeakPage.jsx
│ ├── YesNoPage.jsx
│ ├── NotFoundPage.jsx
│ ├── YesNoAnswersPage.test.jsx
│ ├── YesNoAnswersPage.jsx
│ ├── SentenceAnswersPage.jsx
│ ├── YesNoPage.test.jsx
│ ├── SentenceAnswersPage.test.jsx
│ ├── SentenceSpeakPage.test.jsx
│ ├── SetQuestionNumberPage.test.jsx
│ └── SetQuestionNumberPage.jsx
├── hooks
│ ├── __mocks__
│ │ ├── audio.js
│ │ └── volumeCanvas.js
│ ├── audio.js
│ ├── audio.test.js
│ └── volumeCanvas.js
├── services
│ ├── instances
│ │ ├── audioContext.instance.js
│ │ ├── speechRecognition.instance.js
│ │ ├── __mocks__
│ │ │ └── audioContext.instance.js
│ │ └── polly.instance.js
│ ├── __mocks__
│ │ ├── dataService.js
│ │ ├── speechSynthesisService.js
│ │ └── speechRecognitionService.js
│ ├── dataService.js
│ ├── speechRecognitionService.js
│ └── speechSynthesisService.js
├── components
│ ├── SentenceAnswers
│ │ ├── index.js
│ │ ├── styled.js
│ │ ├── SentenceAnswers.jsx
│ │ └── SentenceAnswers.test.jsx
│ ├── SentenceSpeakInput
│ │ ├── index.js
│ │ ├── styled.js
│ │ ├── SentenceSpeakInput.jsx
│ │ └── SentenceSpeakInput.test.jsx
│ ├── VolumeMeter.jsx
│ ├── YesNoGuideMessage.jsx
│ ├── YesNoPlayButton.jsx
│ ├── ProgressBar.test.jsx
│ ├── SpeechInput.jsx
│ ├── QuestionNumbers.test.jsx
│ ├── SpokenSentence.jsx
│ ├── SentenceSubmitButton.jsx
│ ├── YesNoSubmitButtons.jsx
│ ├── YesNoGuideMessage.test.jsx
│ ├── ProgressBar.jsx
│ ├── SentenceAnswerExamples.jsx
│ ├── QuestionNumbers.jsx
│ ├── YesNoPlayButton.test.jsx
│ ├── SentenceAnswerExamples.test.jsx
│ ├── SentenceSubmitButton.test.jsx
│ ├── SentenceAnswer.jsx
│ ├── YesNoSubmitButtons.test.jsx
│ ├── YesNoAnswer.test.jsx
│ ├── SentenceAnswer.test.jsx
│ ├── QuestionCounter.test.jsx
│ ├── SpeechInput.test.jsx
│ ├── SpokenSentence.test.jsx
│ ├── YesNoAnswer.jsx
│ └── QuestionCounter.jsx
├── containers
│ ├── YesNoContainer
│ │ ├── index.js
│ │ ├── styled.js
│ │ ├── YesNoContainer.jsx
│ │ └── YesNoContainer.test.jsx
│ ├── SetQuestionNumberContainer.jsx
│ ├── SpeechContainer.test.jsx
│ ├── SetQuestionNumberContainer.test.jsx
│ ├── SpeechContainer.jsx
│ ├── SentenceAnswersContainer.jsx
│ ├── YesNoAnswersContainer.jsx
│ ├── SentenceAnswersContainer.test.jsx
│ ├── YesNoAnswersContainer.test.jsx
│ ├── SentenceSpeakContainer.jsx
│ └── SentenceSpeakContainer.test.jsx
├── styles
│ ├── fonts.js
│ ├── common.js
│ ├── Message.js
│ ├── colors.js
│ ├── global.css
│ ├── IconButton.test.jsx
│ ├── CommonButtonActive.js
│ ├── CommonButtonInactive.js
│ └── IconButton.jsx
├── enums
│ ├── MicState.js
│ └── SoundState.js
├── redux
│ ├── slices
│ │ ├── index.js
│ │ ├── speakSentenceSlice.test.js
│ │ ├── speakSentenceSlice.js
│ │ ├── yesNoSlice.test.js
│ │ ├── applicationSlice.js
│ │ ├── yesNoSlice.js
│ │ └── applicationSlice.test.js
│ ├── store.js
│ └── epics
│ │ ├── index.js
│ │ ├── YesNoEpics.js
│ │ ├── SpeakSentenceEpics.js
│ │ ├── SpeakSentenceEpics.test.js
│ │ └── YesNoEpics.test.js
├── index.jsx
├── utils
│ ├── utils.jsx
│ └── utils.test.jsx
├── customs
│ └── CustomCarousel.jsx
├── App.jsx
└── App.test.jsx
├── steps_file.js
├── .babelrc
├── jest.config.js
├── .github
├── ISSUE_TEMPLATE
│ └── weekly-plan.md
└── workflows
│ ├── CI.yml
│ └── CD.yml
├── data
├── twoPrompts.js
├── prompts.js
├── yesNoQuestions.js
└── examples.js
├── codecept.conf.js
├── e2e_tests
├── _test.js
└── Polar_test.js
├── webpack.config.dev.js
├── webpack.config.build.js
├── .eslintrc.js
├── README.md
├── index.html
├── 404.html
└── package.json
/404.md:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: /404.html
3 | ---
--------------------------------------------------------------------------------
/__mocks__/fileMock.js:
--------------------------------------------------------------------------------
1 | module.exports = {};
2 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/assets/images/title.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeSoom/ddomal/HEAD/assets/images/title.png
--------------------------------------------------------------------------------
/assets/images/wrong.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeSoom/ddomal/HEAD/assets/images/wrong.png
--------------------------------------------------------------------------------
/assets/images/arrowup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeSoom/ddomal/HEAD/assets/images/arrowup.png
--------------------------------------------------------------------------------
/assets/images/correct.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeSoom/ddomal/HEAD/assets/images/correct.png
--------------------------------------------------------------------------------
/assets/images/replay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeSoom/ddomal/HEAD/assets/images/replay.png
--------------------------------------------------------------------------------
/src/pages/MainPage/index.js:
--------------------------------------------------------------------------------
1 | import MainPage from './MainPage';
2 |
3 | export default MainPage;
4 |
--------------------------------------------------------------------------------
/assets/images/arrowdown.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeSoom/ddomal/HEAD/assets/images/arrowdown.png
--------------------------------------------------------------------------------
/src/pages/SelectPage/index.js:
--------------------------------------------------------------------------------
1 | import SelectPage from './SelectPage';
2 |
3 | export default SelectPage;
4 |
--------------------------------------------------------------------------------
/__mocks__/react-redux.js:
--------------------------------------------------------------------------------
1 | export const useSelector = jest.fn();
2 |
3 | export const useDispatch = jest.fn();
4 |
--------------------------------------------------------------------------------
/assets/sounds/CorrectAnswer.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeSoom/ddomal/HEAD/assets/sounds/CorrectAnswer.mp3
--------------------------------------------------------------------------------
/assets/sounds/IncorrectAnswer.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeSoom/ddomal/HEAD/assets/sounds/IncorrectAnswer.mp3
--------------------------------------------------------------------------------
/src/hooks/__mocks__/audio.js:
--------------------------------------------------------------------------------
1 | export const useAudio = jest.fn();
2 |
3 | // TODO: delete this
4 | export const xx = {};
5 |
--------------------------------------------------------------------------------
/src/services/instances/audioContext.instance.js:
--------------------------------------------------------------------------------
1 | const context = new AudioContext();
2 |
3 | export default context;
4 |
--------------------------------------------------------------------------------
/src/components/SentenceAnswers/index.js:
--------------------------------------------------------------------------------
1 | import SentenceAnswers from './SentenceAnswers';
2 |
3 | export default SentenceAnswers;
4 |
--------------------------------------------------------------------------------
/src/containers/YesNoContainer/index.js:
--------------------------------------------------------------------------------
1 | import YesNoContainer from './YesNoContainer';
2 |
3 | export default YesNoContainer;
4 |
--------------------------------------------------------------------------------
/src/styles/fonts.js:
--------------------------------------------------------------------------------
1 | export const titleFont = '"Gamja Flower", cursive';
2 | export const normalFont = '"Sunflower", sans-serif';
3 |
--------------------------------------------------------------------------------
/src/components/SentenceSpeakInput/index.js:
--------------------------------------------------------------------------------
1 | import SentenceSpeakInput from './SentenceSpeakInput';
2 |
3 | export default SentenceSpeakInput;
4 |
--------------------------------------------------------------------------------
/src/enums/MicState.js:
--------------------------------------------------------------------------------
1 | const MicState = {
2 | ON: 'on',
3 | OFF: 'off',
4 | SPEAKING: 'speaking',
5 | };
6 |
7 | export default MicState;
8 |
--------------------------------------------------------------------------------
/src/hooks/__mocks__/volumeCanvas.js:
--------------------------------------------------------------------------------
1 | export const useVolumeCanvas = () => ({ current: {} });
2 |
3 | // TODO: delete this
4 | export const xx = {};
5 |
--------------------------------------------------------------------------------
/src/enums/SoundState.js:
--------------------------------------------------------------------------------
1 | const SoundState = {
2 | IDLE: 'idle',
3 | PLAYING: 'playing',
4 | END: 'end',
5 | };
6 |
7 | export default SoundState;
8 |
--------------------------------------------------------------------------------
/src/services/__mocks__/dataService.js:
--------------------------------------------------------------------------------
1 | export const fetchNextPrompt = jest.fn();
2 |
3 | export const getExamples = jest.fn();
4 |
5 | export const fetchNextYesNoQuestion = jest.fn();
6 |
--------------------------------------------------------------------------------
/src/styles/common.js:
--------------------------------------------------------------------------------
1 | export const flexBoxCenter = {
2 | display: 'flex',
3 | justifyContent: 'center',
4 | alignItems: 'center',
5 | };
6 |
7 | // TODO: delete this
8 | export const xx = {
9 |
10 | };
11 |
--------------------------------------------------------------------------------
/src/services/instances/speechRecognition.instance.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line new-cap
2 | const recognition = new window.webkitSpeechRecognition();
3 |
4 | recognition.lang = 'ko';
5 |
6 | export default recognition;
7 |
--------------------------------------------------------------------------------
/src/services/__mocks__/speechSynthesisService.js:
--------------------------------------------------------------------------------
1 | import { of } from 'rxjs';
2 |
3 | export const play = jest.fn();
4 |
5 | export const stop = jest.fn();
6 |
7 | export function playEnded() {
8 | return of('');
9 | }
10 |
--------------------------------------------------------------------------------
/src/pages/SpeechPage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import SpeechContainer from '../containers/SpeechContainer';
4 |
5 | export default function SpeechPage() {
6 | return (
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/services/instances/__mocks__/audioContext.instance.js:
--------------------------------------------------------------------------------
1 | const mockAnalyser = {
2 | getByteFrequencyData: () => {},
3 | };
4 |
5 | const context = {
6 | createAnalyser: () => mockAnalyser,
7 | resume: () => {},
8 | };
9 |
10 | export default context;
11 |
--------------------------------------------------------------------------------
/steps_file.js:
--------------------------------------------------------------------------------
1 | // in this file you can append custom step methods to 'I' object
2 |
3 | module.exports = () => actor({
4 |
5 | // Define custom steps here, use 'this' to access default methods of I.
6 | // It is recommended to place a general 'login' function here.
7 |
8 | });
9 |
--------------------------------------------------------------------------------
/src/hooks/audio.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | export const useAudio = (url) => {
4 | const [audio] = useState(new Audio(url));
5 |
6 | const play = () => audio.play();
7 |
8 | return play;
9 | };
10 |
11 | // TODO: delete this
12 | export const xx = () => {};
13 |
--------------------------------------------------------------------------------
/src/styles/Message.js:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | import { normalColor } from './colors';
4 |
5 | const Message = styled.p({
6 | fontSize: '1.5rem',
7 | color: normalColor,
8 | textAlign: 'center',
9 | width: '100%',
10 | });
11 |
12 | export default Message;
13 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-react",
4 | [
5 | "@babel/preset-env",
6 | {
7 | "targets": {
8 | "node": "current"
9 | }
10 | }
11 | ]
12 | ],
13 | "plugins": [
14 | "@babel/plugin-transform-runtime"
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/src/services/instances/polly.instance.js:
--------------------------------------------------------------------------------
1 | import AWS from 'aws-sdk';
2 |
3 | AWS.config.region = process.env.AWS_REGION;
4 | AWS.config.credentials = new AWS.CognitoIdentityCredentials({ IdentityPoolId: process.env.AWS_ID });
5 |
6 | const polly = new AWS.Polly({ apiVersion: '2016-06-10' });
7 |
8 | export default polly;
9 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | setupFiles: [
3 | 'jest-plugin-context/setup',
4 | ],
5 | setupFilesAfterEnv: [
6 | './jest.setup',
7 | 'given2/setup',
8 | ],
9 | moduleNameMapper: {
10 | '\\.mp3$': '/__mocks__/fileMock.js',
11 | '\\.css$': '/__mocks__/fileMock.js',
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/src/components/VolumeMeter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useVolumeCanvas } from '../hooks/volumeCanvas';
4 |
5 | function VolumeMeter() {
6 | const canvasRef = useVolumeCanvas();
7 |
8 | return (
9 |
10 | );
11 | }
12 |
13 | export default React.memo(VolumeMeter);
14 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/weekly-plan.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Weekly Plan
3 | about: 매주 지속적으로 가치를 전달하기 위한 문서
4 | title: ''
5 | labels: weekly plan
6 | assignees: ''
7 |
8 | ---
9 |
10 | ## 이번주에 제공해야 하는 우선순위가 높고 핵심인 기능은 무엇인가요?
11 |
12 | ## 그 기능을 구현하려면 가장 먼저 무엇을 해야 하나요?
13 |
14 | ## 당장 이번 주 일요일에 사용자가 기능을 사용할 수 있게 하려면 어떻게 할까요?
15 |
16 | ## 이번주에 해야할 일을 우선순위에 따라 나열해주세요.
17 |
--------------------------------------------------------------------------------
/src/styles/colors.js:
--------------------------------------------------------------------------------
1 | export const primaryColor = '#1CB0F6';
2 | export const secondaryColor = '#5CC6F9';
3 | export const tertiaryColor = '#116D99';
4 | export const normalColor = '#FFF';
5 | export const emphasisColor = '#FFC33F';
6 |
7 | export const correctColor = 'green';
8 | export const wrongColor = 'red';
9 |
10 | export const inactiveColor = '#DDDDDD';
11 |
--------------------------------------------------------------------------------
/src/components/YesNoGuideMessage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Message from '../styles/Message';
4 |
5 | function YesNoGuideMessage({ isIdle }) {
6 | return (
7 |
8 | {isIdle
9 | ? '버튼을 누르면 문제가 나와요'
10 | : '잘 듣고 정답을 골라보세요'}
11 |
12 | );
13 | }
14 |
15 | export default React.memo(YesNoGuideMessage);
16 |
--------------------------------------------------------------------------------
/src/pages/SpeechPage.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import SpeechPage from './SpeechPage';
6 |
7 | jest.mock('react-redux');
8 |
9 | describe('SpeechPage', () => {
10 | it('renders input', () => {
11 | const { container } = render();
12 |
13 | expect(container).toHaveTextContent('버튼을 누르고 문장을 말해보세요');
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/redux/slices/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from '@reduxjs/toolkit';
2 |
3 | import applicationReducer from './applicationSlice';
4 | import speakSentenceReducer from './speakSentenceSlice';
5 | import yesnoReducer from './yesNoSlice';
6 |
7 | const rootReducer = combineReducers({
8 | application: applicationReducer,
9 | speakSentence: speakSentenceReducer,
10 | yesno: yesnoReducer,
11 | });
12 |
13 | export default rootReducer;
14 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import { Provider } from 'react-redux';
5 |
6 | import { BrowserRouter } from 'react-router-dom';
7 |
8 | import store from './redux/store';
9 |
10 | import App from './App';
11 |
12 | ReactDOM.render(
13 |
14 |
15 |
16 |
17 | ,
18 | document.getElementById('app'),
19 | );
20 |
--------------------------------------------------------------------------------
/src/redux/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 |
3 | import { createEpicMiddleware } from 'redux-observable';
4 |
5 | import rootEpic from './epics/index';
6 |
7 | import reducer from './slices/index';
8 |
9 | const epicMiddleware = createEpicMiddleware();
10 |
11 | const store = configureStore({
12 | reducer,
13 | middleware: [epicMiddleware],
14 | });
15 |
16 | epicMiddleware.run(rootEpic);
17 |
18 | export default store;
19 |
--------------------------------------------------------------------------------
/src/services/__mocks__/speechRecognitionService.js:
--------------------------------------------------------------------------------
1 | import { of } from 'rxjs';
2 |
3 | export const recognize = jest.fn();
4 |
5 | export function soundStart() {
6 | return of('');
7 | }
8 |
9 | export function soundEnd() {
10 | return of('');
11 | }
12 |
13 | export function recognitionStart() {
14 | return of('');
15 | }
16 |
17 | export function recognitionEnd() {
18 | return of('');
19 | }
20 |
21 | export const abortRecognition = jest.fn();
22 |
--------------------------------------------------------------------------------
/src/components/SentenceAnswers/styled.js:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { normalColor } from '../../styles/colors';
3 |
4 | export const Container = styled.div({
5 | width: '30rem',
6 | padding: '1rem 1rem',
7 | });
8 |
9 | export const AnswerBox = styled.div({
10 | fontSize: '1.4rem',
11 | height: '50vh',
12 | backgroundColor: '#1792CC',
13 | color: normalColor,
14 | border: '2px solid white',
15 | borderRadius: '4px',
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/YesNoPlayButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { MdPlayCircleOutline } from 'react-icons/md';
4 | import IconButton from '../styles/IconButton';
5 |
6 | export default function YesNoPlayButton({ onClick, isPlaying }) {
7 | return (
8 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/hooks/audio.test.js:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from '@testing-library/react-hooks';
2 |
3 | import { useAudio } from './audio';
4 |
5 | const mockPlay = jest.fn();
6 |
7 | global.HTMLAudioElement.prototype.play = mockPlay;
8 |
9 | test('useAudio', () => {
10 | const { result } = renderHook(() => useAudio(''));
11 |
12 | const play = result.current;
13 |
14 | act(() => {
15 | play();
16 | });
17 |
18 | expect(mockPlay).toBeCalled();
19 | });
20 |
--------------------------------------------------------------------------------
/src/components/ProgressBar.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import ProgressBar from './ProgressBar';
6 |
7 | describe('ProgressBar', () => {
8 | it('renders current number and max number', () => {
9 | const { container } = render();
10 |
11 | expect(container).toHaveTextContent(5);
12 | expect(container).toHaveTextContent(3);
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/pages/SentenceSpeakPage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 |
3 | import { useDispatch } from 'react-redux';
4 |
5 | import SentenceSpeakContainer from '../containers/SentenceSpeakContainer';
6 |
7 | export default function SentenceSpeakPage() {
8 | const dispatch = useDispatch();
9 |
10 | useEffect(() => {
11 | dispatch({ type: 'speakSentence/getNextQuestion' });
12 | }, []);
13 |
14 | return (
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/data/twoPrompts.js:
--------------------------------------------------------------------------------
1 | const twoPrompts = [
2 | ['옷장', '티셔츠'],
3 | ['냄비', '가스레인지'],
4 | ['송편', '추석'],
5 | ['컵', '시원한'],
6 | ['지하철', '타다'],
7 | ['책', '서점'],
8 | ['1월', '새로운'],
9 | ['가수', '노래하다'],
10 | ['사진', '카메라'],
11 | ['마스크', '감기'],
12 | ['컴퓨터', '인터넷'],
13 | ['백화점', '가다'],
14 | ['마트', '채소'],
15 | ['고등어조림', '생선가게'],
16 | ['동물원', '하마'],
17 | ['배우', '영화'],
18 | ['핸드폰', '전화'],
19 | ['대통령', '정치'],
20 | ['눈', '눈사람'],
21 | ];
22 |
23 | export default twoPrompts;
24 |
--------------------------------------------------------------------------------
/src/pages/YesNoPage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 |
3 | import { useDispatch } from 'react-redux';
4 |
5 | import { getNextYesNoQuestion } from '../redux/slices/yesNoSlice';
6 |
7 | import YesNoContainer from '../containers/YesNoContainer';
8 |
9 | export default function YesNoPage() {
10 | const dispatch = useDispatch();
11 |
12 | useEffect(() => {
13 | dispatch(getNextYesNoQuestion());
14 | });
15 |
16 | return (
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/styles/global.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Gamja+Flower&family=Sunflower:wght@300;500;700&display=block');
2 |
3 | * {
4 | margin: 0;
5 | padding: 0;
6 | box-sizing: border-box;
7 | -webkit-appearance: none;
8 | }
9 |
10 | body {
11 | font-size: 16px;
12 | line-height: 1.5;
13 | background-color: grey;
14 | color: #000;
15 | word-break: keep-all;
16 | margin: 0 auto;
17 | max-width: 500px;
18 | height: 100vh;
19 | }
20 |
21 | ul {
22 | list-style: none;
23 | }
24 |
--------------------------------------------------------------------------------
/src/pages/NotFoundPage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { normalColor } from '../styles/colors';
6 |
7 | const Container = styled.div({
8 | display: 'flex',
9 | alignItems: 'center',
10 | justifyContent: 'center',
11 | height: '100vh',
12 | color: normalColor,
13 | fontSize: '2rem',
14 | });
15 |
16 | function NotFoundPage() {
17 | return (
18 |
19 | 페이지를 찾을수 없어요~
20 |
21 | );
22 | }
23 |
24 | export default NotFoundPage;
25 |
--------------------------------------------------------------------------------
/src/containers/YesNoContainer/styled.js:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.div({
4 | display: 'flex',
5 | flexDirection: 'column',
6 | alignItems: 'center',
7 | });
8 |
9 | export const BarBox = styled.div({
10 | marginTop: '3.3vh',
11 | });
12 |
13 | export const MessageBox = styled.div({
14 | marginTop: '13.6vh',
15 | });
16 |
17 | export const PlayButtonBox = styled.div({
18 | marginTop: '13.1vh',
19 | });
20 |
21 | export const SubmitButtonBox = styled.div({
22 | marginTop: '18.13vh',
23 | });
24 |
--------------------------------------------------------------------------------
/src/styles/IconButton.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import { MdMic } from 'react-icons/md';
6 |
7 | import IconButton from './IconButton';
8 |
9 | const handleClick = jest.fn();
10 |
11 | test('IconButton', () => {
12 | const { getByTitle } = render(
13 | ,
18 | );
19 |
20 | fireEvent.click(getByTitle('mic'));
21 |
22 | expect(handleClick).toBeCalled();
23 | });
24 |
--------------------------------------------------------------------------------
/src/components/SpeechInput.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MicState from '../enums/MicState';
3 |
4 | import Message from '../styles/Message';
5 |
6 | import { highlight } from '../utils/utils';
7 |
8 | export default function SpeechInput({ prompt, speech, micState }) {
9 | const highligtedSpeech = highlight({
10 | sentence: speech ?? '버튼을 누르고 문장을 말해보세요',
11 | word: prompt,
12 | });
13 |
14 | return (
15 |
16 | {micState === MicState.OFF
17 | ? highligtedSpeech
18 | : '...'}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/MainPage/styled.js:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | export const Container = styled.div({
4 | display: 'flex',
5 | flexDirection: 'column',
6 | alignItems: 'center',
7 | position: 'relative',
8 | });
9 |
10 | export const ButtonBox = styled.div({
11 | position: 'absolute',
12 | top: '73vh',
13 | });
14 |
15 | export const Title = styled.div({
16 | position: 'absolute',
17 | top: '20vh',
18 | backgroundImage: `url(${'/assets/images/title.png'})`,
19 | backgroundRepeat: 'no-repeat',
20 | height: '15.8rem',
21 | width: '19.5rem',
22 | });
23 |
--------------------------------------------------------------------------------
/src/components/QuestionNumbers.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import * as R from 'ramda';
6 |
7 | import QuestionNumbers from './QuestionNumbers';
8 |
9 | describe('QuestionNumbers', () => {
10 | const MAX = 5;
11 | const MIN = 1;
12 |
13 | it('renders all numbers between min and max', () => {
14 | const { container } = render();
15 |
16 | R.range(MIN, MAX + 1).forEach((number) => {
17 | expect(container).toHaveTextContent(number);
18 | });
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/utils/utils.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { emphasisColor } from '../styles/colors';
4 |
5 | export function get(key) {
6 | return (obj) => obj[key];
7 | }
8 |
9 | const highligtWord = (key, word) => (
10 |
11 | {word}
12 |
13 | );
14 |
15 | export function highlight({ sentence, word }) {
16 | const splitted = (sentence ?? '').split(word);
17 |
18 | if (splitted.length <= 1) {
19 | return splitted;
20 | }
21 |
22 | return splitted
23 | .flatMap((part, index) => [part, highligtWord(`${index + part}`, word)])
24 | .slice(0, -1);
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/SpokenSentence.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import MicState from '../enums/MicState';
4 |
5 | import Message from '../styles/Message';
6 |
7 | import { highlight } from '../utils/utils';
8 |
9 | export default function SpokenSentence({ micState, prompt, spokenSentence }) {
10 | const waiting = '...';
11 | const placeholder = '문장을 소리내어 말해보세요';
12 |
13 | const isInputting = micState !== MicState.OFF;
14 |
15 | const sentence = spokenSentence ?? placeholder;
16 |
17 | return (
18 |
19 | {isInputting ? waiting : highlight({ sentence, word: prompt })}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/styles/CommonButtonActive.js:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | import { primaryColor, normalColor } from './colors';
4 |
5 | import { normalFont } from './fonts';
6 |
7 | import { flexBoxCenter } from './common';
8 |
9 | const Button = styled.button({
10 | ...flexBoxCenter,
11 | width: '20rem',
12 | height: '3.25rem',
13 | backgroundColor: `${normalColor}`,
14 | color: `${primaryColor}`,
15 | fontFamily: `${normalFont}`,
16 | fontSize: '1.31rem',
17 | fontWeight: '700',
18 | borderRadius: '4px',
19 | cursor: 'pointer',
20 | border: 'none',
21 | outline: 'none',
22 | });
23 |
24 | export default Button;
25 |
--------------------------------------------------------------------------------
/src/pages/YesNoAnswersPage.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useSelector } from 'react-redux';
4 |
5 | import { render } from '@testing-library/react';
6 |
7 | import YesNoAnswersPage from './YesNoAnswersPage';
8 |
9 | jest.mock('react-redux');
10 |
11 | describe('YesNoPage', () => {
12 | beforeEach(() => {
13 | useSelector.mockImplementation((selector) => selector({
14 | application: { answers: [] },
15 | }));
16 | });
17 |
18 | it('gets next question on mount', () => {
19 | const { container } = render();
20 |
21 | expect(container).toHaveTextContent('정답 확인');
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/styles/CommonButtonInactive.js:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | import { normalColor, inactiveColor } from './colors';
4 |
5 | import { normalFont } from './fonts';
6 |
7 | import { flexBoxCenter } from './common';
8 |
9 | const Button = styled.button({
10 | ...flexBoxCenter,
11 | width: '20rem',
12 | height: '3.25rem',
13 | backgroundColor: 'transparent',
14 | color: `${inactiveColor}`,
15 | fontFamily: `${normalFont}`,
16 | fontSize: '1.31rem',
17 | fontWeight: '400',
18 | borderRadius: '4px',
19 | cursor: 'pointer',
20 | border: `2px solid ${normalColor}`,
21 | outline: 'none',
22 | });
23 |
24 | export default Button;
25 |
--------------------------------------------------------------------------------
/src/pages/MainPage/MainPage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useHistory } from 'react-router-dom';
4 |
5 | import Button from '../../styles/CommonButtonActive';
6 |
7 | import {
8 | Container,
9 | ButtonBox,
10 | Title,
11 | } from './styled';
12 |
13 | export default function MainPage() {
14 | const history = useHistory();
15 |
16 | const handleClick = () => {
17 | history.push('/select');
18 | };
19 |
20 | return (
21 |
22 |
23 |
24 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/pages/SelectPage/styled.js:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 |
3 | import { normalColor } from '../../styles/colors';
4 | import { flexBoxCenter } from '../../styles/common';
5 |
6 | export const Container = styled.div({
7 | ...flexBoxCenter,
8 | flexDirection: 'column',
9 | });
10 |
11 | export const TitleBox = styled.div({
12 | marginTop: '34.8vh',
13 | });
14 |
15 | export const Title = styled.div({
16 | fontSize: '1.5rem',
17 | color: normalColor,
18 | });
19 |
20 | export const ButtonBox = styled.div({
21 | display: 'flex',
22 | flexDirection: 'column',
23 | height: '7.5rem',
24 | justifyContent: 'space-between',
25 | marginTop: '2.38rem',
26 | });
27 |
--------------------------------------------------------------------------------
/.github/workflows/CI.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | strategy:
10 | matrix:
11 | node-version: [12.x]
12 |
13 | steps:
14 | - uses: actions/checkout@v2
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v1
17 | with:
18 | node-version: ${{ matrix.node-version }}
19 | - name: Install
20 | run: npm ci
21 | - name: Test
22 | run: npm run test:unit
23 | - name: e2e Test
24 | run: npm run test
25 | env:
26 | HEADLESS: true
27 | - name: Lint
28 | run: npm run lint
29 |
--------------------------------------------------------------------------------
/src/components/SentenceSubmitButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ActiveButton from '../styles/CommonButtonActive';
4 | import InactiveButton from '../styles/CommonButtonInactive';
5 |
6 | export default function SentenceSubmitButton({
7 | onClickExit, onClickNext, isComplete, isCorrectSentence, className,
8 | }) {
9 | const Button = isCorrectSentence ? ActiveButton : InactiveButton;
10 |
11 | return (
12 | isComplete
13 | ? (
14 |
17 | )
18 | : (
19 |
22 | )
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/customs/CustomCarousel.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Carousel } from 'react-responsive-carousel';
4 |
5 | function CustomCarousel({
6 | showStatus, showThumbs, onLast, children,
7 | }) {
8 | const customRenderArrowNext = (clickHandler, hasNext, label) => {
9 | if (!hasNext) {
10 | onLast();
11 | }
12 |
13 | const defaultFn = Carousel.defaultProps.renderArrowNext;
14 | return defaultFn(clickHandler, hasNext, label);
15 | };
16 |
17 | return (
18 |
23 | {children}
24 |
25 | );
26 | }
27 |
28 | export default React.memo(CustomCarousel);
29 |
--------------------------------------------------------------------------------
/src/services/dataService.js:
--------------------------------------------------------------------------------
1 | import uniqueRandomRange from 'unique-random-range';
2 |
3 | import examples from '../../data/examples';
4 | import prompts from '../../data/prompts';
5 | import yesNoQuestions from '../../data/yesNoQuestions';
6 |
7 | const dataIndexGenerator = (data) => uniqueRandomRange(0, data.length - 1);
8 | const promptsIndexGenerator = dataIndexGenerator(prompts);
9 | const yesnoIndexGenerator = dataIndexGenerator(yesNoQuestions);
10 |
11 | export function fetchNextPrompt() {
12 | return prompts[promptsIndexGenerator()];
13 | }
14 |
15 | export function getExamples(prompt) {
16 | return examples[prompt] || [];
17 | }
18 |
19 | export function fetchNextYesNoQuestion() {
20 | return yesNoQuestions[yesnoIndexGenerator()];
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/MainPage/MainPage.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import MainPage from './MainPage';
6 |
7 | const mockPush = jest.fn();
8 |
9 | jest.mock('react-router-dom', () => ({
10 | ...jest.requireActual('react-router-dom'),
11 | useHistory() {
12 | return { push: mockPush };
13 | },
14 | }));
15 |
16 | describe('MainPage', () => {
17 | const startButton = '시작 하기';
18 |
19 | beforeEach(() => {
20 | mockPush.mockClear();
21 | });
22 |
23 | it('renders start button', () => {
24 | const { getByText } = render();
25 |
26 | fireEvent.click(getByText(startButton));
27 |
28 | expect(mockPush).toBeCalledWith('/select');
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/components/YesNoSubmitButtons.jsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import React from 'react';
3 |
4 | import ActiveButton from '../styles/CommonButtonActive';
5 | import InactiveButton from '../styles/CommonButtonInactive';
6 |
7 | const Container = styled.div({
8 | display: 'grid',
9 | gridGap: '3.13vh',
10 | });
11 |
12 | export default function YesNoSubmitButtons({ onClick, isIdle }) {
13 | const Button = isIdle ? InactiveButton : ActiveButton;
14 |
15 | return (
16 |
17 |
20 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/redux/epics/index.js:
--------------------------------------------------------------------------------
1 | import { combineEpics } from 'redux-observable';
2 |
3 | import {
4 | saveAnswerEpic,
5 | getNextQuestionEpic,
6 | listenRecognitionEvents,
7 | recognizeSpeechEpic,
8 | } from './SpeakSentenceEpics';
9 |
10 | import {
11 | getNextYesNoQuestionEpic,
12 | playYesNoQuestionEpic,
13 | listenYesNoEndEventEpic,
14 | stopYesNoQuestionEpic,
15 | saveAndGoToNextYesNoQuestionEpic,
16 | } from './YesNoEpics';
17 |
18 | const rootEpic = combineEpics(
19 | saveAnswerEpic,
20 | getNextQuestionEpic,
21 | listenRecognitionEvents,
22 | recognizeSpeechEpic,
23 | getNextYesNoQuestionEpic,
24 | playYesNoQuestionEpic,
25 | listenYesNoEndEventEpic,
26 | stopYesNoQuestionEpic,
27 | saveAndGoToNextYesNoQuestionEpic,
28 | );
29 |
30 | export default rootEpic;
31 |
--------------------------------------------------------------------------------
/src/pages/YesNoAnswersPage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import YesNoAnswersContainer from '../containers/YesNoAnswersContainer';
6 | import { normalColor } from '../styles/colors';
7 |
8 | const Container = styled.div({
9 | display: 'flex',
10 | flexDirection: 'column',
11 | alignItems: 'center',
12 | });
13 |
14 | const TitleBox = styled.div({
15 | marginTop: '7.5vh',
16 | });
17 |
18 | const Title = styled.h1({
19 | fontSize: '1.5rem',
20 | fontWeight: '700',
21 | color: normalColor,
22 | });
23 |
24 | export default function YesNoPage() {
25 | return (
26 |
27 |
28 | 정답 확인
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/pages/SentenceAnswersPage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import SentenceAnswersContainer from '../containers/SentenceAnswersContainer';
6 |
7 | import { flexBoxCenter } from '../styles/common';
8 |
9 | import { normalColor } from '../styles/colors';
10 |
11 | const Title = styled.h2({
12 | marginTop: '5vh',
13 | color: normalColor,
14 | });
15 |
16 | const Container = styled.div({
17 | ...flexBoxCenter,
18 | flexDirection: 'column',
19 | });
20 |
21 | const Box = styled.div({
22 | marginTop: '1rem',
23 | });
24 |
25 | export default function SentenceAnswersPage() {
26 | return (
27 |
28 |
29 | 오늘 말해본 문장
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/services/speechRecognitionService.js:
--------------------------------------------------------------------------------
1 | import { fromEvent } from 'rxjs';
2 | import { map } from 'rxjs/operators';
3 |
4 | import recognition from './instances/speechRecognition.instance';
5 |
6 | export function recognize() {
7 | recognition.start();
8 |
9 | return fromEvent(recognition, 'result')
10 | .pipe(map((event) => event.results[0][0].transcript));
11 | }
12 |
13 | export function abortRecognition() {
14 | recognition.abort();
15 | }
16 |
17 | export function soundStart() {
18 | return fromEvent(recognition, 'soundstart');
19 | }
20 |
21 | export function soundEnd() {
22 | return fromEvent(recognition, 'soundend');
23 | }
24 |
25 | export function recognitionStart() {
26 | return fromEvent(recognition, 'start');
27 | }
28 |
29 | export function recognitionEnd() {
30 | return fromEvent(recognition, 'end');
31 | }
32 |
--------------------------------------------------------------------------------
/codecept.conf.js:
--------------------------------------------------------------------------------
1 | const { setHeadlessWhen } = require('@codeceptjs/configure');
2 |
3 | // turn on headless mode when running with HEADLESS=true environment variable
4 | // HEADLESS=true npx codecept run
5 | setHeadlessWhen(process.env.HEADLESS);
6 |
7 | exports.config = {
8 | tests: './e2e_tests/*_test.js',
9 | output: './output',
10 | helpers: {
11 | Playwright: {
12 | url: 'http://localhost:8080',
13 | show: true,
14 | browser: 'chromium',
15 | },
16 | },
17 | include: {
18 | I: './steps_file.js',
19 | },
20 | bootstrap: null,
21 | mocha: {},
22 | name: 'final_project',
23 | plugins: {
24 | pauseOnFail: {},
25 | retryFailedStep: {
26 | enabled: true,
27 | },
28 | tryTo: {
29 | enabled: true,
30 | },
31 | screenshotOnFail: {
32 | enabled: true,
33 | },
34 | },
35 | };
36 |
--------------------------------------------------------------------------------
/e2e_tests/_test.js:
--------------------------------------------------------------------------------
1 | Feature('Application elments');
2 |
3 | Scenario('Main Page', ({ I }) => {
4 | I.amOnPage('/');
5 | I.see('시작 하기');
6 | I.click('시작 하기');
7 | I.see('무엇을 연습해 볼까요?');
8 | });
9 |
10 | Scenario('Select Page', ({ I }) => {
11 | I.amOnPage('/select');
12 | I.see('무엇을 연습해 볼까요?');
13 | I.see('문장 만들기');
14 | I.see('듣고 이해하기');
15 | I.click('문장 만들기');
16 | I.see('시작하기');
17 | I.click('시작하기');
18 | I.see('문장을 소리내어 말해보세요');
19 | });
20 |
21 | Scenario('Sentence Page', ({ I }) => {
22 | I.amOnPage('/sentence');
23 | I.see('문장을 소리내어 말해보세요');
24 | I.see('다음 문제');
25 | I.click('다음 문제');
26 | I.click('다음 문제');
27 | I.click('종료');
28 | I.see('오늘 말해본 문장');
29 | });
30 |
31 | Scenario('Answers Page', ({ I }) => {
32 | I.amOnPage('/answers');
33 | I.see('오늘 말해본 문장');
34 | I.see('처음으로');
35 | I.click('처음으로');
36 | I.see('시작 하기');
37 | });
38 |
--------------------------------------------------------------------------------
/src/pages/YesNoPage.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import { useDispatch, useSelector } from 'react-redux';
6 |
7 | import YesNoPage from './YesNoPage';
8 |
9 | import { getNextYesNoQuestion } from '../redux/slices/yesNoSlice';
10 |
11 | global.HTMLMediaElement.prototype.pause = jest.fn();
12 |
13 | describe('YesNoPage', () => {
14 | const dispatch = jest.fn();
15 |
16 | beforeEach(() => {
17 | dispatch.mockClear();
18 |
19 | useDispatch.mockImplementation(() => dispatch);
20 |
21 | useSelector.mockImplementation((selector) => selector({
22 | application: { answers: [] },
23 | yesno: {},
24 | }));
25 | });
26 |
27 | it('gets next question on mount', () => {
28 | render();
29 |
30 | expect(dispatch).toBeCalledWith(getNextYesNoQuestion());
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/src/components/SentenceAnswers/SentenceAnswers.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import 'react-responsive-carousel/lib/styles/carousel.min.css';
4 |
5 | import CustomCarousel from '../../customs/CustomCarousel';
6 |
7 | import SentenceAnswer from '../SentenceAnswer';
8 |
9 | import {
10 | Container,
11 | AnswerBox,
12 | } from './styled';
13 |
14 | export default function SentenceAnswers({ answers, onClickReplay, onClickLastSlide }) {
15 | return (
16 |
17 |
22 | {answers.map((answer) => (
23 |
24 |
25 |
26 | ))}
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/YesNoGuideMessage.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import YesNoGuideMessage from './YesNoGuideMessage';
6 |
7 | describe('YesNoGuideMessage', () => {
8 | const idleMessage = '버튼을 누르면 문제가 나와요';
9 | const playingMessage = '잘 듣고 정답을 골라보세요';
10 |
11 | const renderYesNoGuideMessage = ({ isIdle }) => render(
12 | ,
15 | );
16 |
17 | it('renders idle message on idle state', () => {
18 | const { container } = renderYesNoGuideMessage({ isIdle: true });
19 |
20 | expect(container).toHaveTextContent(idleMessage);
21 | });
22 |
23 | it('renders playing message on not idle state', () => {
24 | const { container } = renderYesNoGuideMessage({ isIdle: false });
25 |
26 | expect(container).toHaveTextContent(playingMessage);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/containers/SetQuestionNumberContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import QuestionCounter from '../components/QuestionCounter';
6 |
7 | import { setNumberOfQuestions } from '../redux/slices/applicationSlice';
8 |
9 | import { get } from '../utils/utils';
10 |
11 | export default function SentenceAnswersContainer() {
12 | const { numberOfQuestions } = useSelector(get('application'));
13 |
14 | const dispatch = useDispatch();
15 |
16 | const handleClickIncrease = () => {
17 | dispatch(setNumberOfQuestions(numberOfQuestions + 1));
18 | };
19 |
20 | const handleClickDecrease = () => {
21 | dispatch(setNumberOfQuestions(numberOfQuestions - 1));
22 | };
23 |
24 | return (
25 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/redux/slices/speakSentenceSlice.test.js:
--------------------------------------------------------------------------------
1 | import MicState from '../../enums/MicState';
2 |
3 | import reducer, {
4 | setSpokenSentence,
5 | setMicState,
6 | setPrompt,
7 | } from './speakSentenceSlice';
8 |
9 | jest.mock('../../services/speechRecognitionService');
10 |
11 | describe('reducer', () => {
12 | describe('setSpokenSentence', () => {
13 | it('changes spoken sentence', () => {
14 | const state = reducer({
15 | spokenSentence: '',
16 | }, setSpokenSentence('spoken'));
17 |
18 | expect(state.spokenSentence).toBe('spoken');
19 | });
20 | });
21 |
22 | test('setMicState', () => {
23 | const state = reducer({
24 | micState: MicState.OFF,
25 | }, setMicState(MicState.ON));
26 |
27 | expect(state.micState).toBe(MicState.ON);
28 | });
29 |
30 | test('setPrompt', () => {
31 | const state = reducer({
32 | prompt: '',
33 | }, setPrompt('마늘'));
34 |
35 | expect(state.prompt).toBe('마늘');
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/SentenceSpeakInput/styled.js:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { emphasisColor } from '../../styles/colors';
3 |
4 | import { flexBoxCenter } from '../../styles/common';
5 |
6 | export const Container = styled.div({
7 | ...flexBoxCenter,
8 | flexDirection: 'column',
9 | });
10 |
11 | export const SentenceBox = styled.div({
12 | marginTop: '9.06vh',
13 | display: 'flex',
14 | flexDirection: 'column',
15 | alignItems: 'center',
16 | });
17 |
18 | export const WarningMessage = styled.div(({ isHidden }) => ({
19 | fontSize: '1rem',
20 | fontWeight: '600',
21 | marginBottom: '0.3rem',
22 | color: emphasisColor,
23 | visibility: `${isHidden ? 'hidden' : 'visible'}`,
24 | }));
25 |
26 | export const MicBox = styled.div({
27 | marginTop: '10.78vh',
28 | position: 'relative',
29 | zIndex: '100',
30 | });
31 |
32 | export const MeterBox = styled.div({
33 | position: 'absolute',
34 | top: '-100%',
35 | left: '-100%',
36 | zIndex: '-500',
37 | });
38 |
--------------------------------------------------------------------------------
/src/pages/SentenceAnswersPage.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import { useDispatch, useSelector } from 'react-redux';
6 |
7 | import SentenceAnswersPage from './SentenceAnswersPage';
8 |
9 | jest.mock('react-redux');
10 | jest.mock('../services/speechRecognitionService.js');
11 |
12 | describe('SentenceAnswersPage', () => {
13 | const answer = {
14 | prompt: '사과',
15 | spokenSentence: '사과는 맛있다',
16 | examples: [],
17 | };
18 |
19 | beforeEach(() => {
20 | useSelector.mockImplementation((selector) => selector({
21 | application: {
22 | answers: [answer],
23 | },
24 | }));
25 |
26 | useDispatch.mockImplementation(() => jest.fn());
27 | });
28 |
29 | it('renders answer', () => {
30 | const { container } = render();
31 |
32 | expect(container).toHaveTextContent(answer.prompt);
33 | expect(container).toHaveTextContent(answer.spokenSentence);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | const Dotenv = require('dotenv-webpack');
2 |
3 | module.exports = () => ({
4 | mode: 'development',
5 | entry: './src/index.jsx',
6 | output: {
7 | filename: 'main.js',
8 | },
9 | devServer: {
10 | historyApiFallback: true,
11 | },
12 | module: {
13 | rules: [
14 | {
15 | test: /\.m?jsx?$/,
16 | exclude: /(node_modules|bower_components)/,
17 | use: {
18 | loader: 'babel-loader',
19 | options: {
20 | presets: ['@babel/preset-env'],
21 | },
22 | },
23 | },
24 | {
25 | test: /\.mp3$/,
26 | loader: 'file-loader',
27 | query: {
28 | name: 'static/media/[name].[hash:8].[ext]',
29 | },
30 | },
31 | {
32 | test: /\.css$/i,
33 | use: ['style-loader', 'css-loader'],
34 | },
35 | ],
36 | },
37 | resolve: {
38 | extensions: ['.js', '.jsx'],
39 | },
40 | plugins: [
41 | new Dotenv(),
42 | ],
43 | });
44 |
--------------------------------------------------------------------------------
/src/styles/IconButton.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { flexBoxCenter } from './common';
6 | import { normalColor, primaryColor } from './colors';
7 |
8 | const IconBox = styled.button(({ disabled }) => ({
9 | backgroundColor: normalColor,
10 | ...flexBoxCenter,
11 | width: '4.6rem',
12 | height: '4.6rem',
13 | borderRadius: '6px',
14 | outline: 'unset',
15 | border: '0.3px solid lightgray',
16 | opacity: `${disabled ? '.5' : '1'}`,
17 | }));
18 |
19 | const getIcon = (Icon) => styled(Icon)`
20 | cursor: pointer;
21 | color: ${primaryColor};
22 | `;
23 |
24 | export default function IconButton({
25 | Icon, iconSize, iconTitle, onClick, disabled, className,
26 | }) {
27 | const StyledIcon = getIcon(Icon);
28 |
29 | return (
30 |
35 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils/utils.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import { get, highlight } from './utils';
6 |
7 | import { emphasisColor } from '../styles/colors';
8 |
9 | describe('utils', () => {
10 | test('get', () => {
11 | const object = {
12 | name: 'Kim',
13 | };
14 |
15 | const getName = get('name');
16 |
17 | expect(getName(object)).toBe('Kim');
18 | });
19 |
20 | describe('highlight', () => {
21 | const sentence = '사과가 너무너무 사과하다';
22 | const word = '사과';
23 |
24 | it('renders highlited words', () => {
25 | const { getAllByText } = render(
26 | <>
27 | {highlight({ sentence, word })}
28 | >,
29 | );
30 |
31 | getAllByText(word).forEach((element) => {
32 | expect(element).toHaveStyle(`color: ${emphasisColor}`);
33 | });
34 | });
35 |
36 | it('renders nothing with null sentence', () => {
37 | expect(highlight({
38 | sentence: null,
39 | })).toEqual(['']);
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/components/ProgressBar.jsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import React from 'react';
3 | import { normalColor, tertiaryColor } from '../styles/colors';
4 |
5 | const Container = styled.div({
6 | transform: 'translateX(.5rem)',
7 | });
8 |
9 | const MaxBar = styled.div({
10 | display: 'inline-block',
11 | width: '17.8rem',
12 | backgroundColor: tertiaryColor,
13 | height: '.88rem',
14 | borderRadius: '10px',
15 | });
16 |
17 | const CurrentBar = styled.div(({ current, max }) => ({
18 | backgroundColor: 'white',
19 | height: '.88rem',
20 | width: `calc(100%*${current}/${max})`,
21 | borderRadius: '10px',
22 | }));
23 |
24 | const Count = styled.span({
25 | marginLeft: '.5rem',
26 | color: normalColor,
27 | });
28 |
29 | function ProgressBar({ currentNumber, maxNumber, className }) {
30 | return (
31 |
32 |
33 |
34 |
35 |
36 | {currentNumber}
37 | /
38 | {maxNumber}
39 |
40 |
41 | );
42 | }
43 |
44 | export default React.memo(ProgressBar);
45 |
--------------------------------------------------------------------------------
/src/components/SentenceAnswerExamples.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { highlight } from '../utils/utils';
6 |
7 | const Example = styled.div({
8 | marginTop: '1rem',
9 | fontSize: '1.2rem',
10 | fontWeight: '300',
11 | });
12 |
13 | const ReplayButton = styled.button({
14 | backgroundImage: 'url("/assets/images/replay.png")',
15 | width: '1.5rem',
16 | height: '1.5rem',
17 | backgroundSize: 'contain',
18 | backgroundRepeat: 'no-repeat',
19 | border: 'none',
20 | backgroundColor: 'transparent',
21 | cursor: 'pointer',
22 | transform: 'translate(.4rem, .3rem)',
23 | });
24 |
25 | export default function SentenceAnswer({ examples, prompt, onClickReplay }) {
26 | return (
27 | <>
28 | {examples.map((example) => (
29 |
30 | {highlight({
31 | sentence: example,
32 | word: prompt,
33 | })}
34 | onClickReplay(example)}
38 | />
39 |
40 | ))}
41 | >
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/QuestionNumbers.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import * as R from 'ramda';
4 |
5 | import styled from '@emotion/styled';
6 |
7 | import { normalColor } from '../styles/colors';
8 | import { flexBoxCenter } from '../styles/common';
9 |
10 | const Container = styled.div(({ noQ }) => ({
11 | transform: `translateY(${4 * (noQ - 4)}rem)`,
12 | transition: 'all .3s',
13 | }));
14 |
15 | const Number = styled.div(({ diff }) => ({
16 | ...flexBoxCenter,
17 | color: normalColor,
18 | lineHeight: '1',
19 | height: '4rem',
20 | visibility: `${(diff === 0 || diff === 1) ? 'visible' : 'hidden'}`,
21 | opacity: `${(diff === 1) ? '0.5' : '1'}`,
22 | fontSize: `${(diff === 1) ? '1.5rem' : '3rem'}`,
23 | }));
24 |
25 | export default function QuestionNumbers({ numberOfQuestions, min, max }) {
26 | const calculateDiff = (first, second) => Math.abs(first - second);
27 |
28 | return (
29 |
30 | {R.range(min, max + 1).reverse().map((number) => (
31 |
32 | {number}
33 |
34 | ))}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/redux/slices/speakSentenceSlice.js:
--------------------------------------------------------------------------------
1 | import { createAction, createSlice } from '@reduxjs/toolkit';
2 |
3 | import MicState from '../../enums/MicState';
4 |
5 | const initialState = {
6 | prompt: null,
7 | micState: MicState.OFF,
8 | };
9 |
10 | const { reducer, actions } = createSlice({
11 | name: 'speakSentence',
12 | initialState,
13 | reducers: {
14 | setSpokenSentence(state, { payload: spokenSentence }) {
15 | return {
16 | ...state,
17 | spokenSentence,
18 | };
19 | },
20 | setPrompt(state, { payload: prompt }) {
21 | return {
22 | ...state,
23 | prompt,
24 | };
25 | },
26 | setMicState(state, { payload: micState }) {
27 | return {
28 | ...state,
29 | micState,
30 | };
31 | },
32 | },
33 | });
34 |
35 | export const {
36 | setSpokenSentence,
37 | setMicState,
38 | setPrompt,
39 | } = actions;
40 |
41 | export const recognizeSpeech = createAction('speakSentence/recognizeSpeech');
42 | export const getNextQuestion = createAction('speakSentence/getNextQuestion');
43 | export const saveAnswer = createAction('speakSentence/saveAnswer');
44 |
45 | export default reducer;
46 |
--------------------------------------------------------------------------------
/e2e_tests/Polar_test.js:
--------------------------------------------------------------------------------
1 | Feature('사용자는 문제를 풀기 위해 예/아니오 버튼을 누를 수 있다.');
2 |
3 | Scenario('Play question', ({ I }) => {
4 | // Given - 예 / 아니오 문제 페이지에서
5 | I.amOnPage('/yesno');
6 | I.seeElement('svg');
7 | I.see('버튼을 누르면 문제가 나와요');
8 | // When - 재생 아이콘을 누르면
9 | I.click('svg');
10 | // Then - 문제가 재생된다
11 | I.see('잘 듣고 정답을 골라보세요');
12 | });
13 |
14 | // Feature('사용자는 문제를 다시 확인하기 위해 버튼을 눌러 문제를 다시 들을 수 있다.');
15 |
16 | // Scenario('Replay question', ({ I }) => {
17 | // // Given - 예 / 아니오 문제 페이지에서
18 | // I.amOnPage('/yesno');
19 | // // When - 재생 아이콘을 누르면
20 | // I.click('svg');
21 | // // Then - 또 재생 아이콘을 누를 수 있다.
22 | // I.click('svg');
23 | // });
24 |
25 | Feature('사용자는 문제를 풀기 위해 예/아니오 버튼을 누를 수 있다.');
26 |
27 | Scenario('Click yes', ({ I }) => {
28 | // Given - 예 / 아니오 문제 페이지에서
29 | I.amOnPage('/yesno');
30 | // When - 재생버튼을 누르면
31 | I.click('svg');
32 | // Then - 맞아요 버튼을 누를 수 있다.
33 | I.click('맞아요');
34 | });
35 |
36 | Scenario('Click no', ({ I }) => {
37 | // Given - 예 / 아니오 문제 페이지에서
38 | I.amOnPage('/yesno');
39 | // When - 재생버튼을 누르면
40 | I.click('svg');
41 | // Then - 아니에요 버튼을 누를 수 있다.
42 | I.click('아니에요');
43 | });
44 |
45 | Feature('사용자는 정확히 무엇이 틀렸고 맞았는지 확인하기 위해 문자로 된 문제와 고른 답을 확인 할 수 있다.');
46 |
--------------------------------------------------------------------------------
/src/redux/slices/yesNoSlice.test.js:
--------------------------------------------------------------------------------
1 | import SoundState from '../../enums/SoundState';
2 |
3 | import reducer, {
4 | setYesNoQuestion,
5 | startPlaying,
6 | endPlaying,
7 | idlePlaying,
8 | } from './yesNoSlice';
9 |
10 | jest.mock('../../services/speechRecognitionService');
11 |
12 | describe('reducer', () => {
13 | test('setYesNoQuestion', () => {
14 | const state = reducer({
15 | yesNoQuestion: null,
16 | }, setYesNoQuestion({
17 | question: '지구는 둥급니까?',
18 | answer: '네',
19 | }));
20 |
21 | expect(state.yesNoQuestion.question).toBe('지구는 둥급니까?');
22 | });
23 |
24 | test('startPlaying', () => {
25 | const state = reducer({
26 | soundState: SoundState.IDLE,
27 | }, startPlaying());
28 |
29 | expect(state.soundState).toBe(SoundState.PLAYING);
30 | });
31 |
32 | test('stopPlaying', () => {
33 | const state = reducer({
34 | soundState: SoundState.PLAYING,
35 | }, endPlaying());
36 |
37 | expect(state.soundState).toBe(SoundState.END);
38 | });
39 |
40 | test('idlePlaying', () => {
41 | const state = reducer({
42 | soundState: SoundState.PLAYING,
43 | }, idlePlaying());
44 |
45 | expect(state.soundState).toBe(SoundState.IDLE);
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/redux/slices/applicationSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | const initialState = {
4 | answers: [],
5 | isGameEnd: false,
6 | numberOfQuestions: 3,
7 | };
8 |
9 | const { reducer, actions } = createSlice({
10 | name: 'application',
11 | initialState,
12 | reducers: {
13 | addAnswer(state, { payload: answer }) {
14 | const { answers } = state;
15 |
16 | return {
17 | ...state,
18 | answers: [
19 | ...answers,
20 | answer,
21 | ],
22 | };
23 | },
24 | clearAnswers(state) {
25 | return {
26 | ...state,
27 | answers: [],
28 | };
29 | },
30 | endGame(state) {
31 | return {
32 | ...state,
33 | isGameEnd: true,
34 | };
35 | },
36 | setNumberOfQuestions(state, { payload: numberOfQuestions }) {
37 | return {
38 | ...state,
39 | numberOfQuestions,
40 | };
41 | },
42 | initializeState() {
43 | return initialState;
44 | },
45 | },
46 | });
47 |
48 | export const {
49 | addAnswer,
50 | clearAnswers,
51 | startGame,
52 | endGame,
53 | setNumberOfQuestions,
54 | initializeState,
55 | } = actions;
56 |
57 | export default reducer;
58 |
--------------------------------------------------------------------------------
/src/components/YesNoPlayButton.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import YesNoPlayButton from './YesNoPlayButton';
6 |
7 | describe('YesNoPlayButton', () => {
8 | const playButton = 'play';
9 |
10 | const handleClick = jest.fn();
11 |
12 | const renderYesNoPlayButton = ({ isPlaying }) => render(
13 | ,
17 | );
18 |
19 | beforeEach(() => {
20 | handleClick.mockClear();
21 | });
22 |
23 | context('when sound is not playing', () => {
24 | const isPlaying = false;
25 |
26 | it('user can click play button', () => {
27 | const { getByTitle } = renderYesNoPlayButton({ isPlaying });
28 |
29 | fireEvent.click(getByTitle(playButton));
30 |
31 | expect(handleClick).toBeCalled();
32 | });
33 | });
34 |
35 | context('when sound is playing', () => {
36 | const isPlaying = true;
37 |
38 | it('user cannot click play button', () => {
39 | const { getByTitle } = renderYesNoPlayButton({ isPlaying });
40 |
41 | fireEvent.click(getByTitle(playButton));
42 |
43 | expect(handleClick).not.toBeCalled();
44 | });
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/webpack.config.build.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const path = require('path');
3 |
4 | module.exports = {
5 | mode: 'production',
6 | entry: './src/index.jsx',
7 | output: {
8 | filename: 'main.js',
9 | path: path.resolve(__dirname, 'dist'),
10 | },
11 | devServer: {
12 | historyApiFallback: true,
13 | contentBase: path.join(__dirname, 'dist'),
14 | },
15 | module: {
16 | rules: [
17 | {
18 | test: /\.m?jsx?$/,
19 | exclude: /(node_modules|bower_components)/,
20 | use: {
21 | loader: 'babel-loader',
22 | options: {
23 | presets: ['@babel/preset-env'],
24 | },
25 | },
26 | },
27 | {
28 | test: /\.mp3$/,
29 | loader: 'file-loader',
30 | query: {
31 | name: 'static/media/[name].[hash:8].[ext]',
32 | },
33 | },
34 | {
35 | test: /\.css$/i,
36 | use: ['style-loader', 'css-loader'],
37 | },
38 | ],
39 | },
40 | resolve: {
41 | extensions: ['.js', '.jsx'],
42 | },
43 | plugins: [
44 | new webpack.DefinePlugin({
45 | 'process.env': {
46 | AWS_REGION: JSON.stringify(process.env.AWS_REGION),
47 | AWS_ID: JSON.stringify(process.env.AWS_ID),
48 | },
49 | }),
50 | ],
51 | };
52 |
--------------------------------------------------------------------------------
/.github/workflows/CD.yml:
--------------------------------------------------------------------------------
1 | name: Build and Deploy
2 | on: [push]
3 | jobs:
4 | build-and-deploy:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - name: Checkout 🛎️
8 | uses: actions/checkout@v2.3.1 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
9 | with:
10 | persist-credentials: false
11 |
12 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
13 | run: |
14 | npm ci
15 | npm run build
16 | env:
17 | AWS_REGION: ${{ secrets.AWS_REGION }}
18 | AWS_ID: ${{ secrets.AWS_ID }}
19 |
20 | - name: Move Files
21 | run: |
22 | mv -t ./dist/ ./index.html ./404.html ./404.md
23 | mv ./assets/ ./dist/
24 |
25 | - name: Deploy 🚀
26 | uses: JamesIves/github-pages-deploy-action@3.7.1
27 | with:
28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29 | BRANCH: gh-pages # The branch the action should deploy to.
30 | FOLDER: dist # The folder the action should deploy.
31 | CLEAN: true # Automatically remove deleted files from the deploy branch
32 |
--------------------------------------------------------------------------------
/src/containers/SpeechContainer.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import { useDispatch, useSelector } from 'react-redux';
6 |
7 | import SpeechContainer from './SpeechContainer';
8 | import { recognizeSpeech } from '../redux/slices/speakSentenceSlice';
9 |
10 | describe('SpeechContainer', () => {
11 | const prompts = ['사과', '수박'];
12 | const speech = '사과가 맛있네요';
13 |
14 | const dispatch = jest.fn();
15 |
16 | beforeEach(() => {
17 | useDispatch.mockReturnValue(dispatch);
18 |
19 | useSelector.mockImplementation((selector) => selector({
20 | speech,
21 | prompts,
22 | }));
23 | });
24 |
25 | const renderSpeechContainer = () => render(
26 | ,
27 | );
28 |
29 | it('renders prompts', () => {
30 | const { container } = renderSpeechContainer();
31 |
32 | prompts.forEach((prompt) => {
33 | expect(container).toHaveTextContent(prompt);
34 | });
35 | });
36 |
37 | it('renders spoken sentence', () => {
38 | const { container } = renderSpeechContainer();
39 |
40 | expect(container).toHaveTextContent(speech);
41 | });
42 |
43 | it('renders speak sentence button', () => {
44 | const { getByTitle } = renderSpeechContainer();
45 |
46 | fireEvent.click(getByTitle('mic'));
47 |
48 | expect(dispatch).toBeCalledWith(recognizeSpeech());
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/src/redux/slices/yesNoSlice.js:
--------------------------------------------------------------------------------
1 | import { createAction, createSlice } from '@reduxjs/toolkit';
2 |
3 | import SoundState from '../../enums/SoundState';
4 |
5 | const initialState = {
6 | soundState: SoundState.IDLE,
7 | };
8 |
9 | const { reducer, actions } = createSlice({
10 | name: 'yesno',
11 | initialState,
12 | reducers: {
13 | setYesNoQuestion(state, { payload: yesNoQuestion }) {
14 | return {
15 | ...state,
16 | yesNoQuestion,
17 | };
18 | },
19 | startPlaying(state) {
20 | return {
21 | ...state,
22 | soundState: SoundState.PLAYING,
23 | };
24 | },
25 | endPlaying(state) {
26 | return {
27 | ...state,
28 | soundState: SoundState.END,
29 | };
30 | },
31 | idlePlaying(state) {
32 | return {
33 | ...state,
34 | soundState: SoundState.IDLE,
35 | };
36 | },
37 | },
38 | });
39 |
40 | export const {
41 | setYesNoQuestion,
42 | startPlaying,
43 | endPlaying,
44 | idlePlaying,
45 | } = actions;
46 |
47 | export const getNextYesNoQuestion = createAction('yesno/getNextYesNoQuestion');
48 | export const playYesNoQuestion = createAction('yesno/playYesNoQuestion');
49 | export const stopYesNoQuestion = createAction('yesno/stopYesNoQuestion');
50 | export const saveAndGoToNextYesNoQuestion = createAction('yesno/saveAndGoToNextYesNoQuestion');
51 |
52 | export default reducer;
53 |
--------------------------------------------------------------------------------
/src/components/SentenceAnswerExamples.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import SentenceAnswerExamples from './SentenceAnswerExamples';
6 |
7 | jest.mock('react-redux');
8 |
9 | const mockPush = jest.fn();
10 |
11 | jest.mock('react-router-dom', () => ({
12 | ...jest.requireActual('react-router-dom'),
13 | useHistory() {
14 | return { push: mockPush };
15 | },
16 | }));
17 |
18 | describe('SentenceAnswerExamples', () => {
19 | const prompt = '사과';
20 | const examples = [
21 | '사과는 빨갛다',
22 | '사과하나 주세요',
23 | ];
24 |
25 | const handleClickReplay = jest.fn();
26 |
27 | const renderSentenceAnswerExamples = () => render(
28 | ,
33 | );
34 |
35 | beforeEach(() => {
36 | handleClickReplay.mockClear();
37 | });
38 |
39 | it('renders examples', () => {
40 | const { container } = renderSentenceAnswerExamples();
41 |
42 | examples.forEach((example) => {
43 | expect(container).toHaveTextContent(example);
44 | });
45 | });
46 |
47 | it('renders replay button on each side of example', () => {
48 | const { getAllByTitle } = renderSentenceAnswerExamples();
49 |
50 | examples.forEach((example, idx) => {
51 | fireEvent.click(getAllByTitle('replay')[idx]);
52 |
53 | expect(handleClickReplay).toBeCalledWith(example);
54 | });
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/src/pages/SelectPage/SelectPage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useHistory } from 'react-router-dom';
4 |
5 | import { useDispatch } from 'react-redux';
6 |
7 | import { initializeState } from '../../redux/slices/applicationSlice';
8 |
9 | import Button from '../../styles/CommonButtonActive';
10 |
11 | import {
12 | Container,
13 | TitleBox,
14 | Title,
15 | ButtonBox,
16 | } from './styled';
17 |
18 | import context from '../../services/instances/audioContext.instance';
19 |
20 | export default function SelectPage() {
21 | const history = useHistory();
22 | const dispatch = useDispatch();
23 |
24 | const handleClickSpeakSentence = () => {
25 | history.push({
26 | pathname: '/setnumber',
27 | search: '?game=speak_sentence',
28 | });
29 | dispatch(initializeState());
30 | context.resume();
31 | };
32 |
33 | const handleClickYesno = () => {
34 | history.push({
35 | pathname: '/setnumber',
36 | search: '?game=yesno',
37 | });
38 | dispatch(initializeState());
39 | context.resume();
40 | };
41 |
42 | return (
43 |
44 |
45 |
46 | 무엇을 연습해 볼까요?
47 |
48 |
49 |
50 |
53 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/redux/slices/applicationSlice.test.js:
--------------------------------------------------------------------------------
1 | import reducer, {
2 | addAnswer,
3 | clearAnswers,
4 | endGame,
5 | setNumberOfQuestions,
6 | initializeState,
7 | } from './applicationSlice';
8 |
9 | jest.mock('../../services/speechRecognitionService');
10 |
11 | describe('reducer', () => {
12 | test('addAnswer', () => {
13 | const state = reducer({
14 | answers: [],
15 | }, addAnswer({ prompt: '사과', sentence: '사과가 맛있다' }));
16 |
17 | expect(state.answers).toEqual([
18 | { prompt: '사과', sentence: '사과가 맛있다' },
19 | ]);
20 | });
21 |
22 | test('clearAnswers', () => {
23 | const state = reducer({
24 | answers: [{ prompt: '사과', sentence: '사과가 맛있다' }],
25 | }, clearAnswers());
26 |
27 | expect(state.answers).toEqual([]);
28 | });
29 |
30 | test('endGame', () => {
31 | const state = reducer({
32 | isGameEnd: false,
33 | }, endGame());
34 |
35 | expect(state.isGameEnd).toBe(true);
36 | });
37 |
38 | test('setNumberOfQuestions', () => {
39 | const state = reducer({
40 | numberOfQuestions: 2,
41 | }, setNumberOfQuestions(3));
42 |
43 | expect(state.numberOfQuestions).toBe(3);
44 | });
45 |
46 | test('initializeState', () => {
47 | const initialState = {
48 | answers: [],
49 | isGameEnd: false,
50 | numberOfQuestions: 3,
51 | };
52 |
53 | const state = reducer({
54 | answers: [{ prompt: '사과', sentence: '사과가 맛있다' }],
55 | }, initializeState());
56 |
57 | expect(state).toEqual(initialState);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/services/speechSynthesisService.js:
--------------------------------------------------------------------------------
1 | import {
2 | bindCallback,
3 | from,
4 | fromEvent,
5 | of,
6 | } from 'rxjs';
7 |
8 | import { map, switchMap } from 'rxjs/operators';
9 |
10 | import polly from './instances/polly.instance';
11 | import context from './instances/audioContext.instance';
12 |
13 | function decode(buffer) {
14 | return from(context.decodeAudioData(buffer));
15 | }
16 |
17 | function synthesizeQuestion(question) {
18 | const speechParams = {
19 | Text: `
20 |
21 | ${question}
22 |
23 | `,
24 | OutputFormat: 'mp3',
25 | SampleRate: '24000',
26 | TextType: 'ssml',
27 | VoiceId: 'Seoyeon',
28 | };
29 |
30 | const boundSynthesize = bindCallback(polly.synthesizeSpeech);
31 |
32 | return boundSynthesize.call(polly, speechParams).pipe(
33 | map(([, data]) => new Uint8Array(data.AudioStream)),
34 | map((uIntArray) => uIntArray.buffer),
35 | switchMap((buffer) => decode(buffer)),
36 | );
37 | }
38 |
39 | let playSound;
40 |
41 | export function play(question) {
42 | playSound = context.createBufferSource();
43 |
44 | synthesizeQuestion(question).subscribe((decodedData) => {
45 | playSound.buffer = decodedData;
46 | playSound.connect(context.destination);
47 | playSound.start();
48 | });
49 | }
50 |
51 | export function stop() {
52 | if (playSound) {
53 | playSound.stop();
54 | }
55 | }
56 |
57 | export function playEnded() {
58 | return playSound ? fromEvent(playSound, 'ended') : of();
59 | }
60 |
--------------------------------------------------------------------------------
/src/pages/SentenceSpeakPage.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import { useSelector, useDispatch } from 'react-redux';
6 |
7 | import SentenceSpeakPage from './SentenceSpeakPage';
8 |
9 | jest.mock('react-redux');
10 | jest.mock('../services/speechRecognitionService.js');
11 | jest.mock('../services/instances/audioContext.instance.js');
12 | jest.mock('../hooks/volumeCanvas.js');
13 |
14 | global.HTMLMediaElement.prototype.pause = jest.fn();
15 | global.HTMLCanvasElement.prototype.getContext = jest.fn();
16 |
17 | jest.mock('react', () => {
18 | const originReact = jest.requireActual('react');
19 | const mockUseRef = jest.fn().mockReturnValue({ current: {} });
20 | return {
21 | ...originReact,
22 | useRef: mockUseRef,
23 | };
24 | });
25 |
26 | describe('SentenceSpeakPage', () => {
27 | const prompt = '사과';
28 |
29 | const dispatch = jest.fn();
30 |
31 | beforeEach(() => {
32 | dispatch.mockClear();
33 |
34 | useDispatch.mockImplementation(() => dispatch);
35 |
36 | useSelector.mockImplementation((selector) => selector({
37 | application: {
38 | answers: [],
39 | },
40 | speakSentence: {
41 | prompt,
42 | spokenSentence: '',
43 | },
44 | }));
45 | });
46 |
47 | it('get first prompt on mount', () => {
48 | render();
49 |
50 | expect(dispatch).toBeCalled();
51 | });
52 |
53 | it('renders prompt', () => {
54 | const { queryByText } = render();
55 |
56 | expect(queryByText(prompt)).not.toBeNull();
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/pages/SetQuestionNumberPage.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useSelector } from 'react-redux';
4 |
5 | import { fireEvent, render } from '@testing-library/react';
6 |
7 | import given from 'given2';
8 |
9 | import SetQuestionNumberPage from './SetQuestionNumberPage';
10 |
11 | const mockLocation = {};
12 | const mockPush = jest.fn();
13 |
14 | jest.mock('react-router-dom', () => ({
15 | ...jest.requireActual('react-router-dom'),
16 | useLocation() {
17 | return mockLocation;
18 | },
19 | useHistory() {
20 | return {
21 | push: mockPush,
22 | };
23 | },
24 | }));
25 |
26 | // TODO: Location mocking 을 이용한 테스트
27 | describe('SetQuestionNumberPage', () => {
28 | const startButton = '시작하기';
29 |
30 | beforeEach(() => {
31 | mockLocation.search = given.query;
32 |
33 | useSelector.mockImplementation((selector) => selector({
34 | application: {},
35 | }));
36 | });
37 |
38 | context('speak sentence game', () => {
39 | given('query', () => '?game=speak_sentence');
40 |
41 | it('links to speak sentence page', () => {
42 | const { getByText } = render();
43 |
44 | fireEvent.click(getByText(startButton));
45 |
46 | expect(mockPush).toBeCalled();
47 | });
48 | });
49 |
50 | context('speak sentence game', () => {
51 | given('query', () => '?game=yesno');
52 |
53 | it('links to speak sentence page', () => {
54 | const { getByText } = render();
55 |
56 | fireEvent.click(getByText(startButton));
57 |
58 | expect(mockPush).toBeCalled();
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es6: true,
5 | jest: true,
6 | },
7 | extends: [
8 | 'plugin:react/recommended',
9 | 'airbnb',
10 | ],
11 | globals: {
12 | context: 'readonly',
13 | Feature: 'readonly',
14 | Scenario: 'readonly',
15 | actor: 'readonly',
16 | given: 'readonly',
17 | },
18 | parserOptions: {
19 | ecmaFeatures: {
20 | jsx: true,
21 | },
22 | ecmaVersion: 11,
23 | sourceType: 'module',
24 | },
25 | plugins: [
26 | 'react',
27 | ],
28 | rules: {
29 | indent: ['error', 2],
30 | 'no-trailing-spaces': 'error',
31 | curly: 'error',
32 | 'brace-style': 'error',
33 | 'no-multi-spaces': 'error',
34 | 'space-infix-ops': 'error',
35 | 'space-unary-ops': 'error',
36 | 'no-whitespace-before-property': 'error',
37 | 'func-call-spacing': 'error',
38 | 'space-before-blocks': 'error',
39 | 'keyword-spacing': ['error', { before: true, after: true }],
40 | 'comma-spacing': ['error', { before: false, after: true }],
41 | 'comma-style': ['error', 'last'],
42 | 'comma-dangle': ['error', 'always-multiline'],
43 | 'space-in-parens': ['error', 'never'],
44 | 'block-spacing': 'error',
45 | 'array-bracket-spacing': ['error', 'never'],
46 | 'object-curly-spacing': ['error', 'always'],
47 | 'key-spacing': ['error', { mode: 'strict' }],
48 | 'arrow-spacing': ['error', { before: true, after: true }],
49 | 'jsx-a11y/label-has-associated-control': ['error', { assert: 'either' }],
50 | 'react/prop-types': 'off',
51 | 'linebreak-style': 'off',
52 | 'no-proto': 'off',
53 | },
54 | };
55 |
--------------------------------------------------------------------------------
/src/components/SentenceSubmitButton.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import SentenceSubmitButton from './SentenceSubmitButton';
6 |
7 | jest.mock('react-redux');
8 |
9 | const mockPush = jest.fn();
10 |
11 | jest.mock('react-router-dom', () => ({
12 | ...jest.requireActual('react-router-dom'),
13 | useHistory() {
14 | return { push: mockPush };
15 | },
16 | }));
17 |
18 | describe('SentenceSubmitButton', () => {
19 | const nextButton = '다음 문제';
20 | const exitButton = '종료';
21 |
22 | const handleClickNext = jest.fn();
23 | const handleClickExit = jest.fn();
24 |
25 | beforeEach(() => {
26 | jest.clearAllMocks();
27 | });
28 |
29 | const renderSentenceSubmitButton = ({ isComplete }) => render(
30 | ,
35 | );
36 |
37 | context('when answering is not complete', () => {
38 | const isComplete = false;
39 |
40 | it('renders next button', () => {
41 | const { getByText } = renderSentenceSubmitButton({ isComplete });
42 |
43 | fireEvent.click(getByText(nextButton));
44 |
45 | expect(handleClickNext).toBeCalled();
46 | });
47 | });
48 |
49 | context('when answering is complete', () => {
50 | const isComplete = true;
51 |
52 | it('renders exit button', () => {
53 | const { getByText } = renderSentenceSubmitButton({ isComplete });
54 |
55 | fireEvent.click(getByText(exitButton));
56 |
57 | expect(handleClickExit).toBeCalled();
58 | });
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 또 다시 말해요
2 | 틀려도 괜찮습니다.
3 | 계속해서 다시 한 번 말하다보면,
4 | 다시 한 번 유창하게 말할 수 있을거에요!
5 |
6 | ## 주소
7 | > https://www.ddomal.com/
8 | > - Chrome 브라우저, 스마트폰은 안드로이드 계열에서만 동작합니다
9 |
10 | ## 소개
11 | 실어증을 겪고 계신 분들을 위한 의사소통 연습 앱 이에요.
12 | 현재는 문장을 듣고서 큰 무리없이 이해하고 읽는 능력이 보존되어 계신 명명실어증(Anomic aphasia) 환우 분들을
13 | 대상으로 만들어졌어요.
14 |
15 |
16 |
17 | ## 사용방법
18 | ### 문장만들기
19 | ```
20 | 시작하기!
21 | 풀 문제 갯수 고르기!
22 | 단어를 보고 마이크 버튼을 눌러 나만의 문장을 만들기!
23 | 예시문을 보면서 문장 확인하기
24 | ```
25 |
26 | ### 듣고 이해하기
27 | ```
28 | 시작하기!
29 | 풀 문제 갯수 고르기!
30 | 플레이 버튼을 누르고 문제 듣기!
31 | 문제를 잘 듣고 예 / 아니오 고르기! (얼마든지 다시 들을 수 있어요)
32 | 정답 확인하기!
33 | ```
34 |
35 | ## 프로젝트 지식 위키
36 | - [사용자 스토리](https://github.com/CodeSoom/ddomal/wiki/%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%8A%A4%ED%86%A0%EB%A6%AC)
37 | - [용어 사전](https://github.com/CodeSoom/ddomal/wiki/%EC%9A%A9%EC%96%B4-%EC%82%AC%EC%A0%84)
38 |
39 | ## Project Setting
40 |
41 | ### Install npm dependencies
42 |
43 | ```bash
44 | > npm install
45 | ```
46 | ### Start dev-server
47 |
48 | ```bash
49 | > npm start
50 | ```
51 |
52 | ### Run tests
53 |
54 | - e2e test
55 | ```bash
56 | > npm test:e2e
57 | ```
58 |
59 | - unit test
60 | ```bash
61 | > npm run test:unit
62 | ```
63 |
64 | ### Run build project
65 |
66 | ```bash
67 | > npm build
68 | ```
69 |
70 | ### Run Lint
71 |
72 | ```bash
73 | > npm run lint
74 | ```
75 |
76 | ### Run Coverage
77 |
78 | ```bash
79 | > npm run coverage
80 | ```
81 |
82 | ### .env file
83 |
84 | ```bash
85 | > AWS_ID='your aws identity pool id'
86 | > AWS_REGION='your aws region'
87 | ```
88 |
89 | #### Using AWS Identity pool
90 | https://docs.aws.amazon.com/cognito/latest/developerguide/identity-pools.html
91 |
--------------------------------------------------------------------------------
/src/pages/SetQuestionNumberPage.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { useHistory, useLocation } from 'react-router-dom';
6 |
7 | import SetQuestionNumberContainer from '../containers/SetQuestionNumberContainer';
8 |
9 | import { normalColor } from '../styles/colors';
10 | import Button from '../styles/CommonButtonActive';
11 |
12 | const Container = styled.div({
13 | display: 'flex',
14 | flexDirection: 'column',
15 | alignItems: 'center',
16 | });
17 |
18 | const TitleBox = styled.div({
19 | marginTop: '22.19vh',
20 | });
21 |
22 | const Title = styled.h1({
23 | color: normalColor,
24 | fontSize: '1.5rem',
25 | });
26 |
27 | const ButtonBox = styled.div({
28 | marginTop: '12.18vh',
29 | });
30 |
31 | const ContainerBox = styled.div({
32 | marginTop: '8.125vh',
33 | });
34 |
35 | export default function SetQuestionNumberPage() {
36 | const query = new URLSearchParams(useLocation().search);
37 | const history = useHistory();
38 |
39 | const param = query.get('game');
40 |
41 | const gameToPath = {
42 | speak_sentence: '/sentence',
43 | yesno: '/yesno',
44 | };
45 |
46 | const handleClickStart = () => {
47 | history.push(gameToPath[param]);
48 | };
49 |
50 | return (
51 |
52 |
53 |
54 | 몇 문제를 풀어볼까요?
55 |
56 |
57 |
58 |
59 |
60 |
61 |
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/SentenceAnswer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { emphasisColor, normalColor } from '../styles/colors';
6 |
7 | import { titleFont } from '../styles/fonts';
8 |
9 | import { highlight } from '../utils/utils';
10 |
11 | import SentenceAnswerExamples from './SentenceAnswerExamples';
12 |
13 | const Prompt = styled.h3({
14 | textAlign: 'center',
15 | display: 'inline-block',
16 | borderBottom: '3px solid #EEE',
17 | color: emphasisColor,
18 | fontFamily: titleFont,
19 | fontSize: '2.5rem',
20 | });
21 |
22 | const Sentence = styled.div({
23 | paddingTop: '1rem',
24 | });
25 |
26 | const ExamplesBox = styled.div({
27 | marginTop: '8vh',
28 | fontSize: '1.5rem',
29 | display: 'flex',
30 | flexDirection: 'column',
31 | alignItems: 'center',
32 | });
33 |
34 | const ExamplesHeading = styled.p({
35 | borderBottom: `2px solid ${normalColor}`,
36 | fontWeight: '700',
37 | width: 'max-content',
38 | });
39 |
40 | export default function SentenceAnswer({
41 | answer: { prompt, spokenSentence, examples },
42 | onClickReplay,
43 | }) {
44 | return (
45 | <>
46 |
47 | {prompt}
48 |
49 |
50 | {highlight({
51 | sentence: spokenSentence,
52 | word: prompt,
53 | })}
54 |
55 |
56 |
57 | 예시 문장
58 |
59 |
64 |
65 | >
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/containers/SetQuestionNumberContainer.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import { useSelector, useDispatch } from 'react-redux';
6 |
7 | import SetQuestionNumberContainer from './SetQuestionNumberContainer';
8 |
9 | import { setNumberOfQuestions } from '../redux/slices/applicationSlice';
10 |
11 | jest.mock('react-redux');
12 |
13 | const mockPush = jest.fn();
14 |
15 | jest.mock('react-router-dom', () => ({
16 | ...jest.requireActual('react-router-dom'),
17 | useHistory() {
18 | return { push: mockPush };
19 | },
20 | }));
21 |
22 | describe('SetQuestionNumberContainer', () => {
23 | const increaseButton = 'arrowup';
24 | const descreaseButton = 'arrowdown';
25 |
26 | const numberOfQuestions = 3;
27 |
28 | const dispatch = jest.fn();
29 |
30 | beforeEach(() => {
31 | jest.clearAllMocks();
32 |
33 | useSelector.mockImplementation((selector) => selector({
34 | application: {
35 | numberOfQuestions,
36 | },
37 | }));
38 |
39 | useDispatch.mockImplementation(() => dispatch);
40 | });
41 |
42 | it('renders increase button', () => {
43 | const { getByTitle } = render();
44 |
45 | fireEvent.click(getByTitle(increaseButton));
46 |
47 | expect(dispatch).toBeCalledWith(setNumberOfQuestions(numberOfQuestions + 1));
48 | });
49 |
50 | it('renders descrease button', () => {
51 | const { getByTitle } = render();
52 |
53 | fireEvent.click(getByTitle(descreaseButton));
54 |
55 | expect(dispatch).toBeCalledWith(setNumberOfQuestions(numberOfQuestions - 1));
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/src/components/SentenceAnswers/SentenceAnswers.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import SentenceAnswers from './SentenceAnswers';
6 |
7 | jest.mock('react-redux');
8 |
9 | const mockPush = jest.fn();
10 |
11 | jest.mock('react-router-dom', () => ({
12 | ...jest.requireActual('react-router-dom'),
13 | useHistory() {
14 | return { push: mockPush };
15 | },
16 | }));
17 |
18 | describe('SentenceAnswers', () => {
19 | const replayButton = 'replay';
20 | const examples1 = [
21 | '사과는 빨갛다',
22 | '사과하나 주세요',
23 | ];
24 |
25 | const answers = [
26 | { prompt: '사과', spokenSentence: '사과는 맛있다', examples: examples1 },
27 | { prompt: '양파', spokenSentence: '양파는 맛없다', examples: examples1 },
28 | ];
29 |
30 | const handleClickReplay = jest.fn();
31 |
32 | it('renders answers', () => {
33 | const { container } = render();
34 |
35 | answers.forEach(({ prompt, spokenSentence }) => {
36 | expect(container).toHaveTextContent(prompt);
37 | expect(container).toHaveTextContent(spokenSentence);
38 | });
39 | });
40 |
41 | it('renders examples', () => {
42 | const { container } = render();
43 |
44 | examples1.forEach((example) => {
45 | expect(container).toHaveTextContent(example);
46 | });
47 | });
48 |
49 | it('renders replay button', () => {
50 | const { getAllByTitle } = render(
51 | ,
55 | );
56 |
57 | fireEvent.click(getAllByTitle(replayButton)[0]);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/hooks/volumeCanvas.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 |
3 | import context from '../services/instances/audioContext.instance';
4 | import { tertiaryColor } from '../styles/colors';
5 |
6 | export const useVolumeCanvas = () => {
7 | const [analyser] = useState(() => context.createAnalyser());
8 | const [dataArray] = useState(new Uint8Array(analyser.frequencyBinCount));
9 |
10 | const canvasRef = useRef(null);
11 |
12 | analyser.fftSize = 256;
13 |
14 | const draw = (ctx, sum) => {
15 | if (!ctx) {
16 | return;
17 | }
18 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
19 | ctx.fillStyle = tertiaryColor;
20 | ctx.beginPath();
21 | ctx.arc(110.4, 110.4, sum / 200 + 50, 0, 2 * Math.PI);
22 | ctx.fill();
23 | };
24 |
25 | async function getData() {
26 | const stream = await navigator.mediaDevices.getUserMedia({
27 | audio: true,
28 | });
29 |
30 | const source = context.createMediaStreamSource(stream);
31 | source.connect(analyser);
32 | }
33 |
34 | useEffect(() => {
35 | getData();
36 |
37 | const canvas = canvasRef.current;
38 | const canvasContext = canvas.getContext('2d');
39 |
40 | let animationId;
41 | function render() {
42 | analyser.getByteFrequencyData(dataArray);
43 |
44 | const sum = dataArray.reduce((acc, curr) => acc + curr, 0);
45 |
46 | draw(canvasContext, sum);
47 | animationId = window.requestAnimationFrame(render);
48 | }
49 | render();
50 |
51 | return () => {
52 | window.cancelAnimationFrame(animationId);
53 | };
54 | }, [draw]);
55 |
56 | return canvasRef;
57 | };
58 |
59 | // TODO: delete this
60 | export const xx = {};
61 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
34 |
35 |
36 |
37 | Speak Again
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/components/YesNoSubmitButtons.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import YesNoSubmitButtons from './YesNoSubmitButtons';
6 |
7 | describe('YesNoSubmitButtons', () => {
8 | const yesButton = '맞아요';
9 | const noButton = '아니에요';
10 |
11 | const handleClick = jest.fn();
12 |
13 | const renderYesNoSubmitButtons = ({ isIdle }) => render(
14 | ,
15 | );
16 |
17 | beforeEach(() => {
18 | handleClick.mockClear();
19 | });
20 |
21 | context('when sound has started', () => {
22 | const isIdle = false;
23 |
24 | it('user can click yes button', () => {
25 | const { getByText } = renderYesNoSubmitButtons({ isIdle });
26 |
27 | fireEvent.click(getByText(yesButton));
28 |
29 | expect(handleClick).toBeCalledWith('Y');
30 | });
31 |
32 | it('user can click no button', () => {
33 | const { getByText } = renderYesNoSubmitButtons({ isIdle });
34 |
35 | fireEvent.click(getByText(noButton));
36 |
37 | expect(handleClick).toBeCalledWith('N');
38 | });
39 | });
40 |
41 | context('when sound has not started', () => {
42 | const isIdle = true;
43 |
44 | it('user cannot click yes button', () => {
45 | const { getByText } = renderYesNoSubmitButtons({ isIdle });
46 |
47 | fireEvent.click(getByText(yesButton));
48 |
49 | expect(handleClick).not.toBeCalled();
50 | });
51 |
52 | it('user cannot click no button', () => {
53 | const { getByText } = renderYesNoSubmitButtons({ isIdle });
54 |
55 | fireEvent.click(getByText(noButton));
56 |
57 | expect(handleClick).not.toBeCalled();
58 | });
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/src/components/YesNoAnswer.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import YesNoAnswer from './YesNoAnswer';
6 |
7 | describe('YesNoAnswer', () => {
8 | const replayButton = 'replay';
9 |
10 | const question = '코끼리는 쥐보다 가볍나요?';
11 | const noAnswer = 'N';
12 | const noAnswerText = '아니에요';
13 | const yesAnswer = 'Y';
14 | const userAnswer = 'Y';
15 |
16 | const handleClickReplay = jest.fn();
17 |
18 | const renderYesNoAnswer = ({ answer } = { answer: noAnswer }) => render(
19 | ,
25 | );
26 |
27 | beforeEach(() => {
28 | jest.clearAllMocks();
29 | });
30 |
31 | it('show answer', () => {
32 | const { container } = renderYesNoAnswer();
33 |
34 | expect(container).toHaveTextContent(question);
35 | expect(container).toHaveTextContent(noAnswerText);
36 | });
37 |
38 | it('renders replay button on each answer', () => {
39 | const { getByTitle } = renderYesNoAnswer();
40 |
41 | fireEvent.click(getByTitle(replayButton));
42 |
43 | expect(handleClickReplay).toBeCalledWith(question);
44 | });
45 |
46 | it('renders correct picture when user answer is equal to answer', () => {
47 | const { queryByTitle } = renderYesNoAnswer({
48 | answer: yesAnswer,
49 | });
50 |
51 | expect(queryByTitle('correct')).not.toBeNull();
52 | });
53 |
54 | it('renders wrong picture when user answer is different with answer', () => {
55 | const { queryByTitle } = renderYesNoAnswer({
56 | answer: noAnswer,
57 | });
58 |
59 | expect(queryByTitle('wrong')).not.toBeNull();
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | Switch,
5 | Route,
6 | } from 'react-router-dom';
7 |
8 | import './styles/global.css';
9 |
10 | import styled from '@emotion/styled';
11 |
12 | import MainPage from './pages/MainPage/index';
13 | import SelectPage from './pages/SelectPage/index';
14 | import SentenceSpeakPage from './pages/SentenceSpeakPage';
15 | import SentenceAnswersPage from './pages/SentenceAnswersPage';
16 | import YesNoPage from './pages/YesNoPage';
17 | import YesNoAnswersPage from './pages/YesNoAnswersPage';
18 | import SetQuestionNumberPage from './pages/SetQuestionNumberPage';
19 | import SpeechPage from './pages/SpeechPage';
20 |
21 | import { primaryColor, tertiaryColor } from './styles/colors';
22 | import { normalFont } from './styles/fonts';
23 | import NotFoundPage from './pages/NotFoundPage';
24 |
25 | const Container = styled.div({
26 | width: '100%',
27 | height: '100vh',
28 | backgroundImage: `linear-gradient(130deg, ${primaryColor}, ${tertiaryColor})`,
29 | fontFamily: normalFont,
30 | zIndex: '0',
31 | });
32 |
33 | export default function App() {
34 | return (
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/containers/SpeechContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useDispatch, useSelector } from 'react-redux';
4 |
5 | import styled from '@emotion/styled';
6 |
7 | import { MdMic } from 'react-icons/md';
8 |
9 | import SpeechInput from '../components/SpeechInput';
10 |
11 | import MicState from '../enums/MicState';
12 |
13 | import { get } from '../utils/utils';
14 |
15 | import { flexBoxCenter } from '../styles/common';
16 | import { titleFont } from '../styles/fonts';
17 | import { normalColor } from '../styles/colors';
18 | import IconButton from '../styles/IconButton';
19 |
20 | import { recognizeSpeech } from '../redux/slices/speakSentenceSlice';
21 |
22 | const Prompt = styled.div({
23 | fontFamily: titleFont,
24 | fontSize: '3.5rem',
25 | ...flexBoxCenter,
26 | // marginTop: '9.53vh',
27 | color: normalColor,
28 | height: '5.5rem',
29 | });
30 |
31 | const Mic = styled(IconButton)({
32 | marginTop: '10.78vh',
33 | zIndex: '100',
34 | // TODO: delete below
35 | position: 'absolute',
36 | left: '50%',
37 | transform: 'translate(-50%)',
38 | });
39 |
40 | export default function SpeechContainer() {
41 | const [speech, prompts] = ['speech', 'prompts']
42 | .map((key) => useSelector(get(key)));
43 |
44 | const dispatch = useDispatch();
45 |
46 | const handleClickMic = () => {
47 | dispatch(recognizeSpeech());
48 | };
49 |
50 | return (
51 |
52 |
53 | {(prompts ?? ['사과', '수박']).join(' , ')}
54 |
55 |
59 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/SentenceAnswer.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import SentenceAnswer from './SentenceAnswer';
6 |
7 | jest.mock('react-redux');
8 |
9 | const mockPush = jest.fn();
10 |
11 | jest.mock('react-router-dom', () => ({
12 | ...jest.requireActual('react-router-dom'),
13 | useHistory() {
14 | return { push: mockPush };
15 | },
16 | }));
17 |
18 | describe('SentenceAnswer', () => {
19 | const examples = [
20 | '사과는 빨갛다',
21 | '사과하나 주세요',
22 | ];
23 |
24 | const answer = {
25 | prompt: '사과',
26 | spokenSentence: '사과는 맛있다',
27 | examples,
28 | };
29 |
30 | const handleClickReplay = jest.fn();
31 |
32 | const renderSentenceAnswer = () => render(
33 | ,
37 | );
38 |
39 | beforeEach(() => {
40 | handleClickReplay.mockClear();
41 | });
42 |
43 | it('renders answer', () => {
44 | const { prompt, spokenSentence } = answer;
45 |
46 | const { container } = renderSentenceAnswer();
47 |
48 | expect(container).toHaveTextContent(prompt);
49 | expect(container).toHaveTextContent(spokenSentence);
50 | });
51 |
52 | it('renders examples', () => {
53 | const { container } = renderSentenceAnswer();
54 |
55 | examples.forEach((example) => {
56 | expect(container).toHaveTextContent(example);
57 | });
58 | });
59 |
60 | it('renders replay button on each side of example', () => {
61 | const { getAllByTitle } = renderSentenceAnswer();
62 |
63 | examples.forEach((example, idx) => {
64 | fireEvent.click(getAllByTitle('replay')[idx]);
65 |
66 | expect(handleClickReplay).toBeCalledWith(example);
67 | });
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/src/containers/SentenceAnswersContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { useDispatch, useSelector } from 'react-redux';
6 |
7 | import { useHistory } from 'react-router-dom';
8 |
9 | import { playYesNoQuestion } from '../redux/slices/yesNoSlice';
10 | import { endGame, initializeState } from '../redux/slices/applicationSlice';
11 |
12 | import { get } from '../utils/utils';
13 |
14 | import SentenceAnswers from '../components/SentenceAnswers';
15 |
16 | import { flexBoxCenter } from '../styles/common';
17 | import ActiveButton from '../styles/CommonButtonActive';
18 | import InactiveButton from '../styles/CommonButtonInactive';
19 |
20 | const ButtonBox = styled.div({
21 | ...flexBoxCenter,
22 | marginTop: '2rem',
23 | });
24 |
25 | export default function SentenceAnswersContainer() {
26 | const { answers, isGameEnd } = useSelector(get('application'));
27 |
28 | const dispatch = useDispatch();
29 | const history = useHistory();
30 |
31 | const Button = isGameEnd ? ActiveButton : InactiveButton;
32 |
33 | const handleClickRestart = () => {
34 | dispatch(initializeState());
35 | history.push('/');
36 | };
37 |
38 | const handleClickReplay = (example) => {
39 | dispatch(playYesNoQuestion(example));
40 | };
41 |
42 | const handleClickLastSlide = () => {
43 | if (!isGameEnd) {
44 | dispatch(endGame());
45 | }
46 | };
47 |
48 | return (
49 | <>
50 |
55 |
56 |
59 |
60 | >
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/QuestionCounter.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import FlowCounter from './QuestionCounter';
6 |
7 | describe('FlowCounter', () => {
8 | const increaseButton = 'arrowup';
9 | const decreaseButton = 'arrowdown';
10 | const MIN_Q = 1;
11 | const MAX_Q = 5;
12 |
13 | const handleClickIncrease = jest.fn();
14 | const handleClickDecrease = jest.fn();
15 |
16 | const renderFlowCounter = ({ numberOfQuestions } = { numberOfQuestions: 3 }) => render(
17 | ,
22 | );
23 |
24 | beforeEach(() => {
25 | jest.clearAllMocks();
26 | });
27 |
28 | it('renders increase button', () => {
29 | const { getByTitle } = renderFlowCounter();
30 |
31 | fireEvent.click(getByTitle(increaseButton));
32 |
33 | expect(handleClickIncrease).toBeCalled();
34 | });
35 |
36 | it('renders decrease button', () => {
37 | const { getByTitle } = renderFlowCounter();
38 |
39 | fireEvent.click(getByTitle(decreaseButton));
40 |
41 | expect(handleClickDecrease).toBeCalled();
42 | });
43 |
44 | it('cannot click increase if number of question is over MAX', () => {
45 | const { getByTitle } = renderFlowCounter({ numberOfQuestions: MAX_Q + 1 });
46 |
47 | fireEvent.click(getByTitle(increaseButton));
48 |
49 | expect(handleClickIncrease).not.toBeCalled();
50 | });
51 |
52 | it('cannot click decrease if number of question is below MIN', () => {
53 | const { getByTitle } = renderFlowCounter({ numberOfQuestions: MIN_Q - 1 });
54 |
55 | fireEvent.click(getByTitle(decreaseButton));
56 |
57 | expect(handleClickDecrease).not.toBeCalled();
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/pages/SelectPage/SelectPage.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, fireEvent } from '@testing-library/react';
4 |
5 | import { useDispatch } from 'react-redux';
6 |
7 | import SelectPage from './SelectPage';
8 |
9 | import { initializeState } from '../../redux/slices/applicationSlice';
10 |
11 | const mockPush = jest.fn();
12 |
13 | jest.mock('react-router-dom', () => ({
14 | ...jest.requireActual('react-router-dom'),
15 | useHistory() {
16 | return { push: mockPush };
17 | },
18 | }));
19 |
20 | jest.mock('react-redux');
21 | jest.mock('../../services/instances/audioContext.instance.js');
22 |
23 | describe('SelectPage', () => {
24 | const title = '무엇을 연습해 볼까요?';
25 | const speakSentenceButton = '문장 만들기';
26 | const yesnoButton = '듣고 이해하기';
27 |
28 | const dispatch = jest.fn();
29 |
30 | beforeEach(() => {
31 | jest.clearAllMocks();
32 |
33 | useDispatch.mockImplementation(() => dispatch);
34 | });
35 |
36 | it('renders title', () => {
37 | const { container } = render();
38 |
39 | expect(container).toHaveTextContent(title);
40 | });
41 |
42 | it('renders sentence speak page link button', () => {
43 | const { getByText } = render();
44 |
45 | fireEvent.click(getByText(speakSentenceButton));
46 |
47 | expect(mockPush).toBeCalledWith({
48 | pathname: '/setnumber',
49 | search: '?game=speak_sentence',
50 | });
51 |
52 | expect(dispatch).toBeCalledWith(initializeState());
53 | });
54 |
55 | it('renders yesno page link button', () => {
56 | const { getByText } = render();
57 |
58 | fireEvent.click(getByText(yesnoButton));
59 |
60 | expect(mockPush).toBeCalledWith({
61 | pathname: '/setnumber',
62 | search: '?game=yesno',
63 | });
64 |
65 | expect(dispatch).toBeCalledWith(initializeState());
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/src/components/SentenceSpeakInput/SentenceSpeakInput.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 |
3 | import _ from 'lodash';
4 |
5 | import { MdMic } from 'react-icons/md';
6 |
7 | import SpokenSentence from '../SpokenSentence';
8 |
9 | import { useAudio } from '../../hooks/audio';
10 |
11 | import {
12 | Container,
13 | SentenceBox,
14 | WarningMessage,
15 | MicBox,
16 | MeterBox,
17 | } from './styled';
18 |
19 | import IconButton from '../../styles/IconButton';
20 |
21 | import VolumeMeter from '../VolumeMeter';
22 |
23 | import MicState from '../../enums/MicState';
24 |
25 | function SentenceSpeakInput({
26 | isCorrectSentence, prompt, spokenSentence, micState, onClick,
27 | }) {
28 | const play = useAudio('../../assets/sounds/CorrectAnswer.mp3');
29 |
30 | const isWarningHidden = isCorrectSentence || _.isNull(spokenSentence);
31 | const isMicNotOff = micState !== MicState.OFF;
32 |
33 | useEffect(() => {
34 | if (isCorrectSentence) {
35 | play();
36 | }
37 | }, [spokenSentence]);
38 |
39 | return (
40 |
41 |
42 |
43 | 제시어를 사용해서 문장을 말해보세요
44 |
45 |
50 |
51 |
52 |
59 |
60 | {
61 | micState !== MicState.OFF
62 | ?
63 | : ''
64 | }
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | export default React.memo(SentenceSpeakInput);
72 |
--------------------------------------------------------------------------------
/src/redux/epics/YesNoEpics.js:
--------------------------------------------------------------------------------
1 | import { ofType } from 'redux-observable';
2 |
3 | import { of } from 'rxjs';
4 |
5 | import {
6 | map,
7 | mergeMap,
8 | tap,
9 | filter,
10 | } from 'rxjs/operators';
11 | import SoundState from '../../enums/SoundState';
12 |
13 | import { fetchNextYesNoQuestion } from '../../services/dataService';
14 | import { play, playEnded, stop } from '../../services/speechSynthesisService';
15 | import { addAnswer } from '../slices/applicationSlice';
16 |
17 | import {
18 | endPlaying,
19 | getNextYesNoQuestion,
20 | idlePlaying,
21 | setYesNoQuestion,
22 | startPlaying,
23 | stopYesNoQuestion,
24 | } from '../slices/yesNoSlice';
25 |
26 | export const getNextYesNoQuestionEpic = (action$) => action$.pipe(
27 | ofType('yesno/getNextYesNoQuestion'),
28 | map(fetchNextYesNoQuestion),
29 | map(setYesNoQuestion),
30 | );
31 |
32 | export const playYesNoQuestionEpic = (action$) => action$.pipe(
33 | ofType('yesno/playYesNoQuestion'),
34 | tap(({ payload }) => play(payload)),
35 | mergeMap(() => of(
36 | startPlaying(),
37 | { type: 'listenYesNoEndEvent' },
38 | )),
39 | );
40 |
41 | export const stopYesNoQuestionEpic = (action$) => action$.pipe(
42 | ofType('yesno/stopYesNoQuestion'),
43 | tap(() => stop()),
44 | map(() => idlePlaying()),
45 | );
46 |
47 | export const listenYesNoEndEventEpic = (action$, state$) => action$.pipe(
48 | ofType('listenYesNoEndEvent'),
49 | map(() => playEnded()),
50 | mergeMap((end$) => end$.pipe(
51 | filter(() => state$.value.soundState !== SoundState.IDLE),
52 | map(() => endPlaying()),
53 | )),
54 | );
55 |
56 | export const saveAndGoToNextYesNoQuestionEpic = (action$) => action$.pipe(
57 | ofType('yesno/saveAndGoToNextYesNoQuestion'),
58 | mergeMap(({ payload }) => of(
59 | stopYesNoQuestion(),
60 | idlePlaying(),
61 | addAnswer(payload),
62 | getNextYesNoQuestion(),
63 | )),
64 | );
65 |
--------------------------------------------------------------------------------
/src/components/SpeechInput.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import SpeechInput from './SpeechInput';
6 |
7 | import MicState from '../enums/MicState';
8 |
9 | import { emphasisColor } from '../styles/colors';
10 |
11 | describe('SpeechInput', () => {
12 | const placeholder = '버튼을 누르고 문장을 말해보세요';
13 | const waiting = '...';
14 | const highlightColor = emphasisColor;
15 |
16 | const renderSpeechInput = ({ prompt, speech, micState = MicState.OFF }) => (
17 | render()
22 | );
23 |
24 | context('with sentence', () => {
25 | const speech = '사과는 맛있다';
26 |
27 | it('renders sentence', () => {
28 | const { container } = renderSpeechInput({ speech });
29 |
30 | expect(container).toHaveTextContent(speech);
31 | });
32 | });
33 |
34 | context('without sentence', () => {
35 | const sentence = null;
36 |
37 | it('renders default message', () => {
38 | const { container } = renderSpeechInput({ sentence });
39 |
40 | expect(container).toHaveTextContent(placeholder);
41 | });
42 | });
43 |
44 | it('renders waiting while user is inputting', () => {
45 | [MicState.SPEAKING, MicState.ON].forEach((micState) => {
46 | const { container } = renderSpeechInput({
47 | micState,
48 | });
49 |
50 | expect(container).toHaveTextContent(waiting);
51 | });
52 | });
53 |
54 | it('doesnt render loading sign while user is not inputting', () => {
55 | const { container } = renderSpeechInput({
56 | micState: MicState.OFF,
57 | });
58 |
59 | expect(container).not.toHaveTextContent(waiting);
60 | });
61 |
62 | it('highlight prompt in the spoken sentence', () => {
63 | const { getByText } = renderSpeechInput({
64 | speech: '사과는 맛있다',
65 | prompt: '사과',
66 | });
67 |
68 | expect(getByText('사과')).toHaveStyle(`color: ${highlightColor}`);
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/data/prompts.js:
--------------------------------------------------------------------------------
1 | const prompts = [
2 | // 스포츠
3 | '탁구',
4 | '농구',
5 | '배구',
6 | '야구',
7 | '테니스',
8 | '달리기',
9 | '수영',
10 | // 과일
11 | '사과',
12 | '배',
13 | '참외',
14 | '키위',
15 | '귤',
16 | '포도',
17 | '청포도',
18 | '곶감',
19 | '딸기',
20 | '망고',
21 | '자두',
22 | '체리',
23 | // 야채
24 | '양파',
25 | '파',
26 | '당근',
27 | '피망',
28 | '감자',
29 | '고구마',
30 | '무',
31 | '상추',
32 | '콩나물',
33 | '버섯',
34 | '깻잎',
35 | // 계절
36 | '봄',
37 | '여름',
38 | '가을',
39 | '겨울',
40 | // 동물
41 | '강아지',
42 | '기린',
43 | '코끼리',
44 | '사자',
45 | '사슴',
46 | '캥거루',
47 | '늑대',
48 | '여우',
49 | '너구리',
50 | '다람쥐',
51 | '고슴도치',
52 | // 가족
53 | '아들',
54 | '딸',
55 | '어머니',
56 | '아버지',
57 | // 요일
58 | '월요일',
59 | '화요일',
60 | '금요일',
61 | '토요일',
62 | '일요일',
63 | // 시간
64 | '어제',
65 | '오늘',
66 | '내일',
67 | // 교통수단
68 | '비행기',
69 | '기차',
70 | '버스',
71 | '자동차',
72 | '택시',
73 | // 장소
74 | '학교',
75 | '집',
76 | '병원',
77 | '시장',
78 | '공원',
79 | '우체국',
80 | '경찰서',
81 | // 직업
82 | '의사',
83 | '선생님',
84 | '간호사',
85 | '소방관',
86 | '경찰',
87 | '군인',
88 | '학생',
89 | // 추상
90 | '행복',
91 | '영원',
92 | '사랑',
93 | '우정',
94 | '믿음',
95 | '존경',
96 | // 물건
97 | '핸드폰',
98 | '지갑',
99 | '책',
100 | '컴퓨터',
101 | '노트북',
102 | '가위',
103 | '책상',
104 | '의자',
105 | // 지역
106 | '서울',
107 | '대전',
108 | '부산',
109 | '미국',
110 | '영국',
111 | '호주',
112 | '스페인',
113 | '독일',
114 | '유럽',
115 | // 악기
116 | '피아노',
117 | '기타',
118 | '바이올린',
119 | '풍금',
120 | // 가전제품
121 | '세탁기',
122 | '냉장고',
123 | '라디오',
124 | '텔레비전',
125 | // 취미
126 | '영화',
127 | '독서',
128 | '등산',
129 | '운동',
130 | '산책',
131 | '사진',
132 | '글씨',
133 | '우표',
134 | '편지',
135 | // 공부
136 | '수학',
137 | '영어',
138 | '컴퓨터',
139 | '국어',
140 | '미술',
141 | // 요리
142 | '치킨',
143 | '피자',
144 | '탕수육',
145 | '죽',
146 | ];
147 |
148 | export default prompts;
149 |
--------------------------------------------------------------------------------
/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Single Page Apps for GitHub Pages
6 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/components/SpokenSentence.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import SpokenSentence from './SpokenSentence';
6 |
7 | import MicState from '../enums/MicState';
8 |
9 | import { emphasisColor } from '../styles/colors';
10 |
11 | describe('Sentence', () => {
12 | const defaultMessage = '문장을 소리내어 말해보세요';
13 | const waiting = '...';
14 | const highlightColor = emphasisColor;
15 |
16 | const renderSpokenSentence = ({ prompt, sentence, micState = MicState.OFF }) => (
17 | render()
22 | );
23 |
24 | context('with sentence', () => {
25 | const sentence = '사과는 맛있다';
26 |
27 | it('renders sentence', () => {
28 | const { container } = renderSpokenSentence({ sentence });
29 |
30 | expect(container).toHaveTextContent(sentence);
31 | });
32 | });
33 |
34 | context('without sentence', () => {
35 | const sentence = null;
36 |
37 | it('renders default message', () => {
38 | const { container } = renderSpokenSentence({ sentence });
39 |
40 | expect(container).toHaveTextContent(defaultMessage);
41 | });
42 | });
43 |
44 | it('renders waiting while user is inputting', () => {
45 | [MicState.SPEAKING, MicState.ON].forEach((micState) => {
46 | const { container } = renderSpokenSentence({
47 | micState,
48 | });
49 |
50 | expect(container).toHaveTextContent(waiting);
51 | });
52 | });
53 |
54 | it('doesnt render loading sign while user is not inputting', () => {
55 | const { container } = renderSpokenSentence({
56 | micState: MicState.OFF,
57 | });
58 |
59 | expect(container).not.toHaveTextContent(waiting);
60 | });
61 |
62 | it('highlight prompt in the spoken sentence', () => {
63 | const { getByText } = renderSpokenSentence({
64 | sentence: '사과는 맛있다',
65 | prompt: '사과',
66 | });
67 |
68 | expect(getByText('사과')).toHaveStyle(`color: ${highlightColor}`);
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/src/containers/YesNoAnswersContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import _ from 'lodash';
4 |
5 | import styled from '@emotion/styled';
6 |
7 | import { useHistory } from 'react-router-dom';
8 |
9 | import { useDispatch, useSelector } from 'react-redux';
10 |
11 | import { get } from '../utils/utils';
12 |
13 | import { initializeState } from '../redux/slices/applicationSlice';
14 | import { playYesNoQuestion } from '../redux/slices/yesNoSlice';
15 |
16 | import YesNoAnswer from '../components/YesNoAnswer';
17 |
18 | import Button from '../styles/CommonButtonActive';
19 |
20 | const AnswersBox = styled.div({
21 | marginTop: '4.375vh',
22 | });
23 |
24 | const AnswerBox = styled.div({
25 | marginTop: '3vh',
26 | });
27 |
28 | const ButtonBox = styled.div({
29 | marginTop: '6.1vh',
30 | });
31 |
32 | const Container = styled.div({
33 | display: 'flex',
34 | flexDirection: 'column',
35 | alignItems: 'center',
36 | });
37 |
38 | export default function YesNoAnswersContainer() {
39 | const { answers } = useSelector(get('application'));
40 |
41 | const dispatch = useDispatch();
42 | const history = useHistory();
43 |
44 | const handleClickGoHome = () => {
45 | dispatch(initializeState());
46 | history.push('/');
47 | };
48 |
49 | const handleClickReplay = (question) => {
50 | const debouncedDispatch = _.debounce(dispatch, 600, {
51 | trailing: true,
52 | });
53 |
54 | debouncedDispatch(playYesNoQuestion(question));
55 | };
56 |
57 | return (
58 |
59 |
60 | {answers.map(({ question, answer, userAnswer }) => (
61 |
62 |
68 |
69 | ))}
70 |
71 |
72 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/containers/SentenceAnswersContainer.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import { useDispatch, useSelector } from 'react-redux';
6 |
7 | import SentenceAnswersContainer from './SentenceAnswersContainer';
8 |
9 | import { playYesNoQuestion } from '../redux/slices/yesNoSlice';
10 |
11 | jest.mock('react-redux');
12 |
13 | const mockPush = jest.fn();
14 |
15 | jest.mock('react-router-dom', () => ({
16 | ...jest.requireActual('react-router-dom'),
17 | useHistory() {
18 | return { push: mockPush };
19 | },
20 | }));
21 |
22 | describe('SentenceAnswersContainer', () => {
23 | const goHomeButton = '처음으로';
24 | const replayButton = 'replay';
25 | const example = '사과사과사과';
26 |
27 | const answers = [
28 | { prompt: '사과', spokenSentence: '사과는 맛있다', examples: [example] },
29 | { prompt: '양파', spokenSentence: '양파는 맛없다', examples: [] },
30 | ];
31 |
32 | const dispatch = jest.fn();
33 |
34 | beforeEach(() => {
35 | dispatch.mockClear();
36 |
37 | useDispatch.mockImplementation(() => dispatch);
38 |
39 | useSelector.mockImplementation((selector) => selector({
40 | application: {
41 | answers,
42 | },
43 | }));
44 | });
45 |
46 | it('renders answers', () => {
47 | const { container } = render();
48 |
49 | answers.forEach(({ prompt, spokenSentence }) => {
50 | expect(container).toHaveTextContent(prompt);
51 | expect(container).toHaveTextContent(spokenSentence);
52 | });
53 | });
54 |
55 | it('renders go home button', () => {
56 | const { getByText } = render();
57 |
58 | fireEvent.click(getByText(goHomeButton));
59 |
60 | expect(dispatch).toBeCalled();
61 | expect(mockPush).toBeCalledWith('/');
62 | });
63 |
64 | it('renders replay button', () => {
65 | const { getByTitle } = render();
66 |
67 | fireEvent.click(getByTitle(replayButton));
68 |
69 | expect(dispatch).toBeCalledWith(
70 | playYesNoQuestion(example),
71 | );
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/src/redux/epics/SpeakSentenceEpics.js:
--------------------------------------------------------------------------------
1 | import { ofType } from 'redux-observable';
2 |
3 | import { merge, of } from 'rxjs';
4 | import { map, mergeMap, tap } from 'rxjs/operators';
5 |
6 | import MicState from '../../enums/MicState';
7 |
8 | import { fetchNextPrompt, getExamples } from '../../services/dataService';
9 |
10 | import {
11 | recognize,
12 | recognitionStart,
13 | recognitionEnd,
14 | soundStart,
15 | soundEnd,
16 | abortRecognition,
17 | } from '../../services/speechRecognitionService';
18 |
19 | import {
20 | addAnswer,
21 | } from '../slices/applicationSlice';
22 |
23 | import {
24 | setMicState,
25 | setPrompt,
26 | setSpokenSentence,
27 | } from '../slices/speakSentenceSlice';
28 |
29 | export const getNextQuestionEpic = (action$) => action$.pipe(
30 | ofType('speakSentence/getNextQuestion'),
31 | tap(abortRecognition),
32 | map(fetchNextPrompt),
33 | mergeMap((nextPrompt) => of(
34 | setPrompt(nextPrompt),
35 | setSpokenSentence(null),
36 | )),
37 | );
38 |
39 | export const recognizeSpeechEpic = (action$) => action$.pipe(
40 | ofType('speakSentence/recognizeSpeech'),
41 | map(recognize),
42 | mergeMap((sentence$) => merge(
43 | of({ type: 'listenRecognitionEvents' }),
44 | sentence$.pipe(
45 | map((sentence) => setSpokenSentence(sentence)),
46 | ),
47 | )),
48 | );
49 |
50 | export const listenRecognitionEvents = (action$) => action$.pipe(
51 | ofType('listenRecognitionEvents'),
52 | map(() => [
53 | recognitionStart(),
54 | recognitionEnd(),
55 | soundStart(),
56 | soundEnd(),
57 | ]),
58 | mergeMap(([
59 | start$, end$, soundStart$, soundEnd$,
60 | ]) => merge(
61 | start$.pipe(map(() => setMicState(MicState.ON))),
62 | end$.pipe(map(() => setMicState(MicState.OFF))),
63 | soundStart$.pipe(map(() => setMicState(MicState.SPEAKING))),
64 | soundEnd$.pipe(map(() => setMicState(MicState.ON))),
65 | )),
66 | );
67 |
68 | export const saveAnswerEpic = (action$) => action$.pipe(
69 | ofType('speakSentence/saveAnswer'),
70 | map(({ payload }) => (
71 | addAnswer({
72 | ...payload,
73 | examples: getExamples(payload.prompt),
74 | })
75 | )),
76 | );
77 |
--------------------------------------------------------------------------------
/src/components/YesNoAnswer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 | import { emphasisColor } from '../styles/colors';
5 |
6 | const CorrectPicture = styled.div({
7 | position: 'absolute',
8 | top: '-1.4vh',
9 | left: '0',
10 | display: 'inline-block',
11 | backgroundImage: 'url("/assets/images/correct.png")',
12 | width: '2.94rem',
13 | height: '2.94rem',
14 | backgroundSize: 'contain',
15 | backgroundRepeat: 'no-repeat',
16 | padding: '.3rem .3rem',
17 | });
18 |
19 | const WrongPicture = styled.div({
20 | position: 'absolute',
21 | top: '-1vh',
22 | left: '0',
23 | display: 'inline-block',
24 | backgroundImage: 'url("/assets/images/wrong.png")',
25 | width: '2.94rem',
26 | height: '2.94rem',
27 | backgroundSize: 'contain',
28 | backgroundRepeat: 'no-repeat',
29 | });
30 |
31 | const ReplayButton = styled.button({
32 | backgroundImage: 'url("/assets/images/replay.png")',
33 | width: '1.5rem',
34 | height: '1.5rem',
35 | backgroundSize: 'contain',
36 | backgroundRepeat: 'no-repeat',
37 | border: 'none',
38 | backgroundColor: 'transparent',
39 | cursor: 'pointer',
40 | transform: 'translate(.4rem, .3rem)',
41 | });
42 |
43 | const Text = styled.div({
44 | color: 'white',
45 | fontSize: '1.125rem',
46 | zIndex: '50',
47 | });
48 |
49 | const Container = styled.div({
50 | display: 'grid',
51 | gridGap: '2.34vh',
52 | position: 'relative',
53 | paddingLeft: '2rem',
54 | paddingTop: '0.8rem',
55 | });
56 |
57 | const AnswerIs = styled.span({
58 | color: emphasisColor,
59 | });
60 |
61 | export default function YesNoAnswer({
62 | question, answer, userAnswer, onClick,
63 | }) {
64 | const isCorrect = answer === userAnswer;
65 |
66 | const Picture = isCorrect ? CorrectPicture : WrongPicture;
67 | const title = isCorrect ? 'correct' : 'wrong';
68 | const answerText = answer === 'Y' ? '맞아요' : '아니에요';
69 |
70 | return (
71 |
72 |
73 |
74 | {question}
75 | onClick(question)}
79 | />
80 |
81 |
82 | {'답: '}
83 |
84 | {answerText}
85 |
86 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/src/containers/YesNoAnswersContainer.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | fireEvent, render, waitFor,
5 | } from '@testing-library/react';
6 |
7 | import { useSelector, useDispatch } from 'react-redux';
8 |
9 | import YesNoAnswersContainer from './YesNoAnswersContainer';
10 |
11 | import { initializeState } from '../redux/slices/applicationSlice';
12 | import { playYesNoQuestion } from '../redux/slices/yesNoSlice';
13 |
14 | jest.mock('react-redux');
15 |
16 | const mockPush = jest.fn();
17 |
18 | jest.mock('react-router-dom', () => ({
19 | ...jest.requireActual('react-router-dom'),
20 | useHistory() {
21 | return { push: mockPush };
22 | },
23 | }));
24 |
25 | describe('YesNoAnswersContainer', () => {
26 | const goHomeButton = '처음으로';
27 |
28 | const answerTextMap = {
29 | Y: '맞아요',
30 | N: '아니에요',
31 | };
32 |
33 | const answers = [
34 | {
35 | question: '코끼리는 쥐보다 가볍나요?',
36 | answer: 'N',
37 | userAnswer: 'Y',
38 | },
39 | {
40 | question: '사자는 쥐한테 잡아먹히나요?',
41 | answer: 'N',
42 | userAnswer: 'N',
43 | },
44 | ];
45 |
46 | const dispatch = jest.fn();
47 |
48 | beforeEach(() => {
49 | jest.clearAllMocks();
50 |
51 | useSelector.mockImplementation((selector) => selector({
52 | application: { answers },
53 | }));
54 |
55 | useDispatch.mockImplementation(() => dispatch);
56 | });
57 |
58 | it('show answers', () => {
59 | const { container } = render();
60 |
61 | answers.forEach((each) => {
62 | const { question, answer } = each;
63 |
64 | expect(container).toHaveTextContent(question);
65 | expect(container).toHaveTextContent(answerTextMap[answer]);
66 | });
67 | });
68 |
69 | it('renders replay button on each answer', () => {
70 | const { getAllByTitle } = render();
71 |
72 | answers.forEach(({ question }, idx) => {
73 | dispatch.mockClear();
74 |
75 | fireEvent.click(getAllByTitle('replay')[idx]);
76 |
77 | waitFor(() => expect(dispatch).toBeCalledWith(playYesNoQuestion(question)));
78 | });
79 | });
80 |
81 | it('renders go home button', () => {
82 | const { getByText } = render();
83 |
84 | fireEvent.click(getByText(goHomeButton));
85 |
86 | expect(dispatch).toBeCalledWith(initializeState());
87 | expect(mockPush).toBeCalledWith('/');
88 | });
89 | });
90 |
--------------------------------------------------------------------------------
/src/components/QuestionCounter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import styled from '@emotion/styled';
4 |
5 | import { normalColor } from '../styles/colors';
6 | import QuestionNumbers from './QuestionNumbers';
7 |
8 | const Container = styled.div({
9 | display: 'flex',
10 | alignItems: 'center',
11 | });
12 |
13 | const NumberContainer = styled.div({
14 | display: 'flex',
15 | flexDirection: 'column',
16 | alignItems: 'center',
17 | height: '12rem',
18 | overflow: 'hidden',
19 | });
20 |
21 | const ButtonContainer = styled.div({
22 | display: 'flex',
23 | flexDirection: 'column',
24 | justifyContent: 'space-between',
25 | marginRight: '2rem',
26 | height: '6rem',
27 | });
28 |
29 | const Text = styled.p({
30 | fontSize: '1.5rem',
31 | color: normalColor,
32 | paddingLeft: '1.5rem',
33 | });
34 |
35 | const ArrowUp = styled.button({
36 | backgroundImage: 'url("/assets/images/arrowup.png")',
37 | border: 'none',
38 | width: '1.3rem',
39 | height: '1.3rem',
40 | backgroundSize: 'contain',
41 | backgroundRepeat: 'no-repeat',
42 | backgroundColor: 'transparent',
43 | cursor: 'pointer',
44 | outline: 'unset',
45 | });
46 |
47 | const ArrowDown = styled.button({
48 | backgroundImage: 'url("/assets/images/arrowdown.png")',
49 | border: 'none',
50 | width: '1.3rem',
51 | height: '1.3rem',
52 | backgroundSize: 'contain',
53 | backgroundRepeat: 'no-repeat',
54 | backgroundColor: 'transparent',
55 | cursor: 'pointer',
56 | outline: 'unset',
57 | });
58 |
59 | const MAX_QUESTIONS = 5;
60 | const MIN_QUESTIONS = 1;
61 |
62 | export default function FlowCounter({ numberOfQuestions, onClickIncrease, onClickDecrease }) {
63 | const isOverMax = numberOfQuestions >= MAX_QUESTIONS;
64 | const isBelowMin = numberOfQuestions <= MIN_QUESTIONS;
65 |
66 | return (
67 |
68 |
69 |
75 |
81 |
82 |
83 |
88 |
89 |
90 | 문제
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/containers/YesNoContainer/YesNoContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useDispatch, useSelector } from 'react-redux';
4 |
5 | import { useHistory } from 'react-router-dom';
6 |
7 | import { useAudio } from '../../hooks/audio';
8 |
9 | import SoundState from '../../enums/SoundState';
10 |
11 | import {
12 | playYesNoQuestion,
13 | saveAndGoToNextYesNoQuestion,
14 | } from '../../redux/slices/yesNoSlice';
15 |
16 | import { get } from '../../utils/utils';
17 |
18 | import ProgressBar from '../../components/ProgressBar';
19 | import YesNoGuideMessage from '../../components/YesNoGuideMessage';
20 | import YesNoPlayButton from '../../components/YesNoPlayButton';
21 | import YesNoSubmitButtons from '../../components/YesNoSubmitButtons';
22 |
23 | import {
24 | Container,
25 | BarBox,
26 | MessageBox,
27 | PlayButtonBox,
28 | SubmitButtonBox,
29 | } from './styled';
30 |
31 | export default function YesNoContainer() {
32 | const { answers, numberOfQuestions } = useSelector(get('application'));
33 | const { yesNoQuestion, soundState } = useSelector(get('yesno'));
34 |
35 | const answersNumber = answers.length;
36 | const { question, answer } = yesNoQuestion || {};
37 |
38 | const isPlaying = soundState === SoundState.PLAYING;
39 | const isIdle = soundState === SoundState.IDLE;
40 |
41 | const dispatch = useDispatch();
42 | const history = useHistory();
43 |
44 | const playCorrect = useAudio('../../assets/sounds/CorrectAnswer.mp3');
45 | const playWrong = useAudio('../../assets/sounds/IncorrectAnswer.mp3');
46 |
47 | const handleClickPlay = () => {
48 | dispatch(playYesNoQuestion(question));
49 | };
50 |
51 | const playSound = (userAnswer) => {
52 | const play = (userAnswer === answer) ? playCorrect : playWrong;
53 | return play();
54 | };
55 |
56 | const handleClickYesNo = async (userAnswer) => {
57 | await playSound(userAnswer);
58 |
59 | dispatch(saveAndGoToNextYesNoQuestion({ question, answer, userAnswer }));
60 |
61 | if (answersNumber === numberOfQuestions - 1) {
62 | history.push('/ynanswers');
63 | }
64 | };
65 |
66 | return (
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "final_project",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "homepage": "https://codesoom.github.io/project-react-2-gringrape/",
7 | "dependencies": {
8 | "@codeceptjs/configure": "^0.6.2",
9 | "@emotion/react": "^11.1.1",
10 | "@emotion/styled": "^11.0.0",
11 | "@reduxjs/toolkit": "^1.4.0",
12 | "aws-sdk": "^2.799.0",
13 | "codeceptjs": "^3.0.2",
14 | "lodash": "^4.17.20",
15 | "mock-audio-element": "0.0.0-beta.2",
16 | "playwright": "^1.6.2",
17 | "ramda": "^0.27.1",
18 | "react": "^17.0.1",
19 | "react-dom": "^17.0.1",
20 | "react-icons": "^3.11.0",
21 | "react-redux": "^7.2.2",
22 | "react-responsive-carousel": "^3.2.11",
23 | "react-router-dom": "^5.2.0",
24 | "redux-observable": "^1.2.0",
25 | "rxjs": "^6.6.3",
26 | "unique-random-range": "^1.0.7"
27 | },
28 | "devDependencies": {
29 | "@babel/core": "^7.12.3",
30 | "@babel/plugin-transform-runtime": "^7.12.1",
31 | "@babel/preset-env": "^7.12.1",
32 | "@babel/preset-react": "^7.12.5",
33 | "@testing-library/jest-dom": "^5.11.6",
34 | "@testing-library/react": "^11.1.2",
35 | "@testing-library/react-hooks": "^3.7.0",
36 | "@types/jest": "^26.0.15",
37 | "babel-jest": "^26.6.3",
38 | "babel-loader": "^8.2.1",
39 | "css-loader": "^5.0.1",
40 | "dotenv-webpack": "^5.1.0",
41 | "eslint": "^7.13.0",
42 | "eslint-config-airbnb": "^18.2.1",
43 | "eslint-plugin-import": "^2.22.1",
44 | "eslint-plugin-jsx-a11y": "^6.4.1",
45 | "eslint-plugin-react": "^7.21.5",
46 | "eslint-plugin-react-hooks": "^4.2.0",
47 | "file-loader": "^6.2.0",
48 | "gh-pages": "^3.1.0",
49 | "given2": "^2.1.7",
50 | "jest": "^26.6.3",
51 | "jest-plugin-context": "^2.9.0",
52 | "react-test-renderer": "^16.9.0",
53 | "redux-mock-store": "^1.5.4",
54 | "start-server-and-test": "^1.11.6",
55 | "style-loader": "^2.0.0",
56 | "webpack": "^4.32.2",
57 | "webpack-cli": "^3.3.0",
58 | "webpack-dev-server": "^3.11.0"
59 | },
60 | "scripts": {
61 | "start:dev": "webpack-dev-server --config webpack.config.dev.js",
62 | "build": "webpack --config webpack.config.build.js",
63 | "predeploy": "npm run build",
64 | "deploy": "gh-pages -d dist",
65 | "lint": "eslint --ext js,jsx .",
66 | "coverage": "jest --coverage",
67 | "test:unit": "jest",
68 | "test:e2e": "codeceptjs run --steps",
69 | "test": "start-server-and-test start:dev http://localhost:8080 test:e2e"
70 | },
71 | "repository": {
72 | "url": "git+https://github.com/gringrape/project-react-2-gringrape.git"
73 | },
74 | "keywords": [],
75 | "author": "Jin Kim",
76 | "license": "ISC",
77 | "bugs": {
78 | "url": "https://github.com/gringrape/project-react-2-gringrape/issues"
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/components/SentenceSpeakInput/SentenceSpeakInput.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { cleanup, fireEvent, render } from '@testing-library/react';
4 |
5 | import SentenceSpeakInput from './SentenceSpeakInput';
6 |
7 | import MicState from '../../enums/MicState';
8 |
9 | import { useAudio } from '../../hooks/audio';
10 |
11 | jest.mock('../../hooks/audio.js');
12 | jest.mock('../../hooks/volumeCanvas.js');
13 | jest.mock('../../services/instances/audioContext.instance.js');
14 |
15 | global.HTMLCanvasElement.prototype.getContext = jest.fn();
16 |
17 | describe('SentenceSpeakInput', () => {
18 | const spokenSentence = '사과가 맛있네요';
19 | const micButton = 'mic';
20 | const warningMessage = '제시어를 사용해서 문장을 말해보세요';
21 |
22 | const handleClick = jest.fn();
23 | const play = jest.fn();
24 |
25 | const renderSentenceSpeakInput = ({
26 | isCorrectSentence = true, sentence = spokenSentence, micState = MicState.OFF,
27 | } = {}) => render(
28 | ,
34 | );
35 |
36 | beforeEach(() => {
37 | handleClick.mockClear();
38 | play.mockClear();
39 |
40 | useAudio.mockReturnValue(play);
41 | });
42 |
43 | it('renders spoken sentence with sentence', () => {
44 | const { container } = renderSentenceSpeakInput({
45 | sentence: spokenSentence,
46 | });
47 |
48 | expect(container).toHaveTextContent(spokenSentence);
49 | });
50 |
51 | it('renders speak sentence button', () => {
52 | const { getByTitle } = renderSentenceSpeakInput();
53 |
54 | fireEvent.click(getByTitle(micButton));
55 |
56 | expect(handleClick).toBeCalled();
57 | });
58 |
59 | it('cannot click speak sentence button while mic is not off', () => {
60 | [MicState.ON, MicState.SPEAKING].forEach((micState) => {
61 | handleClick.mockClear();
62 |
63 | const { getByTitle } = renderSentenceSpeakInput({ micState });
64 |
65 | fireEvent.click(getByTitle(micButton));
66 |
67 | expect(handleClick).not.toBeCalled();
68 |
69 | cleanup();
70 | });
71 | });
72 |
73 | context('when spoken sentence is correct', () => {
74 | const isCorrectSentence = true;
75 |
76 | it('play correct sound ', () => {
77 | renderSentenceSpeakInput({
78 | isCorrectSentence,
79 | });
80 |
81 | expect(play).toBeCalled();
82 | });
83 | });
84 |
85 | context('when spoken sentence does not contain prompt', () => {
86 | const isCorrectSentence = false;
87 |
88 | it('does not play correct sound', () => {
89 | renderSentenceSpeakInput({
90 | isCorrectSentence,
91 | });
92 |
93 | expect(play).not.toBeCalled();
94 | });
95 |
96 | it('shows warning message', () => {
97 | const { container } = renderSentenceSpeakInput({
98 | isCorrectSentence,
99 | });
100 |
101 | expect(container).toHaveTextContent(warningMessage);
102 | });
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/src/containers/SentenceSpeakContainer.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 |
3 | import { useDispatch, useSelector } from 'react-redux';
4 |
5 | import { useHistory } from 'react-router-dom';
6 |
7 | import styled from '@emotion/styled';
8 |
9 | import _ from 'lodash';
10 |
11 | import SentenceSpeakInput from '../components/SentenceSpeakInput';
12 | import SentenceSubmitButton from '../components/SentenceSubmitButton';
13 | import ProgressBar from '../components/ProgressBar';
14 |
15 | import { flexBoxCenter } from '../styles/common';
16 |
17 | import { titleFont } from '../styles/fonts';
18 | import { normalColor } from '../styles/colors';
19 |
20 | import {
21 | recognizeSpeech,
22 | getNextQuestion,
23 | saveAnswer,
24 | } from '../redux/slices/speakSentenceSlice';
25 |
26 | import { get } from '../utils/utils';
27 |
28 | const Container = styled.div({
29 | display: 'flex',
30 | flexDirection: 'column',
31 | alignItems: 'center',
32 | });
33 |
34 | const QuestionProgress = styled(ProgressBar)({
35 | marginTop: '3.28vh',
36 | });
37 |
38 | const Prompt = styled.div({
39 | fontFamily: titleFont,
40 | fontSize: '4.5rem',
41 | ...flexBoxCenter,
42 | marginTop: '9.53vh',
43 | color: normalColor,
44 | height: '5.5rem',
45 | });
46 |
47 | export const SubmitButton = styled(SentenceSubmitButton)({
48 | marginTop: '9.38vh',
49 | });
50 |
51 | export default function SentenceSpeakContainer() {
52 | const { prompt, spokenSentence, micState } = useSelector(get('speakSentence'));
53 | const { answers, numberOfQuestions } = useSelector((get('application')));
54 |
55 | const dispatch = useDispatch();
56 | const history = useHistory();
57 |
58 | const isAnsweringComplete = answers.length === numberOfQuestions - 1;
59 | const isCorrectSentence = _.isString(spokenSentence) && spokenSentence.includes(prompt);
60 |
61 | const handleClickSpeak = useCallback(() => {
62 | dispatch(recognizeSpeech());
63 | }, [dispatch]);
64 |
65 | const handleClickNext = useCallback(() => {
66 | dispatch(saveAnswer({ prompt, spokenSentence }));
67 | dispatch(getNextQuestion());
68 | }, [dispatch, prompt, spokenSentence]);
69 |
70 | const handleClickExit = useCallback(() => {
71 | dispatch(saveAnswer({ prompt, spokenSentence }));
72 | history.push('/answers');
73 | }, [dispatch, prompt, spokenSentence, history]);
74 |
75 | return (
76 |
77 |
81 |
82 | {prompt}
83 |
84 |
91 |
97 |
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/src/App.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render } from '@testing-library/react';
4 |
5 | import { useDispatch, useSelector } from 'react-redux';
6 |
7 | import { MemoryRouter } from 'react-router-dom';
8 |
9 | import App from './App';
10 |
11 | jest.mock('react-redux');
12 | jest.mock('./services/speechRecognitionService.js');
13 | jest.mock('./services/instances/audioContext.instance.js');
14 | jest.mock('./hooks/volumeCanvas.js');
15 |
16 | global.HTMLMediaElement.prototype.pause = jest.fn();
17 | global.HTMLCanvasElement.prototype.getContext = jest.fn();
18 |
19 | describe('App', () => {
20 | const prompt = '사과';
21 | const mainPageButton = '시작 하기';
22 | const answersPage = '오늘';
23 | const yesnoPage = '잘 듣고 정답을 골라보세요';
24 | const selectPageTitle = '무엇을 연습해 볼까요';
25 | const ynAnswersPage = '정답 확인';
26 | const setQuestionNumberPage = '몇 문제를 풀어볼까요?';
27 |
28 | const dispatch = jest.fn();
29 |
30 | const renderApp = ({ path }) => render((
31 |
32 |
33 |
34 | ));
35 |
36 | beforeEach(() => {
37 | useDispatch.mockImplementation(() => dispatch);
38 |
39 | useSelector.mockImplementation((selector) => selector({
40 | application: {
41 | answers: [],
42 | },
43 | speakSentence: {
44 | prompt,
45 | spokenSentence: null,
46 | },
47 | yesno: {},
48 | }));
49 | });
50 |
51 | it('shows main page on route /', () => {
52 | const { queryByText } = renderApp({ path: '/' });
53 |
54 | expect(queryByText(mainPageButton)).not.toBeNull();
55 | });
56 |
57 | it('shows select page on route /select', () => {
58 | const { container } = renderApp({ path: '/select' });
59 |
60 | expect(container).toHaveTextContent(selectPageTitle);
61 | });
62 |
63 | it('shows make sentence page on route /sentence', () => {
64 | const { container } = renderApp({ path: '/sentence' });
65 |
66 | expect(container).toHaveTextContent(prompt);
67 | });
68 |
69 | it('shows results page on route /results', () => {
70 | const { container } = renderApp({ path: '/answers' });
71 |
72 | expect(container).toHaveTextContent(answersPage);
73 | });
74 |
75 | it('shows YesNo page on route /yesno', () => {
76 | const { container } = renderApp({ path: '/yesno' });
77 |
78 | expect(container).toHaveTextContent(yesnoPage);
79 | });
80 |
81 | it('shows yes no question answers page on route /ynanswers', () => {
82 | const { container } = renderApp({ path: '/ynanswers' });
83 |
84 | expect(container).toHaveTextContent(ynAnswersPage);
85 | });
86 |
87 | it('shows set question number page on route /setnumber', () => {
88 | const { container } = renderApp({ path: '/setnumber' });
89 |
90 | expect(container).toHaveTextContent(setQuestionNumberPage);
91 | });
92 |
93 | // TODO: rename
94 | it('shows related speech page on route /speech', () => {
95 | const { container } = renderApp({ path: '/speech' });
96 |
97 | expect(container).toHaveTextContent('버튼을 누르고 문장을 말해보세요');
98 | });
99 |
100 | it('shows the not found page on invalid route', () => {
101 | const { container } = renderApp({ path: '/404' });
102 |
103 | expect(container).toHaveTextContent('페이지를 찾을수 없어요~');
104 | });
105 | });
106 |
--------------------------------------------------------------------------------
/src/redux/epics/SpeakSentenceEpics.test.js:
--------------------------------------------------------------------------------
1 | import { TestScheduler } from 'rxjs/testing';
2 |
3 | import { of } from 'rxjs';
4 |
5 | import {
6 | addAnswer,
7 | } from '../slices/applicationSlice';
8 |
9 | import {
10 | getNextQuestion,
11 | recognizeSpeech,
12 | saveAnswer,
13 | setMicState,
14 | setPrompt,
15 | setSpokenSentence,
16 | } from '../slices/speakSentenceSlice';
17 |
18 | import {
19 | getNextQuestionEpic,
20 | recognizeSpeechEpic,
21 | listenRecognitionEvents,
22 | saveAnswerEpic,
23 | } from './SpeakSentenceEpics';
24 |
25 | import { fetchNextPrompt, getExamples } from '../../services/dataService';
26 | import { abortRecognition, recognize } from '../../services/speechRecognitionService';
27 |
28 | import MicState from '../../enums/MicState';
29 |
30 | jest.mock('../../services/speechRecognitionService.js');
31 | jest.mock('../../services/dataService.js');
32 |
33 | describe('epics', () => {
34 | let testScheduler;
35 |
36 | const fakePrompt = '사과';
37 | const fakeSentence = '사과는 맛있다';
38 | const fakeExamples = [
39 | '자두는 맛이 없다',
40 | '자두는 맛이 있다',
41 | ];
42 |
43 | beforeEach(() => {
44 | fetchNextPrompt.mockImplementation(() => fakePrompt);
45 | recognize.mockImplementation(() => of(fakeSentence));
46 | getExamples.mockImplementation(() => fakeExamples);
47 |
48 | testScheduler = new TestScheduler((actual, expected) => {
49 | expect(actual).toEqual(expected);
50 | });
51 | });
52 |
53 | test('getNextQuestionEpic', () => {
54 | testScheduler.run(({ hot, expectObservable }) => {
55 | const action$ = hot('-a', {
56 | a: getNextQuestion(),
57 | });
58 |
59 | const output$ = getNextQuestionEpic(action$);
60 |
61 | expectObservable(output$).toBe('-(ab)', {
62 | a: setPrompt(fakePrompt),
63 | b: setSpokenSentence(null),
64 | });
65 | });
66 |
67 | expect(abortRecognition).toBeCalled();
68 | });
69 |
70 | test('recognizeSpeechEpic', () => {
71 | testScheduler.run(({ hot, expectObservable }) => {
72 | const action$ = hot('-a', {
73 | a: recognizeSpeech(),
74 | });
75 |
76 | const output$ = recognizeSpeechEpic(action$);
77 |
78 | expectObservable(output$).toBe('-(ab)', {
79 | a: { type: 'listenRecognitionEvents' },
80 | b: setSpokenSentence(fakeSentence),
81 | });
82 | });
83 | });
84 |
85 | test('listenRecognitionEventsEpic', () => {
86 | testScheduler.run(({ hot, expectObservable }) => {
87 | const action$ = hot('-a', {
88 | a: { type: 'listenRecognitionEvents' },
89 | });
90 |
91 | const output$ = listenRecognitionEvents(action$);
92 |
93 | expectObservable(output$).toBe('-(abcd)', {
94 | a: setMicState(MicState.ON),
95 | b: setMicState(MicState.OFF),
96 | c: setMicState(MicState.SPEAKING),
97 | d: setMicState(MicState.ON),
98 | });
99 | });
100 | });
101 |
102 | test('saveAnswerEpic', () => {
103 | testScheduler.run(({ hot, expectObservable }) => {
104 | const action$ = hot('-a', {
105 | a: saveAnswer({
106 | prompt: '자두',
107 | spokenSentence: '자두는',
108 | }),
109 | });
110 |
111 | const output$ = saveAnswerEpic(action$);
112 |
113 | expectObservable(output$).toBe('-a', {
114 | a: addAnswer({
115 | prompt: '자두',
116 | spokenSentence: '자두는',
117 | examples: fakeExamples,
118 | }),
119 | });
120 | });
121 | });
122 | });
123 |
--------------------------------------------------------------------------------
/src/containers/SentenceSpeakContainer.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render } from '@testing-library/react';
4 |
5 | import { useDispatch, useSelector } from 'react-redux';
6 |
7 | import given from 'given2';
8 |
9 | import { useAudio } from '../hooks/audio';
10 |
11 | import SentenceSpeakContainer from './SentenceSpeakContainer';
12 |
13 | import MicState from '../enums/MicState';
14 | import { recognizeSpeech, saveAnswer } from '../redux/slices/speakSentenceSlice';
15 |
16 | jest.mock('react-redux');
17 | jest.mock('../services/speechRecognitionService.js');
18 | jest.mock('../hooks/audio.js');
19 | jest.mock('../services/instances/audioContext.instance.js');
20 |
21 | const mockPush = jest.fn();
22 |
23 | jest.mock('react-router-dom', () => ({
24 | ...jest.requireActual('react-router-dom'),
25 | useHistory() {
26 | return { push: mockPush };
27 | },
28 | }));
29 |
30 | describe('SentenceSpeakContainer', () => {
31 | const prompt = '사과';
32 | const micButton = 'mic';
33 | const spokenSentence = '사과가 맛있네요';
34 | const nextButton = '다음 문제';
35 | const exitButton = '종료';
36 | const numberOfQuestions = 5;
37 |
38 | const dispatch = jest.fn();
39 |
40 | const renderSentenceSpeakContainer = () => render(
41 | ,
42 | );
43 |
44 | beforeEach(() => {
45 | dispatch.mockClear();
46 |
47 | useAudio.mockImplementation(() => jest.fn());
48 |
49 | useDispatch.mockImplementation(() => dispatch);
50 |
51 | useSelector.mockImplementation((selector) => selector({
52 | application: {
53 | numberOfQuestions,
54 | answers: given.answers || [],
55 | },
56 | speakSentence: {
57 | micState: MicState.OFF,
58 | prompt,
59 | spokenSentence,
60 | },
61 | }));
62 | });
63 |
64 | it('renders prompt', () => {
65 | const { queryAllByText } = renderSentenceSpeakContainer();
66 |
67 | expect(queryAllByText(prompt)).not.toEqual([]);
68 | });
69 |
70 | it('renders spoken sentence', () => {
71 | const { container } = renderSentenceSpeakContainer();
72 |
73 | expect(container).toHaveTextContent(spokenSentence);
74 | });
75 |
76 | it('renders speak sentence button', () => {
77 | const { getByTitle } = renderSentenceSpeakContainer();
78 |
79 | fireEvent.click(getByTitle(micButton));
80 |
81 | expect(dispatch).toBeCalledWith(recognizeSpeech());
82 | });
83 |
84 | context('when answering is not complete', () => {
85 | given('answers', () => new Array(numberOfQuestions - 2));
86 |
87 | it('renders next button', () => {
88 | const { getByText } = renderSentenceSpeakContainer();
89 |
90 | fireEvent.click(getByText(nextButton));
91 |
92 | expect(dispatch).toBeCalledWith(saveAnswer({ prompt, spokenSentence }));
93 | });
94 | });
95 |
96 | context('when answering is complete', () => {
97 | given('answers', () => new Array(numberOfQuestions - 1));
98 |
99 | it('renders exit button', () => {
100 | const { getByText } = renderSentenceSpeakContainer();
101 |
102 | fireEvent.click(getByText(exitButton));
103 |
104 | expect(dispatch).toBeCalledWith(saveAnswer({ prompt, spokenSentence }));
105 |
106 | expect(mockPush).toBeCalledWith('/answers');
107 | });
108 | });
109 |
110 | it('renders progress bar', () => {
111 | const questionNumber = 3;
112 |
113 | given('answers', () => new Array(questionNumber));
114 |
115 | const { container } = renderSentenceSpeakContainer();
116 |
117 | expect(container).toHaveTextContent(questionNumber);
118 | });
119 | });
120 |
--------------------------------------------------------------------------------
/data/yesNoQuestions.js:
--------------------------------------------------------------------------------
1 | const yesNoQuestions = [
2 | {
3 | question: '쥐는 하마 보다 큰가요?',
4 | answer: 'N',
5 | },
6 | {
7 | question: '무로 바닥을 청소할 수 있나요?',
8 | answer: 'N',
9 | },
10 | {
11 | question: '프라이팬을 먹을 수 있나요?',
12 | answer: 'N',
13 | },
14 | {
15 | question: '얼음 위를 헤엄 칠 수 있나요?',
16 | answer: 'N',
17 | },
18 | {
19 | question: '창문은 열 수 있나요?',
20 | answer: 'Y',
21 | },
22 | {
23 | question: '불은 뜨겁나요?',
24 | answer: 'Y',
25 | },
26 | {
27 | question: '야채는 잘게 썰 수 있나요?',
28 | answer: 'Y',
29 | },
30 | {
31 | question: '기린은 목이, 기이인 가요?',
32 | answer: 'Y',
33 | },
34 | {
35 | question: '레몬은 맛이 매운가요?',
36 | answer: 'N',
37 | },
38 | {
39 | question: '일주일은 5 일인가요?',
40 | answer: 'N',
41 | },
42 | {
43 | question: '딸기는 먹을 수 있나요?',
44 | answer: 'Y',
45 | },
46 | {
47 | question: '책은 읽을 수 있나요?',
48 | answer: 'Y',
49 | },
50 | {
51 | question: '뼈는 부러질 수 있나요?',
52 | answer: 'Y',
53 | },
54 | {
55 | question: '손을 살 수 있나요?',
56 | answer: 'N',
57 | },
58 | {
59 | question: '계란은 깨질 수 있나요?',
60 | answer: 'Y',
61 | },
62 | {
63 | question: '주스는 마실 수 있나요?',
64 | answer: 'Y',
65 | },
66 | {
67 | question: '연필깎이로 무를 자를 수 있나요?',
68 | answer: 'N',
69 | },
70 | {
71 | question: '컴퓨터를 이용해서 그림을 그릴 수 있나요??',
72 | answer: 'Y',
73 | },
74 | {
75 | question: '감기에 걸리면 나을 수 있나요?',
76 | answer: 'Y',
77 | },
78 | {
79 | question: '감기에 걸리면 약을 먹나요?',
80 | answer: 'Y',
81 | },
82 | {
83 | question: '술병을 마실 수 있나요?',
84 | answer: 'N',
85 | },
86 | {
87 | question: '상처가 나면 소독을 하나요?',
88 | answer: 'Y',
89 | },
90 | {
91 | question: '전자레인지와 대화를 할 수 있을까요?',
92 | answer: 'N',
93 | },
94 | {
95 | question: '냉장고를 웃길 수 있나요?',
96 | answer: 'N',
97 | },
98 | {
99 | question: '옷은 보통, 장농에 보관하나요?',
100 | answer: 'Y',
101 | },
102 | {
103 | question: '이불은 덮을 수 있나요?',
104 | answer: 'Y',
105 | },
106 | {
107 | question: '옷은, 빨 수 있나아요?',
108 | answer: 'Y',
109 | },
110 | {
111 | question: '지우개로 불을 피울 수 있나요?',
112 | answer: 'N',
113 | },
114 | {
115 | question: '야구는 공을, 차는 운동인가요?',
116 | answer: 'N',
117 | },
118 | {
119 | question: '날씨가 더울 때, 성풍기를 사용하나요?',
120 | answer: 'Y',
121 | },
122 | {
123 | question: '날씨가 추울 때 에어컨을 사용하나요?',
124 | answer: 'N',
125 | },
126 | {
127 | question: '호랑이가 쥐한테, 잡아 먹히나요?',
128 | answer: 'N',
129 | },
130 | {
131 | question: '돼지는 고기를 먹나요?',
132 | answer: 'Y',
133 | },
134 | {
135 | question: '닥은 알을 낳나요?',
136 | answer: 'Y',
137 | },
138 | {
139 | question: '휴대폰으로 전화를 걸 수 있을까요?',
140 | answer: 'Y',
141 | },
142 | {
143 | question: '자를 이용해서 길이를 잴 수 있나요?',
144 | answer: 'Y',
145 | },
146 | {
147 | question: '소년이, 할아버지보다, 나이가 많나요?',
148 | answer: 'N',
149 | },
150 | {
151 | question: '할머니가 소녀보다, 나이가 적나요?',
152 | answer: 'N',
153 | },
154 | {
155 | question: '수요일 다음날은 금요일이 맞습니까?',
156 | answer: 'N',
157 | },
158 | {
159 | question: '비행기는 하늘을 날 수 있습니까?',
160 | answer: 'Y',
161 | },
162 | {
163 | question: '우리나라의 계절은 사게절 인가요?',
164 | answer: 'Y',
165 | },
166 | {
167 | question: '면도기로 근사한 요리를 할 수있나요?',
168 | answer: 'N',
169 | },
170 | {
171 | question: '가방을 신고, 다닐 수 있나요?',
172 | answer: 'N',
173 | },
174 | {
175 | question: '침대에 누울 수 있나요?',
176 | answer: 'Y',
177 | },
178 | {
179 | question: '숟가락이 노래를 할 수 있나요?',
180 | answer: 'N',
181 | },
182 | {
183 | question: '빙판은, 미끄러운가요?',
184 | answer: 'Y',
185 | },
186 | {
187 | question: '빗자루를 타고 하늘을 날 수 있나요?',
188 | answer: 'N',
189 | },
190 | {
191 | question: '컴퓨터를 이용해서 옷을 살 수 있나요?',
192 | answer: 'Y',
193 | },
194 | ];
195 |
196 | export default yesNoQuestions;
197 |
--------------------------------------------------------------------------------
/src/redux/epics/YesNoEpics.test.js:
--------------------------------------------------------------------------------
1 | import { TestScheduler } from 'rxjs/testing';
2 |
3 | import { of } from 'rxjs';
4 |
5 | import {
6 | setYesNoQuestion,
7 | startPlaying,
8 | idlePlaying,
9 | endPlaying,
10 | getNextYesNoQuestion,
11 | playYesNoQuestion,
12 | stopYesNoQuestion,
13 | saveAndGoToNextYesNoQuestion,
14 | } from '../slices/yesNoSlice';
15 |
16 | import {
17 | getNextYesNoQuestionEpic,
18 | listenYesNoEndEventEpic,
19 | playYesNoQuestionEpic,
20 | saveAndGoToNextYesNoQuestionEpic,
21 | stopYesNoQuestionEpic,
22 | } from './YesNoEpics';
23 |
24 | import { fetchNextYesNoQuestion } from '../../services/dataService';
25 | import { play, stop } from '../../services/speechSynthesisService';
26 | import SoundState from '../../enums/SoundState';
27 | import { addAnswer } from '../slices/applicationSlice';
28 |
29 | jest.mock('../../services/speechRecognitionService.js');
30 | jest.mock('../../services/speechSynthesisService.js');
31 | jest.mock('../../services/dataService.js');
32 |
33 | describe('epics', () => {
34 | let testScheduler;
35 |
36 | const yesNoQuestion = '안녕하세요';
37 | const fakeQuestion = {
38 | answer: '네',
39 | question: '지구는 네모난 모양인가요?',
40 | };
41 |
42 | beforeEach(() => {
43 | fetchNextYesNoQuestion.mockImplementation(() => fakeQuestion);
44 | play.mockImplementation(() => of(''));
45 |
46 | testScheduler = new TestScheduler((actual, expected) => {
47 | expect(actual).toEqual(expected);
48 | });
49 | });
50 |
51 | test('getNextYesNoQuestionEpic', () => {
52 | testScheduler.run(({ hot, expectObservable }) => {
53 | const action$ = hot('-a', {
54 | a: getNextYesNoQuestion,
55 | });
56 |
57 | const output$ = getNextYesNoQuestionEpic(action$);
58 |
59 | expectObservable(output$).toBe('-a', {
60 | a: setYesNoQuestion(fakeQuestion),
61 | });
62 | });
63 | });
64 |
65 | test('playYesNoQuestionEpic', () => {
66 | testScheduler.run(({ hot, expectObservable }) => {
67 | const action$ = hot('-a', {
68 | a: playYesNoQuestion(yesNoQuestion),
69 | });
70 |
71 | const output$ = playYesNoQuestionEpic(action$);
72 |
73 | expectObservable(output$).toBe('-(ab)', {
74 | a: startPlaying(),
75 | b: { type: 'listenYesNoEndEvent' },
76 | });
77 | });
78 | expect(play).toBeCalledWith(yesNoQuestion);
79 | });
80 |
81 | test('stopYesNoQuestionEpic', () => {
82 | testScheduler.run(({ hot, expectObservable }) => {
83 | const action$ = hot('-a', {
84 | a: stopYesNoQuestion(),
85 | });
86 |
87 | const output$ = stopYesNoQuestionEpic(action$);
88 |
89 | expectObservable(output$).toBe('-a', {
90 | a: idlePlaying(),
91 | });
92 | });
93 |
94 | expect(stop).toBeCalled();
95 | });
96 |
97 | test('listenYesNoEndEventEpic', () => {
98 | testScheduler.run(({ hot, expectObservable }) => {
99 | const action$ = hot('-a', {
100 | a: { type: 'listenYesNoEndEvent' },
101 | });
102 |
103 | const state$ = {
104 | value: {
105 | soundState: SoundState.PLAYING,
106 | },
107 | };
108 |
109 | const output$ = listenYesNoEndEventEpic(action$, state$);
110 |
111 | expectObservable(output$).toBe('-a', {
112 | a: endPlaying(),
113 | });
114 | });
115 | });
116 |
117 | test('saveAndGoToNextYesNoQuestionEpic', () => {
118 | const questionAnswer = { ...fakeQuestion, userAnswer: 'Y' };
119 |
120 | testScheduler.run(({ hot, expectObservable }) => {
121 | const action$ = hot('-a', {
122 | a: saveAndGoToNextYesNoQuestion(questionAnswer),
123 | });
124 |
125 | const output$ = saveAndGoToNextYesNoQuestionEpic(action$);
126 |
127 | expectObservable(output$).toBe('-(abcd)', {
128 | a: stopYesNoQuestion(),
129 | b: idlePlaying(),
130 | c: addAnswer(questionAnswer),
131 | d: getNextYesNoQuestion(),
132 | });
133 | });
134 | });
135 | });
136 |
--------------------------------------------------------------------------------
/src/containers/YesNoContainer/YesNoContainer.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent, render, waitFor } from '@testing-library/react';
4 |
5 | import given from 'given2';
6 |
7 | import { useDispatch, useSelector } from 'react-redux';
8 |
9 | import {
10 | playYesNoQuestion,
11 | saveAndGoToNextYesNoQuestion,
12 | } from '../../redux/slices/yesNoSlice';
13 |
14 | import YesNoContainer from './YesNoContainer';
15 |
16 | import SoundState from '../../enums/SoundState';
17 |
18 | import { useAudio } from '../../hooks/audio';
19 |
20 | jest.mock('../../hooks/audio.js');
21 |
22 | const mockPush = jest.fn();
23 |
24 | jest.mock('react-router-dom', () => ({
25 | ...jest.requireActual('react-router-dom'),
26 | useHistory() {
27 | return { push: mockPush };
28 | },
29 | }));
30 |
31 | describe('YesNoContainer', () => {
32 | const yesButton = '맞아요';
33 | const noButton = '아니에요';
34 | const playButton = 'play';
35 | const guideMessage = '잘 듣고 정답을 골라보세요';
36 | const currentQuestion = { question: '쥐는 코끼리보다 무겁나요?', answer: 'N' };
37 | const currentAnswersNumber = 1;
38 | const numberOfQuestions = 3;
39 |
40 | const dispatch = jest.fn();
41 | const playYes = jest.fn();
42 | const playWrong = jest.fn();
43 |
44 | beforeEach(() => {
45 | jest.clearAllMocks();
46 |
47 | useDispatch.mockImplementation(() => dispatch);
48 |
49 | useSelector.mockImplementation((selector) => selector({
50 | application: {
51 | answers: (given.answers || []),
52 | numberOfQuestions,
53 | },
54 | yesno: {
55 | soundState: SoundState.END,
56 | yesNoQuestion: currentQuestion,
57 | },
58 | }));
59 |
60 | useAudio.mockImplementation((path) => (
61 | path.includes('Correct')
62 | ? playYes
63 | : playWrong
64 | ));
65 | });
66 |
67 | context('with currently answered quetion', () => {
68 | given('answers', () => new Array(currentAnswersNumber));
69 |
70 | it('show progress bar', () => {
71 | const { container } = render();
72 |
73 | expect(container).toHaveTextContent(currentAnswersNumber);
74 | expect(container).toHaveTextContent(numberOfQuestions);
75 | });
76 | });
77 |
78 | it('renders guide message', () => {
79 | const { container } = render();
80 |
81 | expect(container).toHaveTextContent(guideMessage);
82 | });
83 |
84 | it('renders play button', () => {
85 | const { getByTitle } = render();
86 |
87 | fireEvent.click(getByTitle(playButton));
88 |
89 | expect(dispatch).toBeCalledWith(playYesNoQuestion(currentQuestion.question));
90 | });
91 |
92 | it('rings correct sound when user click right button', () => {
93 | const { getByText } = render();
94 |
95 | fireEvent.click(getByText(noButton));
96 |
97 | expect(playYes).toBeCalled();
98 | });
99 |
100 | it('rings incorrect sound when user click wrong button', () => {
101 | const { getByText } = render();
102 |
103 | fireEvent.click(getByText(yesButton));
104 |
105 | expect(playWrong).toBeCalled();
106 | });
107 |
108 | it('save and get next question when user clicks yes or no buttons', async () => {
109 | const { getByText } = render();
110 |
111 | fireEvent.click(getByText(yesButton));
112 |
113 | await waitFor(() => expect(dispatch).toBeCalledWith(saveAndGoToNextYesNoQuestion({
114 | ...currentQuestion,
115 | userAnswer: 'Y',
116 | })));
117 | });
118 |
119 | context('when answers number is max', () => {
120 | given('answers', () => new Array(numberOfQuestions - 1));
121 |
122 | it('go to answers page', async () => {
123 | const { getByText } = render();
124 |
125 | fireEvent.click(getByText(noButton));
126 |
127 | await waitFor(() => expect(mockPush).toBeCalledWith('/ynanswers'));
128 | });
129 | });
130 |
131 | context('when answers number is not max', () => {
132 | given('answers', () => new Array(numberOfQuestions - 2));
133 |
134 | it('does not go to answers page', async () => {
135 | const { getByText } = render();
136 |
137 | fireEvent.click(getByText(noButton));
138 |
139 | await waitFor(() => expect(mockPush).not.toBeCalled());
140 | });
141 | });
142 | });
143 |
--------------------------------------------------------------------------------
/data/examples.js:
--------------------------------------------------------------------------------
1 | const examples = {
2 | 탁구: [
3 | '어제 탁구용품을 사러 갔다',
4 | '탁구는 많은 사람이 즐기는 스포츠다',
5 | ],
6 | 농구: [
7 | '농구는 손을 사용하는 운동이다.',
8 | '다음 주에 농구 경기를 보러 갈 예정이다.',
9 | ],
10 | 배구: [
11 | '배구는 여섯 명이 하는 운동이다',
12 | '친구들과 배구를 했다',
13 | ],
14 | 야구: [
15 | '야구는 공과 글러브를 사용한다',
16 | '내 친구는 야구선수다',
17 | ],
18 | 수영: [
19 | '아들은 한 달 동안 수영장에서 수영을 배웠다',
20 | '수영을 하러 바닷가로 나갔다',
21 | ],
22 | 테니스: [
23 | '나는 취미로 테니스를 친다',
24 | '테니스 코드 중간에는 네트가 있다',
25 | ],
26 | 달리기: [
27 | '달리기를 하면 몸과 마음이 건강해진다',
28 | '체력장으로 오래달리기를 했다',
29 | ],
30 | 사과: [
31 | '사과는 빨간색이다',
32 | '아들은 사과를 좋아한다',
33 | ],
34 | 배: [
35 | '배는 맛이 달다',
36 | '어제는 배로 깍두기를 요리했다',
37 | ],
38 | 참외: [
39 | '참외는 봄과 여름이 제철이다',
40 | '참외는 성주 참외가 유명하다',
41 | ],
42 | 키위: [
43 | '키위가 먹고 싶어요',
44 | '나는 키위를 좋아해요',
45 | ],
46 | 귤: [
47 | '귤은 겨울이 제철이에요',
48 | '귤은 주황색이에요',
49 | ],
50 | 포도: [
51 | '포도주는 포도를 발효시켜서 만들어요',
52 | '아들이 집에 오는 길에 포도를 사 왔다',
53 | ],
54 | 청포도: [
55 | '내 고장 칠월은 청포도가 익어 가는 시절',
56 | '청포도는 녹색입니다',
57 | ],
58 | 곶감: [
59 | '우선 먹기는 곶감이 달다',
60 | '곶감은 생감을 말려서 만들어요',
61 | ],
62 | 딸기: [
63 | '딸기는 장미과에 속하는 식물이다',
64 | '봄 딸기보다 겨울딸기가 맛있다',
65 | ],
66 | 망고: [
67 | '망고는 노란색은 띤다',
68 | '나는 망고 아이스크림을 좋아해요',
69 | ],
70 | 자두: [
71 | '말린 자두는 쾌변에 도움이 된다',
72 | '자두나무는 오얏나무라고도 한다',
73 | ],
74 | 체리: [
75 | '체리는 순우리말로 버찌라고 해요',
76 | '체리는 숙면에 도움을 줍니다',
77 | ],
78 | 양파: [
79 | '양파는 단맛이 난다',
80 | '어제 저녁에 양파 볶음을 만들어 먹었어요',
81 | ],
82 | 파: [
83 | '라면에 대파를 넣어 먹었다',
84 | '친구는 집에서 파를 키운다',
85 | ],
86 | 당근: [
87 | '당근은 주황색이에요',
88 | '볶음밥에 당근을 썰어서 넣었어요',
89 | ],
90 | 피망: [
91 | '고추잡채는 피망을 많이 사용하는 요리다',
92 | '피망 한 개 주세요',
93 | ],
94 | 감자: [
95 | '감자튀김은 맛있어요',
96 | '저녁에 감자조림을 만들어 먹을 거에요',
97 | ],
98 | 고구마: [
99 | '고구마는 단맛이 난다',
100 | '고구마는 껍찔째 먹는 것이 좋다',
101 | ],
102 | 무: [
103 | '저녁에는 소고기무국을 끓일 거에요',
104 | '인터넷으로 무를 주문했어요',
105 | ],
106 | 상추: [
107 | '상추를 먹으면 잠이 와요',
108 | '고기와 상추를 함께 먹어요',
109 | ],
110 | 콩나물: [
111 | '아침에 콩나물국을 끓여 먹었어요',
112 | '콩나물로 많은 요리를 만들 수 있습니다',
113 | ],
114 | 버섯: [
115 | '야생 버섯을 먹으면 위험해요',
116 | '마트에 가서 버섯을 사왔어요',
117 | ],
118 | 깻잎: [
119 | '유기농 깻잎을 사왔어요',
120 | '딸은 깻잎장아찌를 좋아해요',
121 | ],
122 | 봄: [
123 | '봄은 계획, 그리고 시작의 계절이다',
124 | '봄에는 개나리가 피어나요',
125 | ],
126 |
127 | 여름: [
128 | '한 겨울에도 우리의 마음속에 여름을 조금이나마 간직해야 한다',
129 | '여름이 오면 바다에 놀러 갈 거에요',
130 | ],
131 | 가을: [
132 | '가을은 천고마비의 계절',
133 | '지난 가을에는 불국사로 단풍을 보러 갔어요',
134 | ],
135 | 겨울: [
136 | '겨울에 스키여행을 가요',
137 | '작년보다 올해 겨울이 더 추워요',
138 | ],
139 | 강아지: [
140 | '딸과 함께 입양해온 강아지에게 이름을 지어 주었어요',
141 | '강아지는 견종별로 특징이 있어요',
142 | ],
143 | 기린: [
144 | '동물원에 가면 기린을 볼 수 있어요',
145 | '기린은 초식동물입니다',
146 | ],
147 | 코끼리: [
148 | '코끼리는 코가 길어요',
149 | '코끼리는 지구에서 가장 큰 동물이에요',
150 | ],
151 | 사자: [
152 | '사자는 육식동물입니다',
153 | '사자는 무리를 이루어 살아요',
154 | ],
155 | 사슴: [
156 | '사슴에는 뿔이 있어요',
157 | '동물원에서 사슴을 보고 왔어요',
158 | ],
159 | 캥거루: [
160 | '캥거루는 호주에서 서식해요',
161 | '캥거루는 앞다리에 비해 뒷다리가 커요',
162 | ],
163 | 늑대: [
164 | '개는 늑대와 비슷하게 생겼다',
165 | '산중에서 늑대 우는 소리가 들렸다',
166 | ],
167 | 여우: [
168 | '여우는 접시에 고기를 담아 주면서 황새에게 많이 먹으라고 했다',
169 | '구미호는 꼬리가 아홉개 다린 여우다',
170 | ],
171 | 너구리: [
172 | '너구리가 굴속에 숨어 버렸다',
173 | '소연이는 너구리 같아서 속을 모르겠다',
174 | ],
175 | 다람쥐: [
176 | '다람쥐가 밤을 갉아 먹고 있어요',
177 | '덤불 사이에서 바스락하는 소리가 나더니 다람쥐 한 마리가 지나갔다',
178 | ],
179 | 고슴도치: [
180 | '고슴도치 등에는 뽀족한 가시가 돋쳐 있어요',
181 | '친구는 애완용으로 고슴도치를 키워요',
182 | ],
183 | 아들: [
184 | '저 아버지와 아들은 마치 형제처럼 보여요',
185 | '떡두꺼비같은 아들을 낳다',
186 | ],
187 | 딸: [
188 | '우리 집은 딸 둘, 아들 하나로 삼 남매입니다',
189 | '아버지는 딸을 끔찍하게 여깁니다',
190 | ],
191 | 어머니: [
192 | '자식을 위하는 어머니 마음',
193 | '저는 어머니랑 많이 닮았습니다',
194 | ],
195 | 아버지: [
196 | '갈릴레이는 자연 과학의 아버지라고 일컬어진다',
197 | '그는 아버지를 쏙 빼닮았다',
198 | ],
199 | 월요일: [
200 | '주말 잘 보내고 월요일에 만납시다',
201 | '매월 첫째 주 월요일은 휴업입니다',
202 | ],
203 | 화요일: [
204 | '우리 동네 목욕탕은 매월 첫째 주 화요일에 쉰다',
205 | '검사 결과는 다음주 화요일에 나올 것입니다',
206 | ],
207 | 금요일: [
208 | '금요일 오후에는 친구들과 약속이 있습니다',
209 | '금요일에는 언제든지 우리집에 놀러오세요',
210 | ],
211 | 토요일: [
212 | '다음 주 토요일은 결혼식을 하기에는 매우 좋은 날이다',
213 | '토요일은 오전 근무만 하면 됩니다',
214 | ],
215 | 일요일: [
216 | '남편은 일요일에도 회사에 출근했다',
217 | '오는 일요일이 내 생일이다',
218 | ],
219 | 어제: [
220 | '어제의 소년이 오늘에는 백발이 다 되었다',
221 | '어제는 열 시간을 잤다',
222 | ],
223 | 오늘: [
224 | '오늘이 첫 출근 날입니다',
225 | '오늘 해야 할 일을 다음 날로 미루어서는 안 된다',
226 | ],
227 | 내일: [
228 | '내일은 날씨가 좋을 것이다',
229 | '오늘 일을 내일로 미루지 말자',
230 | ],
231 | 비행기: [
232 | '이 비행기는 자동으로 조종된다',
233 | '오늘 광주로 가는 비행기를 탔다',
234 | ],
235 | 기차: [
236 | '기차가 철도 위를 달린다',
237 | '밤 기차 속의 풍경',
238 | ],
239 | 버스: [
240 | '버스를 타기 위해 정류장으로 갔다',
241 | '버스에 승객이 잔뜩 탔다',
242 | ],
243 | 자동차: [
244 | '자전거는 자동차 보다 느리다',
245 | '자동차 출발 전에 안전벨트를 매다',
246 | ],
247 | 택시: [
248 | '택시 요금의 할증 시간이 되었다',
249 | '그는 클길로 뛰어나가 택시를 잡았다',
250 | ],
251 | 학교: [
252 | '나는 학교에 가고 싶어요',
253 | '딸이 학교 앞에 책방을 냈어요',
254 | ],
255 | 집: [
256 | '우리 집은 서울에 있어요',
257 | '친구 집에서 며칠 묵었어요',
258 | ],
259 | 병원: [
260 | '어제는 병원에 친구 병문안을 갔어요',
261 | '우리 아이는 병원 가기를 무서워해요',
262 | ],
263 | 시장: [
264 | '우리 동네에 중고차 시장이 들어섰다',
265 | '아침에 바구니를 들고 시장에 갔다',
266 | ],
267 | 공원: [
268 | '공원에 운동하는 사람이 많아졌다',
269 | '한강 둔치가 공원으로 개발되었다',
270 | ],
271 | 우체국: [
272 | '우체국에 들러서 소포를 부쳤다',
273 | '우리 동네에 우체국이 새로 생겼다',
274 | ],
275 | 경찰서: [
276 | '집에 오는 길에 지갑을 주워서, 경찰서에 맡겼다',
277 | '주인은 경찰서에 도둑이 든 사실을 신고했다',
278 | ],
279 | 의사: [
280 | '아들의 장래희망은 의사입니다',
281 | '그는 의사의 권고로 담배를 끊었다',
282 | ],
283 | 선생님: [
284 | '언어치료 선생님께서 숙제를 내주셨다',
285 | '아들은 훌륭하신 선생님 밑에서 배웠다',
286 | ],
287 | 간호사: [
288 | '간호사가 온도계로 체온을 측정했다',
289 | '간호사가 붕대를 환자의 팔에 감았다',
290 | ],
291 | 소방관: [
292 | '소방관들이 열화 속으로 들어갔다',
293 | '저는 소방관 일에 보람을 느껴요',
294 | ],
295 | 경찰: [
296 | '저 사람은 경찰인 것 같다',
297 | '경찰은 수배된 범인을 검거했다',
298 | ],
299 | 군인: [
300 | '우리 남편은 군인입니다',
301 | '군인들이 발을 착착 맞추며 행진한다',
302 | ],
303 | 학생: [
304 | '학생 둘이 함께 걸어간다',
305 | '학생 하나가 손을 들었다',
306 | ],
307 | 행복: [
308 | '그녀는 행복한 나날을 보냈다',
309 | '사람들은 누구나 행복을 원한다',
310 | ],
311 | 영원: [
312 | '우리는 영원토록 함께하기로 했다',
313 | '그는 영원토록 살고 싶어했다',
314 | ],
315 | 사랑: [
316 | '그의 숲에 대한 사랑은 남다르다',
317 | '이루어질 수 없는 사랑',
318 | ],
319 | 우정: [
320 | '동료의 끈끈한 우정을 느끼다',
321 | '날이 갈수록 우리의 우정은 깊어만 갔다',
322 | ],
323 | 존경: [
324 | '그는 친구들에게 존경과 신뢰를 받았다',
325 | '학생들은 그 선생님을 존경했다',
326 | ],
327 | 믿음: [
328 | '이번 계획은 확실해서 믿음이 간다',
329 | '그녀는 믿음이 가는 친구다',
330 | ],
331 | 의자: [
332 | '이 의자는 높이가 조절된다',
333 | '의자가 푹신해서 앉기에 편하다',
334 | ],
335 | 서울: [
336 | '그는 원래 서울 사람이다',
337 | '동생은 시험을 보러 서울로 올라갔다',
338 | ],
339 | 대전: [
340 | '남편은 대전으로 출장을 갔다',
341 | '이번 전시회는 대전에서 열린다',
342 | ],
343 | 부산: [
344 | '이번 여름에 가족과 함께 부산에 갈 예정이다',
345 | '서울 부산 간 기차표를 끊었다',
346 | ],
347 | 미국: [
348 | '그는 세 살 때 미국으로 입양되었다',
349 | '미국은 다민족으로 이루어진 국가이다',
350 | ],
351 | 영국: [
352 | '딸은 영국에 살아서 영어를 잘한다',
353 | '아들은 영국으로 유학을 간다',
354 | ],
355 | 호주: [
356 | '호주에는 캥거루가 산다',
357 | '작년에 호주로 여행을 다녀왔다',
358 | ],
359 | 스페인: [
360 | '스페인에서는 많은 사람들이 투우를 즐긴다',
361 | '이 책은 스페인 어로 번역되어 출판되었다',
362 | ],
363 | 독일: [
364 | '남편은 독일어를 잘한다',
365 | '학교에서 독일어와 영어를 배운다',
366 | ],
367 | 유럽: [
368 | '우리 부부는 유럽으로 신혼여행을 다녀왔다',
369 | '대통령은 내일 유럽으로 출국할 예정이다',
370 | ],
371 | 피아노: [
372 | '그는 열정적으로 피아노를 연주했다',
373 | '그 피아노는 음색이 좋았다',
374 | ],
375 | 기타: [
376 | '아내는 기타를 잘 친다',
377 | '악기점에서 기타를 사왔다',
378 | ],
379 | 바이올린: [
380 | '어디선가 바이올린 소리가 들려온다',
381 | '바이올린과 첼로를 위한 합주곡',
382 | ],
383 | 풍금: [
384 | '아이들은 선생님의 풍금 소리에 맞춰 노래를 불렀다',
385 | '시골 교회의 풍금 소리가 정겹게 들린다',
386 | ],
387 | 세탁기: [
388 | '세탁기가 있어서 빨래하기가 쉽다',
389 | '세탁기에 가루 비누를 넣고 빨래했다',
390 | ],
391 | 냉장고: [
392 | '반찬을 냉장고에 넣어 두었다',
393 | '냉장고에서 시원한 음료수를 꺼내 마셨다',
394 | ],
395 | 라디오: [
396 | '라디오에서 아름다운 음악이 흘러나온다',
397 | '아내는 아들이 망가트린 라디오를 고치고 있다',
398 | ],
399 | 텔레비전: [
400 | '어제는 텔레비전을 보다가 잠이 들었다',
401 | '텔레비전에서 축구 경기를 방송하고 있다',
402 | ],
403 | 핸드폰: [
404 | '내가 오늘 핸드폰을 집에 두고 나왔지 뭐야',
405 | '영화관에서는 핸드폰을 꺼두는게 기본적인 에티켓이다',
406 | ],
407 | 노트북: [
408 | '노트북 키보드가 고장이 났다',
409 | '아들은 할부로 노트북을 구매했다',
410 | ],
411 | 영화: [
412 | '그 영화는 참 재미있다',
413 | '영화를 보러 극장에 갔다',
414 | ],
415 | 독서: [
416 | '가을은 독서하기에 좋은 계절이다',
417 | '아내는 독서를 많이 해서 똑똑하다',
418 | ],
419 | 책: [
420 | '책을 다 읽어 간다',
421 | '책상 위에 책이 있다',
422 | ],
423 | 책상: [
424 | '연필을 책상 위에 놓았다',
425 | '손걸레로 책상 위를 닦았다',
426 | ],
427 | 지갑: [
428 | '지갑 안에서 돈을 꺼냈다',
429 | '지갑을 주워 경찰서에 맡겼다',
430 | ],
431 | 가위: [
432 | '옷감을 가위로 잘랐다',
433 | '틀린것에 가위표를 치세요',
434 | ],
435 | 등산: [
436 | '나는 주말에 가족들과 등산이나 낚시를 즐긴다',
437 | '어제 등산을 했더니 다리가 아프다',
438 | ],
439 | 운동: [
440 | '오늘 아침에 운동을 했다',
441 | '운동화 끈이 자꾸 풀린다',
442 | ],
443 | 산책: [
444 | '요새는 날마다 산책하러 다닌다',
445 | '할머니가 어린 손자를 데리고 산책을 한다',
446 | ],
447 | 사진: [
448 | '실물보다 사진이 더 잘 나왔다',
449 | '책갈피에서 옛날 사진이 나왔다',
450 | ],
451 | 글씨: [
452 | '글씨가 큼직해서 읽기가 좋다',
453 | '공책에 연필로 글씨를 썼다',
454 | ],
455 | 우표: [
456 | '편지 봉투에 우표를 붙였다',
457 | '나의 취미는 우표 수집이다',
458 | ],
459 | 편지: [
460 | '어제는 친구에게 편지가 왔다',
461 | '할머니께 편지를 읽어드렸다',
462 | ],
463 | 수학: [
464 | '아들이 수학 시험에서 백점을 받았다',
465 | '수학은 기초가 단단해야 잘할 수 있다',
466 | ],
467 | 영어: [
468 | '국어와 영어는 어순이 다르다',
469 | '그는 영어 단어를 외웠다',
470 | ],
471 | 컴퓨터: [
472 | '컴퓨터 한 대를 새로 구입했다',
473 | '고장 난 컴퓨터를 고쳤다',
474 | ],
475 | 국어: [
476 | '아들은 중학교 국어 교사입니다',
477 | '아내는 국어학을 전공했다',
478 | ],
479 | 미술: [
480 | '나는 미술에는 소질이 없다',
481 | '이 미술관은 월요일마다 휴관한다',
482 | ],
483 | 치킨: [
484 | '어제, 치킨을 배달시켜 먹었다',
485 | '아이들 간식으로 치킨을 사주었다',
486 | ],
487 | 피자: [
488 | '오늘 점심은 피자를 시켜서 먹을까?',
489 | '피자를 주문하면 콜라를 무료로 드립니다',
490 | ],
491 | 탕수육: [
492 | '중국집에서 잡채밥 하나와 탕수육을 시켰다',
493 | '어머니는 탕수육을 직접 만들어 주셨다',
494 | ],
495 | 죽: [
496 | '아내는 호박죽을 좋아한다',
497 | '동짓날에 팥죽을 먹었다',
498 | ],
499 | };
500 |
501 | export default examples;
502 |
--------------------------------------------------------------------------------