diff --git a/client/src/components/room/AnswerJudgeResult.fixture.ts b/client/src/components/room/AnswerJudgeResult.fixture.ts new file mode 100644 index 0000000..a2ad442 --- /dev/null +++ b/client/src/components/room/AnswerJudgeResult.fixture.ts @@ -0,0 +1,35 @@ +import type { components } from '@/generated/api' +import { mockContract } from '@/mocks/data' + +type AnswerResponse = components['schemas']['AnswerResponse'] + +function getAnswerFixture( + example: 'incorrect_answer' | 'correct_answer_unlocks_problem', +): AnswerResponse { + return mockContract.getResponseExample('submitAnswer', 200, example) as AnswerResponse +} + +function judgeStateFromAnswer(response: AnswerResponse) { + return response.correct ? ('correct' as const) : ('incorrect' as const) +} + +const incorrectAnswer = getAnswerFixture('incorrect_answer') +const correctAnswer = getAnswerFixture('correct_answer_unlocks_problem') + +export const answerJudgeResultFixtures = { + idle: { + state: 'idle', + }, + pending: { + state: 'pending', + }, + correct: { + state: judgeStateFromAnswer(correctAnswer), + }, + incorrect: { + state: judgeStateFromAnswer(incorrectAnswer), + }, + error: { + state: 'error', + }, +} as const diff --git a/client/src/components/room/AnswerJudgeResult.story.vue b/client/src/components/room/AnswerJudgeResult.story.vue new file mode 100644 index 0000000..033410c --- /dev/null +++ b/client/src/components/room/AnswerJudgeResult.story.vue @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/components/room/AnswerJudgeResult.vue b/client/src/components/room/AnswerJudgeResult.vue new file mode 100644 index 0000000..ce20f4f --- /dev/null +++ b/client/src/components/room/AnswerJudgeResult.vue @@ -0,0 +1,85 @@ + + + + + 判定結果 + + + + {{ presentation.symbol }} + + + + + {{ presentation.label }} + + + {{ presentation.description }} + + + diff --git a/client/src/components/room/AnswerPanel.fixture.ts b/client/src/components/room/AnswerPanel.fixture.ts new file mode 100644 index 0000000..fb63d9d --- /dev/null +++ b/client/src/components/room/AnswerPanel.fixture.ts @@ -0,0 +1,20 @@ +import type { components } from '@/generated/api' +import { mockContract } from '@/mocks/data' + +type ProblemResponse = components['schemas']['ProblemResponse'] +type AnswerRequest = components['schemas']['AnswerRequest'] + +const problem = mockContract.getResponseExample( + 'getProblem', + 200, + 'available_problem', +) as ProblemResponse +const submittedAnswer = mockContract.getRequestExample( + 'submitAnswer', + 'submitted_answer', +) as AnswerRequest + +export const answerPanelFixture = { + maxLength: problem.input_schema.answer.max_length, + submittedAnswer: submittedAnswer.answer, +} as const diff --git a/client/src/components/room/AnswerPanel.story.vue b/client/src/components/room/AnswerPanel.story.vue new file mode 100644 index 0000000..f1b5676 --- /dev/null +++ b/client/src/components/room/AnswerPanel.story.vue @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/client/src/components/room/AnswerPanel.vue b/client/src/components/room/AnswerPanel.vue new file mode 100644 index 0000000..059d925 --- /dev/null +++ b/client/src/components/room/AnswerPanel.vue @@ -0,0 +1,89 @@ + + + + + + 回答パネル + + + + 回答 + + + + 最大{{ maxLength }}文字。Enterで送信、Shift+Enterで改行します。 + + 入力文字数 + {{ answer.length }}/{{ maxLength }} + + + + + + {{ pending ? '送信中…' : disabled ? '入力できません' : '入力待ち' }} + + + {{ pending ? '送信中…' : '送信' }} + + + + + diff --git a/client/src/components/room/__tests__/AnswerJudgeResult.spec.ts b/client/src/components/room/__tests__/AnswerJudgeResult.spec.ts new file mode 100644 index 0000000..6d553ce --- /dev/null +++ b/client/src/components/room/__tests__/AnswerJudgeResult.spec.ts @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import AnswerJudgeResult from '../AnswerJudgeResult.vue' +import { answerJudgeResultFixtures } from '../AnswerJudgeResult.fixture' + +describe('AnswerJudgeResult', () => { + it.each([ + ['idle', '判定待ち', '—', 'bg-[#eef3fa]'], + ['pending', '判定中', '…', 'bg-[#e7f0ff]'], + ['correct', '正解', '○', 'bg-[#ddf8e8]'], + ['incorrect', '不正解', '×', 'bg-[#ffebec]'], + ['error', '判定エラー', '!', 'bg-[#fff1da]'], + ] as const)('%sの文言、記号、配色を表示する', (state, label, symbol, colorClass) => { + const wrapper = mount(AnswerJudgeResult, { + props: { state }, + }) + + expect(wrapper.attributes('data-state')).toBe(state) + expect(wrapper.get('[data-testid="result-label"]').text()).toBe(label) + expect(wrapper.get('[data-testid="result-symbol"]').text()).toBe(symbol) + expect(wrapper.get('[data-testid="result-badge"]').classes()).toContain(colorClass) + expect(wrapper.attributes('aria-live')).toBe('polite') + expect(wrapper.emitted()).toEqual({}) + }) + + it('pendingの間だけbusyであることを通知する', async () => { + const wrapper = mount(AnswerJudgeResult, { + props: { state: 'idle' }, + }) + + expect(wrapper.attributes('aria-busy')).toBeUndefined() + + await wrapper.setProps({ state: 'pending' }) + + expect(wrapper.attributes('aria-busy')).toBe('true') + + await wrapper.setProps({ state: 'correct' }) + + expect(wrapper.attributes('aria-busy')).toBeUndefined() + }) + + it('errorとincorrectを文言で区別して通知する', () => { + const error = mount(AnswerJudgeResult, { + props: { state: 'error' }, + }) + const incorrect = mount(AnswerJudgeResult, { + props: { state: 'incorrect' }, + }) + + expect(error.attributes('aria-live')).toBe('polite') + expect(error.text()).toContain('判定結果を取得できませんでした') + expect(incorrect.attributes('aria-live')).toBe('polite') + expect(incorrect.text()).toContain('回答は不正解です') + }) + + it('共有answer fixtureをcorrectとincorrectの判定stateへ写像する', async () => { + const wrapper = mount(AnswerJudgeResult, { + props: answerJudgeResultFixtures.incorrect, + }) + + expect(wrapper.attributes('data-state')).toBe('incorrect') + + await wrapper.setProps({ ...answerJudgeResultFixtures.correct }) + + expect(wrapper.attributes('data-state')).toBe('correct') + }) +}) diff --git a/client/src/components/room/__tests__/AnswerPanel.spec.ts b/client/src/components/room/__tests__/AnswerPanel.spec.ts new file mode 100644 index 0000000..9cc4e63 --- /dev/null +++ b/client/src/components/room/__tests__/AnswerPanel.spec.ts @@ -0,0 +1,154 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import AnswerPanel from '../AnswerPanel.vue' +import { answerPanelFixture } from '../AnswerPanel.fixture' + +const defaultProps = { + maxLength: answerPanelFixture.maxLength, + pending: false, + disabled: false, +} + +describe('AnswerPanel', () => { + it('always renders an accessible input with the fixture maximum length', () => { + const wrapper = mount(AnswerPanel, { props: defaultProps }) + const textarea = wrapper.get('textarea[name="answer"]') + const label = wrapper.get('label') + + expect(wrapper.get('section').attributes('id')).toBe('answer-panel') + expect(label.attributes('for')).toBe(textarea.attributes('id')) + expect(textarea.attributes('maxlength')).toBe(String(answerPanelFixture.maxLength)) + expect(wrapper.text()).toContain(`最大${answerPanelFixture.maxLength}文字`) + }) + + it('emits the unchanged fixture answer exactly once through form submission', async () => { + const wrapper = mount(AnswerPanel, { props: defaultProps }) + + await wrapper.get('textarea').setValue(answerPanelFixture.submittedAnswer) + await wrapper.get('form').trigger('submit') + + expect(wrapper.emitted('submit')).toEqual([[answerPanelFixture.submittedAnswer]]) + }) + + it('emits the answer exactly once when the submit button is clicked', async () => { + const wrapper = mount(AnswerPanel, { props: defaultProps, attachTo: document.body }) + const submitButton = wrapper.get('button[type="submit"]') + + await wrapper.get('textarea').setValue(answerPanelFixture.submittedAnswer) + submitButton.element.click() + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('submit')).toEqual([[answerPanelFixture.submittedAnswer]]) + + wrapper.unmount() + }) + + it('preserves whitespace and allows an empty answer defined by the transport schema', async () => { + const wrapper = mount(AnswerPanel, { props: defaultProps }) + const textarea = wrapper.get('textarea') + + await textarea.setValue(' answer\n') + await wrapper.get('form').trigger('submit') + await textarea.setValue('') + await wrapper.get('form').trigger('submit') + + expect(wrapper.emitted('submit')).toEqual([[' answer\n'], ['']]) + }) + + it('submits once on Enter and ignores repeated Enter presses', async () => { + const wrapper = mount(AnswerPanel, { props: defaultProps }) + const textarea = wrapper.get('textarea') + + await textarea.setValue(answerPanelFixture.submittedAnswer) + const enter = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }) + const repeatedEnter = new KeyboardEvent('keydown', { + key: 'Enter', + repeat: true, + bubbles: true, + cancelable: true, + }) + textarea.element.dispatchEvent(enter) + textarea.element.dispatchEvent(repeatedEnter) + + expect(wrapper.emitted('submit')).toEqual([[answerPanelFixture.submittedAnswer]]) + expect(enter.defaultPrevented).toBe(true) + expect(repeatedEnter.defaultPrevented).toBe(true) + }) + + it('does not submit on Shift+Enter or while an IME composition is active', async () => { + const wrapper = mount(AnswerPanel, { props: defaultProps }) + const textarea = wrapper.get('textarea') + + await textarea.setValue(answerPanelFixture.submittedAnswer) + const shiftedEnter = new KeyboardEvent('keydown', { + key: 'Enter', + shiftKey: true, + bubbles: true, + cancelable: true, + }) + const composingEnter = new KeyboardEvent('keydown', { + key: 'Enter', + isComposing: true, + bubbles: true, + cancelable: true, + }) + textarea.element.dispatchEvent(shiftedEnter) + textarea.element.dispatchEvent(composingEnter) + + expect(wrapper.emitted('submit')).toBeUndefined() + expect(shiftedEnter.defaultPrevented).toBe(false) + expect(composingEnter.defaultPrevented).toBe(false) + }) + + it.each([ + { pending: true, disabled: false }, + { pending: false, disabled: true }, + ])('blocks input and submission while unavailable: %o', async (props) => { + const wrapper = mount(AnswerPanel, { + props: { ...defaultProps, ...props }, + }) + const textarea = wrapper.get('textarea') + + expect(textarea.attributes('disabled')).toBeDefined() + expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined() + + await wrapper.get('form').trigger('submit') + await textarea.trigger('keydown', { key: 'Enter' }) + + expect(wrapper.emitted('submit')).toBeUndefined() + }) + + it('keeps the draft while pending changes', async () => { + const wrapper = mount(AnswerPanel, { props: defaultProps }) + + await wrapper.get('textarea').setValue(answerPanelFixture.submittedAnswer) + await wrapper.setProps({ pending: true }) + expect(wrapper.get('textarea').element.value).toBe(answerPanelFixture.submittedAnswer) + + await wrapper.setProps({ pending: false }) + + expect(wrapper.get('textarea').element.value).toBe(answerPanelFixture.submittedAnswer) + }) + + it('accepts the exact maximum and rejects a programmatic value over it', async () => { + const wrapper = mount(AnswerPanel, { props: defaultProps }) + const textarea = wrapper.get('textarea') + + const maximumAnswer = 'x'.repeat(answerPanelFixture.maxLength) + await textarea.setValue(maximumAnswer) + await wrapper.get('form').trigger('submit') + + expect(wrapper.emitted('submit')).toEqual([[maximumAnswer]]) + + await textarea.setValue('x'.repeat(answerPanelFixture.maxLength + 1)) + await wrapper.get('form').trigger('submit') + + expect(wrapper.emitted('submit')).toEqual([[maximumAnswer]]) + expect(wrapper.get('button[type="submit"]').attributes('disabled')).toBeDefined() + }) +})
+ {{ presentation.label }} +
+ {{ presentation.description }} +
最大{{ maxLength }}文字。Enterで送信、Shift+Enterで改行します。
+ 入力文字数 + {{ answer.length }}/{{ maxLength }} +
+ {{ pending ? '送信中…' : disabled ? '入力できません' : '入力待ち' }} +