From 0a13c00f37add5396dd9c56317c4e444e9e6a882 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 13 May 2026 20:42:25 +0900 Subject: [PATCH] #155 --- frontend/src/components/ErrorScreen.test.tsx | 32 +++++ .../src/components/MenuSeparator.test.tsx | 13 ++ frontend/src/components/PostEditForm.test.tsx | 69 +++++++++++ frontend/src/components/PostEmbed.test.tsx | 63 ++++++++++ .../src/components/PostFormTagsArea.test.tsx | 34 +++++ frontend/src/components/PostList.test.tsx | 34 +++++ .../PostOriginalCreatedTimeField.test.tsx | 63 ++++++++++ .../components/RouteBlockerOverlay.test.tsx | 30 +++++ frontend/src/components/SortHeader.test.tsx | 39 ++++++ frontend/src/components/TagLink.test.tsx | 45 +++++++ frontend/src/components/TagSearchBox.test.tsx | 30 +++++ frontend/src/components/TopNavUser.test.tsx | 29 +++++ frontend/src/components/TwitterEmbed.test.tsx | 19 +++ .../components/common/DateTimeField.test.tsx | 27 ++++ frontend/src/components/common/Label.test.tsx | 26 ++++ .../src/components/common/Pagination.test.tsx | 38 ++++++ .../src/components/common/TabGroup.test.tsx | 23 ++++ .../src/components/common/TagInput.test.tsx | 44 +++++++ .../common/TypographyAndForm.test.tsx | 39 ++++++ frontend/src/lib/api.test.ts | 96 ++++++++++++++ frontend/src/lib/posts.test.ts | 117 ++++++++++++++++++ frontend/src/lib/prefetchers.test.ts | 112 +++++++++++++++++ frontend/src/lib/queryKeys.test.ts | 14 +++ frontend/src/lib/remark-wiki-autolink.test.ts | 68 ++++++++++ frontend/src/lib/tags.test.ts | 67 ++++++++++ frontend/src/lib/utils.test.ts | 28 +++++ frontend/src/lib/wiki.test.ts | 48 +++++++ .../pages/materials/MaterialBasePage.test.tsx | 26 ++++ .../materials/MaterialDetailPage.test.tsx | 86 +++++++++++++ .../pages/materials/MaterialListPage.test.tsx | 62 ++++++++++ .../pages/materials/MaterialNewPage.test.tsx | 38 ++++++ .../src/pages/posts/PostDetailPage.test.tsx | 109 ++++++++++++++++ frontend/src/pages/posts/PostDetailPage.tsx | 12 +- .../src/pages/posts/PostListPage.test.tsx | 74 +++++++++++ frontend/src/pages/posts/PostNewPage.test.tsx | 58 +++++++++ .../src/pages/posts/PostSearchPage.test.tsx | 84 +++++++++++++ .../src/pages/tags/TagDetailPage.test.tsx | 71 +++++++++++ frontend/src/pages/tags/TagListPage.test.tsx | 71 +++++++++++ frontend/src/pages/users/SettingPage.test.tsx | 54 ++++++++ frontend/src/pages/wiki/WikiDiffPage.test.tsx | 45 +++++++ frontend/src/pages/wiki/WikiEditPage.test.tsx | 82 ++++++++++++ frontend/src/pages/wiki/WikiNewPage.test.tsx | 61 +++++++++ .../src/pages/wiki/WikiSearchPage.test.tsx | 45 +++++++ .../src/stores/sharedTransitionStore.test.ts | 23 ++++ frontend/src/test/coverage.todo.test.ts | 9 ++ frontend/src/test/factories.ts | 74 +++++++++++ frontend/src/test/render.tsx | 35 ++++++ frontend/src/test/setup.ts | 19 +++ 48 files changed, 2378 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/ErrorScreen.test.tsx create mode 100644 frontend/src/components/MenuSeparator.test.tsx create mode 100644 frontend/src/components/PostEditForm.test.tsx create mode 100644 frontend/src/components/PostEmbed.test.tsx create mode 100644 frontend/src/components/PostFormTagsArea.test.tsx create mode 100644 frontend/src/components/PostList.test.tsx create mode 100644 frontend/src/components/PostOriginalCreatedTimeField.test.tsx create mode 100644 frontend/src/components/RouteBlockerOverlay.test.tsx create mode 100644 frontend/src/components/SortHeader.test.tsx create mode 100644 frontend/src/components/TagLink.test.tsx create mode 100644 frontend/src/components/TagSearchBox.test.tsx create mode 100644 frontend/src/components/TopNavUser.test.tsx create mode 100644 frontend/src/components/TwitterEmbed.test.tsx create mode 100644 frontend/src/components/common/DateTimeField.test.tsx create mode 100644 frontend/src/components/common/Label.test.tsx create mode 100644 frontend/src/components/common/Pagination.test.tsx create mode 100644 frontend/src/components/common/TabGroup.test.tsx create mode 100644 frontend/src/components/common/TagInput.test.tsx create mode 100644 frontend/src/components/common/TypographyAndForm.test.tsx create mode 100644 frontend/src/lib/api.test.ts create mode 100644 frontend/src/lib/posts.test.ts create mode 100644 frontend/src/lib/prefetchers.test.ts create mode 100644 frontend/src/lib/queryKeys.test.ts create mode 100644 frontend/src/lib/remark-wiki-autolink.test.ts create mode 100644 frontend/src/lib/tags.test.ts create mode 100644 frontend/src/lib/utils.test.ts create mode 100644 frontend/src/lib/wiki.test.ts create mode 100644 frontend/src/pages/materials/MaterialBasePage.test.tsx create mode 100644 frontend/src/pages/materials/MaterialDetailPage.test.tsx create mode 100644 frontend/src/pages/materials/MaterialListPage.test.tsx create mode 100644 frontend/src/pages/materials/MaterialNewPage.test.tsx create mode 100644 frontend/src/pages/posts/PostDetailPage.test.tsx create mode 100644 frontend/src/pages/posts/PostListPage.test.tsx create mode 100644 frontend/src/pages/posts/PostNewPage.test.tsx create mode 100644 frontend/src/pages/posts/PostSearchPage.test.tsx create mode 100644 frontend/src/pages/tags/TagDetailPage.test.tsx create mode 100644 frontend/src/pages/tags/TagListPage.test.tsx create mode 100644 frontend/src/pages/users/SettingPage.test.tsx create mode 100644 frontend/src/pages/wiki/WikiDiffPage.test.tsx create mode 100644 frontend/src/pages/wiki/WikiEditPage.test.tsx create mode 100644 frontend/src/pages/wiki/WikiNewPage.test.tsx create mode 100644 frontend/src/pages/wiki/WikiSearchPage.test.tsx create mode 100644 frontend/src/stores/sharedTransitionStore.test.ts create mode 100644 frontend/src/test/coverage.todo.test.ts create mode 100644 frontend/src/test/factories.ts create mode 100644 frontend/src/test/render.tsx diff --git a/frontend/src/components/ErrorScreen.test.tsx b/frontend/src/components/ErrorScreen.test.tsx new file mode 100644 index 0000000..2f3bca3 --- /dev/null +++ b/frontend/src/components/ErrorScreen.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react' +import { HelmetProvider } from 'react-helmet-async' +import { describe, expect, it } from 'vitest' + +import ErrorScreen from '@/components/ErrorScreen' + +describe ('ErrorScreen', () => { + it.each ([ + [403, '権限ないよ(笑)'], + [404, 'ページないよ(笑)'], + [500, '鯖でエラー出たって(嘲笑)'], + [503, '鯖死んでるよ(泣)'], + ]) ('renders status %s', (status, message) => { + render ( + + + , + ) + + expect (screen.getByText (String (status))).toBeInTheDocument () + expect (screen.getByText (message)).toBeInTheDocument () + expect (screen.getByAltText ('逃げたギター')).toBeInTheDocument () + }) + + it ('throws for unsupported statuses', () => { + expect (() => render ( + + + , + )).toThrow () + }) +}) diff --git a/frontend/src/components/MenuSeparator.test.tsx b/frontend/src/components/MenuSeparator.test.tsx new file mode 100644 index 0000000..9032f91 --- /dev/null +++ b/frontend/src/components/MenuSeparator.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import MenuSeparator from '@/components/MenuSeparator' + +describe ('MenuSeparator', () => { + it ('renders desktop and mobile separators', () => { + render () + + expect (screen.getByText ('|')).toHaveClass ('md:inline') + expect (document.querySelector ('hr')).toHaveClass ('md:hidden') + }) +}) diff --git a/frontend/src/components/PostEditForm.test.tsx b/frontend/src/components/PostEditForm.test.tsx new file mode 100644 index 0000000..1fc58a9 --- /dev/null +++ b/frontend/src/components/PostEditForm.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import PostEditForm from '@/components/PostEditForm' +import { buildPost, buildTag } from '@/test/factories' + +const postsApi = vi.hoisted (() => ({ + updatePost: vi.fn (), +})) + +const api = vi.hoisted (() => ({ + isApiError: vi.fn (() => false), +})) + +const toastApi = vi.hoisted (() => ({ + toast: vi.fn (), +})) + +vi.mock ('@/lib/posts', () => postsApi) +vi.mock ('@/lib/api', () => api) +vi.mock ('@/components/ui/use-toast', () => toastApi) +vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + useDialogue: () => ({ + choice: vi.fn (), + }), +})) + +describe ('PostEditForm', () => { + it ('submits edited post fields with the current base version', async () => { + const onSave = vi.fn () + const post = buildPost ({ + id: 8, + versionNo: 4, + title: 'old', + tags: [ + buildTag ({ name: 'general-tag', category: 'general' }), + buildTag ({ id: 2, name: 'nico-tag', category: 'nico' }), + ], + parentPosts: [buildPost ({ id: 2, title: 'parent' })], + }) + postsApi.updatePost.mockResolvedValueOnce ({ + ...post, + versionNo: 5, + title: 'new', + tags: [buildTag ({ name: 'new-tag' })], + }) + + render () + + const [title, parentIds] = screen.getAllByRole ('textbox') + fireEvent.change (title, { target: { value: 'new' } }) + fireEvent.change (parentIds, { target: { value: '3 4' } }) + fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!) + + await waitFor (() => { + expect (postsApi.updatePost).toHaveBeenCalledWith ( + expect.objectContaining ({ + id: 8, + title: 'new', + parentPostIds: '3 4', + tags: 'general-tag', + }), + { baseVersionNo: 4 }, + ) + }) + expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) + expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) + }) +}) diff --git a/frontend/src/components/PostEmbed.test.tsx b/frontend/src/components/PostEmbed.test.tsx new file mode 100644 index 0000000..e34713e --- /dev/null +++ b/frontend/src/components/PostEmbed.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import PostEmbed from '@/components/PostEmbed' +import { buildPost } from '@/test/factories' + +const dialogue = vi.hoisted (() => ({ + confirm: vi.fn (), +})) + +vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + useDialogue: () => dialogue, +})) + +vi.mock ('@/components/NicoViewer', () => ({ + default: ({ id }: { id: string }) =>
Nico:{id}
, +})) + +vi.mock ('react-youtube', () => ({ + default: ({ videoId }: { videoId: string }) =>
YouTube:{videoId}
, +})) + +describe ('PostEmbed', () => { + beforeEach (() => { + vi.clearAllMocks () + }) + + it ('embeds nicovideo watch URLs', () => { + render () + + expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument () + }) + + it ('embeds x/twitter status URLs', () => { + render () + + expect (screen.getByRole ('link', { name: '@someone' })).toBeInTheDocument () + }) + + it ('embeds youtube watch URLs', () => { + render () + + expect (screen.getByText ('YouTube:abc123')).toBeInTheDocument () + }) + + it ('asks before framing unknown external pages', async () => { + dialogue.confirm.mockResolvedValueOnce (true) + render ( + , + ) + + fireEvent.click (screen.getByRole ('link', { name: '外部ページを表示' })) + + await waitFor (() => { + expect (dialogue.confirm).toHaveBeenCalled () + }) + expect (await screen.findByTitle ('external')).toHaveAttribute ( + 'src', + 'https://example.com/page', + ) + }) +}) diff --git a/frontend/src/components/PostFormTagsArea.test.tsx b/frontend/src/components/PostFormTagsArea.test.tsx new file mode 100644 index 0000000..b9586db --- /dev/null +++ b/frontend/src/components/PostFormTagsArea.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import PostFormTagsArea from '@/components/PostFormTagsArea' +import { buildTag } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +const api = vi.hoisted (() => ({ + apiGet: vi.fn (), +})) + +vi.mock ('@/lib/api', () => api) + +describe ('PostFormTagsArea', () => { + it ('updates text and fetches autocomplete for the selected token', async () => { + const setTags = vi.fn () + api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })]) + + renderWithProviders () + + const textarea = screen.getByRole ('textbox') + fireEvent.focus (textarea) + fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } }) + fireEvent.change (textarea, { target: { value: '虹夏' } }) + + await waitFor (() => { + expect (api.apiGet).toHaveBeenCalledWith ( + '/tags/autocomplete', + { params: { q: '虹', nico: '0' } }, + ) + }) + expect (setTags).toHaveBeenCalledWith ('虹夏') + }) +}) diff --git a/frontend/src/components/PostList.test.tsx b/frontend/src/components/PostList.test.tsx new file mode 100644 index 0000000..ccb6340 --- /dev/null +++ b/frontend/src/components/PostList.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import PostList from '@/components/PostList' +import { buildPost } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +describe ('PostList', () => { + it ('renders post thumbnails as links to post details', () => { + renderWithProviders ( + , + ) + + expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute ( + 'href', + '/posts/1', + ) + expect ( + screen.getByRole ('link', { name: 'https://example.com/second' }), + ).toHaveAttribute ('href', '/posts/2') + }) + + it ('calls the optional click handler', () => { + const onClick = vi.fn () + renderWithProviders () + + fireEvent.click (screen.getByRole ('link', { name: 'テスト投稿' })) + + expect (onClick).toHaveBeenCalledTimes (1) + }) +}) diff --git a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx new file mode 100644 index 0000000..a061d65 --- /dev/null +++ b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' + +describe ('PostOriginalCreatedTimeField', () => { + it ('updates from and before values', () => { + const setFrom = vi.fn () + const setBefore = vi.fn () + + render ( + , + ) + + const inputs = screen.getAllByDisplayValue ('') + fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } }) + fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } }) + + expect (setFrom).toHaveBeenCalledWith (expect.any (String)) + expect (setBefore).toHaveBeenCalledWith (expect.any (String)) + }) + + it ('infers an exclusive before value on blur', () => { + const setBefore = vi.fn () + + render ( + , + ) + + const input = screen.getAllByDisplayValue ('')[0] + fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } }) + + expect (setBefore).toHaveBeenCalledWith (expect.any (String)) + }) + + it ('resets both values', () => { + const setFrom = vi.fn () + const setBefore = vi.fn () + + render ( + , + ) + + const buttons = screen.getAllByRole ('button', { name: 'リセット' }) + fireEvent.click (buttons[0]) + fireEvent.click (buttons[1]) + + expect (setFrom).toHaveBeenCalledWith (null) + expect (setBefore).toHaveBeenCalledWith (null) + }) +}) diff --git a/frontend/src/components/RouteBlockerOverlay.test.tsx b/frontend/src/components/RouteBlockerOverlay.test.tsx new file mode 100644 index 0000000..1219e5b --- /dev/null +++ b/frontend/src/components/RouteBlockerOverlay.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import RouteBlockerOverlay, { useOverlayStore } from '@/components/RouteBlockerOverlay' + +describe ('RouteBlockerOverlay', () => { + afterEach (() => { + useOverlayStore.setState ({ active: false }) + document.body.style.overflow = '' + document.body.removeAttribute ('aria-busy') + }) + + it ('renders nothing while inactive', () => { + useOverlayStore.setState ({ active: false }) + + const { container } = render () + + expect (container).toBeEmptyDOMElement () + }) + + it ('renders a blocking progressbar and marks the body busy while active', () => { + useOverlayStore.setState ({ active: true }) + + render () + + expect (screen.getByRole ('progressbar', { name: 'Loading' })).toBeInTheDocument () + expect (document.body).toHaveAttribute ('aria-busy', 'true') + expect (document.body.style.overflow).toBe ('hidden') + }) +}) diff --git a/frontend/src/components/SortHeader.test.tsx b/frontend/src/components/SortHeader.test.tsx new file mode 100644 index 0000000..f11b93e --- /dev/null +++ b/frontend/src/components/SortHeader.test.tsx @@ -0,0 +1,39 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import SortHeader from '@/components/SortHeader' +import { renderWithProviders } from '@/test/render' + +describe ('SortHeader', () => { + it ('toggles the active sort direction and resets the page', () => { + renderWithProviders ( + , + { route: '/posts?tags=x&page=4&order=title%3Aasc' }, + ) + + expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute ( + 'href', + '/posts?tags=x&page=1&order=title%3Adesc', + ) + }) + + it ('uses default direction for inactive fields', () => { + renderWithProviders ( + , + { route: '/posts?page=2' }, + ) + + expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute ( + 'href', + '/posts?page=1&order=updated_at%3Adesc', + ) + }) +}) diff --git a/frontend/src/components/TagLink.test.tsx b/frontend/src/components/TagLink.test.tsx new file mode 100644 index 0000000..3c2a152 --- /dev/null +++ b/frontend/src/components/TagLink.test.tsx @@ -0,0 +1,45 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import TagLink from '@/components/TagLink' +import { buildTag } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +describe ('TagLink', () => { + it ('links tag names to post search and shows counts', () => { + renderWithProviders ( + , + ) + + expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute ( + 'href', + '/posts?tags=%E8%99%B9+%E5%A4%8F', + ) + expect (screen.getByText ('4')).toBeInTheDocument () + }) + + it ('links wiki markers to the correct detail route', () => { + renderWithProviders ( + , + ) + + expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute ( + 'href', + '/wiki/a%2Fb', + ) + }) + + it ('renders aliases and non-link tags when requested', () => { + renderWithProviders ( + , + ) + + expect (screen.getByText ('別名')).toBeInTheDocument () + expect (screen.getByText ('正式名')).toBeInTheDocument () + expect (screen.queryByRole ('link')).not.toBeInTheDocument () + }) +}) diff --git a/frontend/src/components/TagSearchBox.test.tsx b/frontend/src/components/TagSearchBox.test.tsx new file mode 100644 index 0000000..5b77518 --- /dev/null +++ b/frontend/src/components/TagSearchBox.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import TagSearchBox from '@/components/TagSearchBox' +import { buildTag } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +describe ('TagSearchBox', () => { + it ('renders suggestions and selects tags on mouse down', () => { + const handleSelect = vi.fn () + const tag = buildTag ({ id: 9, name: '候補', postCount: 2 }) + + renderWithProviders ( + , + ) + + fireEvent.mouseDown (screen.getByText ('候補')) + + expect (handleSelect).toHaveBeenCalledWith (tag) + expect (screen.getByText ('2')).toBeInTheDocument () + }) + + it ('renders nothing when suggestions are empty', () => { + const { container } = renderWithProviders ( + , + ) + + expect (container).toBeEmptyDOMElement () + }) +}) diff --git a/frontend/src/components/TopNavUser.test.tsx b/frontend/src/components/TopNavUser.test.tsx new file mode 100644 index 0000000..49e1ee2 --- /dev/null +++ b/frontend/src/components/TopNavUser.test.tsx @@ -0,0 +1,29 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import TopNavUser from '@/components/TopNavUser' +import { buildUser } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +describe ('TopNavUser', () => { + it ('renders nothing without a user', () => { + const { container } = renderWithProviders () + + expect (container).toBeEmptyDOMElement () + }) + + it ('links named users to settings', () => { + renderWithProviders () + + expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute ( + 'href', + '/users/settings', + ) + }) + + it ('uses the anonymous display name', () => { + renderWithProviders () + + expect (screen.getByRole ('link', { name: '名もなきニジラー' })).toBeInTheDocument () + }) +}) diff --git a/frontend/src/components/TwitterEmbed.test.tsx b/frontend/src/components/TwitterEmbed.test.tsx new file mode 100644 index 0000000..25f2c27 --- /dev/null +++ b/frontend/src/components/TwitterEmbed.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import TwitterEmbed from '@/components/TwitterEmbed' + +describe ('TwitterEmbed', () => { + it ('renders tweet and user links', () => { + render () + + expect (screen.getByRole ('link', { name: '@user_name' })).toHaveAttribute ( + 'href', + 'https://twitter.com/user_name?ref_src=twsrc%3Etfw', + ) + expect (screen.getByRole ('link', { name: /\d/ })).toHaveAttribute ( + 'href', + 'https://twitter.com/user_name/status/12345?ref_src=twsrc%5Etfw', + ) + }) +}) diff --git a/frontend/src/components/common/DateTimeField.test.tsx b/frontend/src/components/common/DateTimeField.test.tsx new file mode 100644 index 0000000..19605c7 --- /dev/null +++ b/frontend/src/components/common/DateTimeField.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import DateTimeField from '@/components/common/DateTimeField' + +describe ('DateTimeField', () => { + it ('renders an ISO value as a datetime-local value', () => { + render () + + const input = screen.getByLabelText ('日時') + + expect (input).toHaveValue ('2026-01-02T12:04') + }) + + it ('reports local changes as ISO strings and empty values as null', () => { + const handleChange = vi.fn () + render () + + const input = screen.getByLabelText ('日時') + fireEvent.change (input, { target: { value: '2026-01-02T03:04' } }) + fireEvent.change (input, { target: { value: '' } }) + + const first = handleChange.mock.calls[0]?.[0] + expect (new Date (first).getFullYear ()).toBe (2026) + expect (handleChange).toHaveBeenLastCalledWith (null) + }) +}) diff --git a/frontend/src/components/common/Label.test.tsx b/frontend/src/components/common/Label.test.tsx new file mode 100644 index 0000000..c7a5666 --- /dev/null +++ b/frontend/src/components/common/Label.test.tsx @@ -0,0 +1,26 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import Label from '@/components/common/Label' + +describe ('Label', () => { + it ('renders a plain label', () => { + render () + + expect (screen.getByText ('名前')).toBeInTheDocument () + }) + + it ('renders and toggles the optional checkbox', () => { + const handleChange = vi.fn () + + render ( + , + ) + + fireEvent.click (screen.getByRole ('checkbox', { name: '不明' })) + + expect (handleChange).toHaveBeenCalledTimes (1) + }) +}) diff --git a/frontend/src/components/common/Pagination.test.tsx b/frontend/src/components/common/Pagination.test.tsx new file mode 100644 index 0000000..d5a659b --- /dev/null +++ b/frontend/src/components/common/Pagination.test.tsx @@ -0,0 +1,38 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import Pagination from '@/components/common/Pagination' +import { renderWithProviders } from '@/test/render' + +describe ('Pagination', () => { + it ('builds page links while preserving existing query parameters', () => { + renderWithProviders ( + , + { route: '/posts?tags=abc&page=3' }, + ) + + expect (screen.getByLabelText ('前のページ')).toHaveAttribute ( + 'href', + '/posts?tags=abc&page=2', + ) + expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( + 'href', + '/posts?tags=abc&page=4', + ) + expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page') + }) + + it ('does not render active previous and next controls at the edges', () => { + const { rerender } = renderWithProviders ( + , + { route: '/tags' }, + ) + + expect (screen.queryByLabelText ('前のページ')).not.toBeInTheDocument () + expect (screen.queryByLabelText ('次のページ')).not.toBeInTheDocument () + + rerender () + + expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2') + }) +}) diff --git a/frontend/src/components/common/TabGroup.test.tsx b/frontend/src/components/common/TabGroup.test.tsx new file mode 100644 index 0000000..424be1c --- /dev/null +++ b/frontend/src/components/common/TabGroup.test.tsx @@ -0,0 +1,23 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import TabGroup, { Tab } from '@/components/common/TabGroup' + +describe ('TabGroup', () => { + it ('uses the init tab and switches tabs when clicked', () => { + render ( + + Alpha + Beta + , + ) + + expect (screen.queryByText ('Alpha')).not.toBeInTheDocument () + expect (screen.getByText ('Beta')).toBeInTheDocument () + + fireEvent.click (screen.getByText ('A')) + + expect (screen.getByText ('Alpha')).toBeInTheDocument () + expect (screen.queryByText ('Beta')).not.toBeInTheDocument () + }) +}) diff --git a/frontend/src/components/common/TagInput.test.tsx b/frontend/src/components/common/TagInput.test.tsx new file mode 100644 index 0000000..dffc950 --- /dev/null +++ b/frontend/src/components/common/TagInput.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import TagInput from '@/components/common/TagInput' +import { buildTag } from '@/test/factories' + +const api = vi.hoisted (() => ({ + apiGet: vi.fn (), +})) + +vi.mock ('@/lib/api', () => api) + +describe ('TagInput', () => { + beforeEach (() => { + vi.clearAllMocks () + }) + + it ('updates value and fetches autocomplete for the last token', async () => { + const setValue = vi.fn () + api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })]) + + render () + + fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } }) + + await waitFor (() => { + expect (api.apiGet).toHaveBeenCalledWith ( + '/tags/autocomplete', + { params: { q: '虹夏' } }, + ) + }) + expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') + }) + + it ('does not fetch when the last token is blank', () => { + const setValue = vi.fn () + render () + + fireEvent.change (screen.getByRole ('textbox'), { target: { value: ' ' } }) + + expect (api.apiGet).not.toHaveBeenCalled () + expect (setValue).toHaveBeenCalledWith (' ') + }) +}) diff --git a/frontend/src/components/common/TypographyAndForm.test.tsx b/frontend/src/components/common/TypographyAndForm.test.tsx new file mode 100644 index 0000000..b94e41c --- /dev/null +++ b/frontend/src/components/common/TypographyAndForm.test.tsx @@ -0,0 +1,39 @@ +import { createRef } from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import Form from '@/components/common/Form' +import SectionTitle from '@/components/common/SectionTitle' +import SubsectionTitle from '@/components/common/SubsectionTitle' +import TextArea from '@/components/common/TextArea' + +describe ('common typography and form components', () => { + it ('renders Form children inside the standard container', () => { + render (
Content
) + + expect (screen.getByText ('Content').parentElement).toHaveClass ('max-w-xl') + }) + + it ('renders SectionTitle as an h2 and merges custom classes', () => { + render (Section) + + const heading = screen.getByRole ('heading', { level: 2, name: 'Section' }) + expect (heading).toHaveClass ('text-xl') + expect (heading).toHaveClass ('custom') + }) + + it ('renders SubsectionTitle as an h3', () => { + render (Subsection) + + expect (screen.getByRole ('heading', { level: 3, name: 'Subsection' })).toBeInTheDocument () + }) + + it ('forwards refs and props to TextArea', () => { + const ref = createRef () + + render (