Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions client/src/components/room/AnswerJudgeResult.fixture.ts
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions client/src/components/room/AnswerJudgeResult.story.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup lang="ts">
import AnswerJudgeResult from './AnswerJudgeResult.vue'
import { answerJudgeResultFixtures } from './AnswerJudgeResult.fixture'
</script>

<template>
<Story title="Room/AnswerJudgeResult">
<Variant title="判定待ち">
<div class="w-80 bg-[#eef4ff] p-4">
<AnswerJudgeResult v-bind="answerJudgeResultFixtures.idle" />
</div>
</Variant>

<Variant title="判定中">
<div class="w-80 bg-[#eef4ff] p-4">
<AnswerJudgeResult v-bind="answerJudgeResultFixtures.pending" />
</div>
</Variant>

<Variant title="正解">
<div class="w-80 bg-[#eef4ff] p-4">
<AnswerJudgeResult v-bind="answerJudgeResultFixtures.correct" />
</div>
</Variant>

<Variant title="不正解">
<div class="w-80 bg-[#eef4ff] p-4">
<AnswerJudgeResult v-bind="answerJudgeResultFixtures.incorrect" />
</div>
</Variant>

<Variant title="エラー">
<div class="w-80 bg-[#eef4ff] p-4">
<AnswerJudgeResult v-bind="answerJudgeResultFixtures.error" />
</div>
</Variant>
</Story>
</template>
85 changes: 85 additions & 0 deletions client/src/components/room/AnswerJudgeResult.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, useId } from 'vue'

type JudgeState = 'idle' | 'pending' | 'correct' | 'incorrect' | 'error'

interface AnswerJudgeResultProps {
state: JudgeState
}

const props = defineProps<AnswerJudgeResultProps>()
const titleId = useId()

const presentationByState = {
idle: {
label: '判定待ち',
description: '回答すると結果が表示されます',
symbol: '—',
badgeClasses: 'bg-[#eef3fa] text-[#52627a]',
},
pending: {
label: '判定中',
description: '回答を判定しています',
symbol: '…',
badgeClasses: 'bg-[#e7f0ff] text-[#2463d4]',
},
correct: {
label: '正解',
description: '回答は正解です',
symbol: '○',
badgeClasses: 'bg-[#ddf8e8] text-[#159447]',
},
incorrect: {
label: '不正解',
description: '回答は不正解です',
symbol: '×',
badgeClasses: 'bg-[#ffebec] text-[#d63844]',
},
error: {
label: '判定エラー',
description: '判定結果を取得できませんでした',
symbol: '!',
badgeClasses: 'bg-[#fff1da] text-[#a85c00]',
},
} satisfies Record<
JudgeState,
{
label: string
description: string
symbol: string
badgeClasses: string
}
>

const presentation = computed(() => presentationByState[props.state])
</script>

<template>
<section
class="flex min-h-72 w-full flex-col items-center rounded-xl border border-[#cbd8e9] bg-white px-6 py-5 text-[#152238] shadow-sm"
:aria-labelledby="titleId"
aria-live="polite"
aria-atomic="true"
:aria-busy="state === 'pending' ? 'true' : undefined"
:data-state="state"
>
<h2 :id="titleId" class="text-sm font-bold">判定結果</h2>

<div
class="mt-4 flex h-24 w-24 items-center justify-center rounded-full"
:class="presentation.badgeClasses"
data-testid="result-badge"
>
<span class="text-5xl font-bold leading-none" aria-hidden="true" data-testid="result-symbol">
{{ presentation.symbol }}
</span>
</div>

<p class="mt-3 text-base font-bold" data-testid="result-label">
{{ presentation.label }}
</p>
<p class="mt-1 text-center text-xs text-[#718099]">
{{ presentation.description }}
</p>
</section>
</template>
20 changes: 20 additions & 0 deletions client/src/components/room/AnswerPanel.fixture.ts
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions client/src/components/room/AnswerPanel.story.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup lang="ts">
import AnswerPanel from './AnswerPanel.vue'
import { answerPanelFixture } from './AnswerPanel.fixture'
</script>

<template>
<Story title="Room/AnswerPanel">
<Variant title="入力可能">
<AnswerPanel
:max-length="answerPanelFixture.maxLength"
:pending="false"
:disabled="false"
@submit="console.log('Submit answer:', $event)"
/>
</Variant>

<Variant title="送信中">
<AnswerPanel :max-length="answerPanelFixture.maxLength" :pending="true" :disabled="false" />
</Variant>

<Variant title="入力不可">
<AnswerPanel :max-length="answerPanelFixture.maxLength" :pending="false" :disabled="true" />
</Variant>
</Story>
</template>
89 changes: 89 additions & 0 deletions client/src/components/room/AnswerPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, ref, useId } from 'vue'

const props = defineProps<{
maxLength: number
pending: boolean
disabled: boolean
}>()

const emit = defineEmits<{
submit: [answer: string]
}>()

const answer = ref('')
const answerInputId = useId()
const helpId = `${answerInputId}-help`
const countId = `${answerInputId}-count`

const interactionDisabled = computed(() => props.pending || props.disabled)
const answerTooLong = computed(() => answer.value.length > props.maxLength)
const submitDisabled = computed(() => interactionDisabled.value || answerTooLong.value)

function submitAnswer() {
if (submitDisabled.value) return
emit('submit', answer.value)
}

function handleAnswerKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return

event.preventDefault()
if (event.repeat) return

submitAnswer()
}
</script>

<template>
<section
id="answer-panel"
class="rounded-xl border border-[#c8d5e8] bg-[#eef4ff] p-4 text-[#121a2a] sm:p-5"
aria-labelledby="answer-panel-title"
:aria-busy="pending"
>
<header class="mb-4">
<h2 id="answer-panel-title" class="text-base font-extrabold">回答パネル</h2>
</header>

<form
class="rounded-xl border border-[#c8d5e8] bg-white p-4 sm:p-5"
@submit.prevent="submitAnswer"
>
<label :for="answerInputId" class="mb-3 block text-sm font-bold">回答</label>
<textarea
:id="answerInputId"
v-model="answer"
name="answer"
rows="6"
:maxlength="maxLength"
:disabled="interactionDisabled"
:aria-describedby="`${helpId} ${countId}`"
class="min-h-36 w-full resize-y rounded-lg border border-[#7aa7ff] bg-[#fbfcff] px-4 py-3 text-sm text-[#121a2a] outline-none transition-shadow placeholder:text-[#8da0bd] focus:border-[#2e6bea] focus:ring-2 focus:ring-[#2e6bea]/20 disabled:cursor-not-allowed disabled:border-[#d5dce7] disabled:bg-[#f1f4f8] disabled:text-[#78869a]"
placeholder="答えを入力してください"
@keydown="handleAnswerKeydown"
/>

<div class="mt-2 flex flex-wrap items-start justify-between gap-2 text-xs text-[#65758d]">
<p :id="helpId">最大{{ maxLength }}文字。Enterで送信、Shift+Enterで改行します。</p>
<p :id="countId" :class="answerTooLong ? 'font-bold text-red-700' : ''">
<span class="sr-only">入力文字数 </span>
{{ answer.length }}/{{ maxLength }}
</p>
</div>

<div class="mt-4 flex items-center justify-between gap-4">
<p class="text-xs font-bold text-[#2764d8]" aria-live="polite">
{{ pending ? '送信中…' : disabled ? '入力できません' : '入力待ち' }}
</p>
<button
type="submit"
:disabled="submitDisabled"
class="inline-flex min-h-10 min-w-20 items-center justify-center rounded-lg bg-[#2864e8] px-5 text-sm font-bold text-white transition-colors hover:bg-[#1f56cc] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3997ea] disabled:cursor-not-allowed disabled:bg-[#a7b8d8]"
>
{{ pending ? '送信中…' : '送信' }}
</button>
</div>
</form>
</section>
</template>
68 changes: 68 additions & 0 deletions client/src/components/room/__tests__/AnswerJudgeResult.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading