このコミットが含まれているのは:
@@ -78,16 +78,18 @@ describe ('PostEditForm', () => {
|
||||
|
||||
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
|
||||
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
||||
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
|
||||
.toHaveValue ('180.5')
|
||||
|
||||
const tags = screen.getAllByRole ('textbox')[2]
|
||||
fireEvent.change (tags, { target: { value: 'general-tag' } })
|
||||
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('動画時間')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.change (tags, {
|
||||
target: { value: '動画 general-tag' },
|
||||
})
|
||||
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
|
||||
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
|
||||
.toHaveValue ('180.5')
|
||||
})
|
||||
|
||||
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ describe ('PostOriginalCreatedTimeField', () => {
|
||||
setOriginalCreatedBefore={vi.fn ()}/>,
|
||||
)
|
||||
|
||||
const input = screen.getDisplayValue ('2024-01-01T12:34')
|
||||
const input = screen.getByDisplayValue ('2024-01-01T12:34')
|
||||
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
|
||||
|
||||
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
|
||||
|
||||
@@ -17,21 +17,19 @@ describe ('menuOutline', () => {
|
||||
for (const role of ['member', 'admin'] as const)
|
||||
{
|
||||
expect (submenuItem (role, '広場', '追加')?.visible).toBe (true)
|
||||
expect (submenuItem (role, '広場', '取込')?.visible).toBe (true)
|
||||
expect (submenuItem (role, '素材', '追加')?.visible).toBe (true)
|
||||
expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true)
|
||||
expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true)
|
||||
}
|
||||
|
||||
expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false)
|
||||
expect (submenuItem ('guest', '広場', '取込')?.visible).toBe (false)
|
||||
expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false)
|
||||
expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false)
|
||||
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
|
||||
})
|
||||
|
||||
it ('uses /posts/new as the import entrypoint', () => {
|
||||
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
|
||||
it ('uses /posts/new as the post creation entrypoint', () => {
|
||||
expect (submenuItem ('member', '広場', '追加')?.to).toBe ('/posts/new')
|
||||
})
|
||||
|
||||
it ('keeps material suppression admin-only', () => {
|
||||
|
||||
@@ -80,7 +80,9 @@ describe ('DialogueProvider', () => {
|
||||
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
|
||||
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
|
||||
.toBeInTheDocument ())
|
||||
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto')
|
||||
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass (
|
||||
'w-full',
|
||||
'md:w-auto')
|
||||
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
|
||||
|
||||
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
|
||||
|
||||
@@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
@@ -14,14 +12,6 @@ vi.mock ('@/components/posts/PostCreationDataFields', () => ({
|
||||
}))
|
||||
|
||||
describe ('PostCreationDataFields usage', () => {
|
||||
it ('is used by PostNewPage', async () => {
|
||||
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('is used by PostImportRowForm', async () => {
|
||||
const { default: PostImportRowForm } = await import (
|
||||
'@/components/posts/import/PostImportRowForm')
|
||||
|
||||
@@ -135,7 +135,7 @@ describe ('PostImportRowForm', () => {
|
||||
})
|
||||
|
||||
it (
|
||||
'shows the shared creation field order without duration and without file upload UI',
|
||||
'shows upload input only when the thumbnail URL is blank',
|
||||
async () => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const controls: DialogueFormControls = {
|
||||
@@ -164,7 +164,9 @@ describe ('PostImportRowForm', () => {
|
||||
'オリジナルの作成日時',
|
||||
'タグ',
|
||||
'親投稿'])
|
||||
expect (container.querySelector ('input[type="file"]')).toBeNull ()
|
||||
expect (container.querySelector ('input[type="file"]')).toHaveAttribute (
|
||||
'accept',
|
||||
'image/*')
|
||||
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
|
||||
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
describe ('PostImportThumbnailPreview', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
@@ -16,69 +10,50 @@ describe ('PostImportThumbnailPreview', () => {
|
||||
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||
})
|
||||
|
||||
it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => {
|
||||
api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
|
||||
|
||||
it ('renders the remote URL directly without a backend proxy', () => {
|
||||
render (
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/thumbnail.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/thumbnail.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
||||
})
|
||||
expect (screen.getByRole ('img')).not.toHaveAttribute (
|
||||
'src',
|
||||
'https://example.com/thumbnail.jpg')
|
||||
expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', {
|
||||
params: { url: 'https://example.com/thumbnail.jpg' },
|
||||
responseType: 'blob' })
|
||||
expect (screen.getByRole ('img')).toHaveAttribute (
|
||||
'src',
|
||||
'https://example.com/thumbnail.jpg')
|
||||
expect (screen.getByRole ('img')).toHaveAttribute (
|
||||
'referrerpolicy',
|
||||
'no-referrer')
|
||||
})
|
||||
|
||||
it ('does not render the unsafe URL directly when preview fetching fails', async () => {
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
|
||||
|
||||
it ('shows the empty frame after the remote image fails', () => {
|
||||
const { container } = render (
|
||||
<PostImportThumbnailPreview
|
||||
url="http://127.0.0.1/private.png"
|
||||
className="h-10 w-10"/>)
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/missing.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
})
|
||||
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
|
||||
expect (screen.queryByText ('なし')).toBeNull ()
|
||||
fireEvent.error (screen.getByRole ('img'))
|
||||
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
|
||||
expect (container.textContent).toBe ('')
|
||||
expect (screen.queryByRole ('img')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('revokes the old object URL when the source URL changes', async () => {
|
||||
const createObjectUrlMock =
|
||||
globalThis.URL.createObjectURL as unknown as ReturnType<typeof vi.fn>
|
||||
createObjectUrlMock
|
||||
.mockReturnValueOnce ('blob:first')
|
||||
.mockReturnValueOnce ('blob:second')
|
||||
api.apiGet
|
||||
.mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
|
||||
.mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
|
||||
it ('uses and revokes an object URL only when the remote URL is blank', () => {
|
||||
const file = new File (['image'], 'thumbnail.png', { type: 'image/png' })
|
||||
const { rerender, unmount } = render (
|
||||
<PostImportThumbnailPreview url="" file={file} className="h-10 w-10"/>)
|
||||
|
||||
const { rerender } = render (
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/first.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
|
||||
})
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
|
||||
|
||||
rerender (
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/second.jpg"
|
||||
className="h-10 w-10"/>)
|
||||
<PostImportThumbnailPreview
|
||||
url="https://example.com/remote.jpg"
|
||||
file={file}
|
||||
className="h-10 w-10"/>)
|
||||
expect (screen.getByRole ('img')).toHaveAttribute (
|
||||
'src',
|
||||
'https://example.com/remote.jpg')
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second')
|
||||
})
|
||||
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
|
||||
unmount ()
|
||||
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:preview')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,14 +16,14 @@ describe ('displayPostImportStatus', () => {
|
||||
skipReason: 'manual' }))).toBe ('skipped')
|
||||
})
|
||||
|
||||
it ('does not expose validation, failure, or created states as badges', () => {
|
||||
it ('distinguishes validation, failure, and created states', () => {
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
status: 'error',
|
||||
validationErrors: { title: ['invalid'] } }))).toBeNull ()
|
||||
validationErrors: { title: ['invalid'] } }))).toBe ('error')
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
importStatus: 'failed' }))).toBeNull ()
|
||||
importStatus: 'failed' }))).toBe ('failed')
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
importStatus: 'created',
|
||||
createdPostId: 3 }))).toBeNull ()
|
||||
createdPostId: 3 }))).toBe ('created')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ import { creatableImportRows,
|
||||
resultRowMessages,
|
||||
resultSummaryCounts,
|
||||
retryImportRow,
|
||||
reviewSummaryCounts } from '@/lib/postImportSession'
|
||||
reviewSummaryCounts } from '@/lib/postImportRows'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
describe ('post import row state', () => {
|
||||
@@ -274,6 +274,7 @@ describe ('post import row state', () => {
|
||||
thumbnailBase: '',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '2',
|
||||
tags: 'edited-tag',
|
||||
parentPostIds: '' },
|
||||
true)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession'
|
||||
import {
|
||||
countImportSourceLines,
|
||||
validateImportSource,
|
||||
} from '@/lib/postImportSourceValidation'
|
||||
|
||||
describe ('post import source validation', () => {
|
||||
it ('counts trimmed non-empty CRLF and LF rows', () => {
|
||||
|
||||
@@ -1,124 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { cleanupExpiredPostImportSessions,
|
||||
clearPostImportSourceDraft,
|
||||
loadPostImportSession,
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSession,
|
||||
savePostImportSourceDraft } from '@/lib/postImportSession'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import {
|
||||
clearPostImportSourceDraft,
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSourceDraft,
|
||||
} from '@/lib/postImportStorage'
|
||||
|
||||
describe ('post import storage', () => {
|
||||
describe ('post import source draft storage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.useRealTimers ()
|
||||
vi.restoreAllMocks ()
|
||||
})
|
||||
|
||||
it ('round-trips a valid session and source draft', () => {
|
||||
const row = buildPostImportRow ({
|
||||
attributes: { duration: '2.5' },
|
||||
recoverable: true,
|
||||
importStatus: 'pending',
|
||||
validationErrors: { title: ['invalid'] } })
|
||||
const skipped = buildPostImportRow ({
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 })
|
||||
const manual = buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
skipReason: 'manual' })
|
||||
|
||||
expect (savePostImportSession ({
|
||||
source: skipped.url,
|
||||
rows: [row, skipped, manual],
|
||||
repairMode: 'all' })).toBe (true)
|
||||
expect (loadPostImportSession ()).toMatchObject ({
|
||||
version: 2,
|
||||
source: skipped.url,
|
||||
rows: [{
|
||||
attributes: { duration: '2.5' },
|
||||
recoverable: true,
|
||||
importStatus: 'pending' },
|
||||
{
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 },
|
||||
{
|
||||
skipReason: 'manual' }] })
|
||||
|
||||
it ('round-trips and clears the URL list source draft', () => {
|
||||
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
|
||||
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
|
||||
expect (loadPostImportSourceDraft ()).toEqual ({
|
||||
source: 'https://example.com' })
|
||||
|
||||
clearPostImportSourceDraft ()
|
||||
|
||||
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
|
||||
})
|
||||
|
||||
it ('rejects inconsistent post IDs and terminal statuses', () => {
|
||||
const session = {
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
source: '',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ()] }
|
||||
const invalidRows = [
|
||||
{ ...session.rows[0], skipReason: 'existing', existingPostId: undefined },
|
||||
{ ...session.rows[0], existingPostId: 2, skipReason: undefined },
|
||||
{ ...session.rows[0], importStatus: 'created', createdPostId: undefined },
|
||||
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 },
|
||||
{ ...session.rows[0], importStatus: 'created', recoverable: true },
|
||||
{ ...session.rows[0], importStatus: 'skipped', recoverable: true },
|
||||
{ ...session.rows[0], recoverable: true }]
|
||||
it ('ignores malformed stored drafts', () => {
|
||||
sessionStorage.setItem ('post-import-source-draft', '{')
|
||||
|
||||
for (const row of invalidRows)
|
||||
{
|
||||
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
|
||||
...session,
|
||||
rows: [row] }))
|
||||
expect (loadPostImportSession ()).toBeNull ()
|
||||
sessionStorage.clear ()
|
||||
}
|
||||
})
|
||||
|
||||
it ('rejects invalid attribute, provenance, tag-source, and snapshot data', () => {
|
||||
const row = buildPostImportRow ()
|
||||
const invalidRows = [
|
||||
{ ...row, attributes: { title: [] } },
|
||||
{ ...row, attributes: { unknown: 'value' } },
|
||||
{ ...row, provenance: { title: 'mapped' } },
|
||||
{ ...row, tagSources: { mapped: 'tag' } },
|
||||
{ ...row, resetSnapshot: { ...row.resetSnapshot,
|
||||
fieldWarnings: { title: 'warning' } } }]
|
||||
|
||||
invalidRows.forEach (invalidRow => {
|
||||
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
source: '',
|
||||
rows: [invalidRow],
|
||||
repairMode: 'all' }))
|
||||
expect (loadPostImportSession ()).toBeNull ()
|
||||
sessionStorage.clear ()
|
||||
})
|
||||
})
|
||||
|
||||
it ('keeps only the current fixed-key session and removes legacy prefixed entries', () => {
|
||||
const current = {
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
source: '',
|
||||
rows: [buildPostImportRow ()],
|
||||
repairMode: 'all' }
|
||||
const expired = {
|
||||
...current,
|
||||
savedAt: new Date (Date.now () - 25 * 60 * 60 * 1000).toISOString () }
|
||||
sessionStorage.setItem ('post-import-session:current', JSON.stringify (current))
|
||||
sessionStorage.setItem ('post-import-session:expired', JSON.stringify (expired))
|
||||
sessionStorage.setItem ('post-import-session:malformed', '{')
|
||||
|
||||
cleanupExpiredPostImportSessions ()
|
||||
|
||||
expect (sessionStorage.getItem ('post-import-session:current')).not.toBeNull ()
|
||||
expect (sessionStorage.getItem ('post-import-session:expired')).toBeNull ()
|
||||
expect (sessionStorage.getItem ('post-import-session:malformed')).toBeNull ()
|
||||
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
|
||||
})
|
||||
|
||||
it ('reports storage access failures without throwing', () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPostNewReviewPath,
|
||||
hasPostNewReviewState,
|
||||
isPostNewReviewPathWithinLimit,
|
||||
parsePostNewReviewUrls,
|
||||
postNewReviewPathByteLength,
|
||||
} from '@/lib/postNewQueryState'
|
||||
|
||||
describe ('post new review URL state', () => {
|
||||
it ('encodes each URL separately and preserves literal plus signs', () => {
|
||||
const urls = [
|
||||
'https://example.com/one+a',
|
||||
'https://example.com/two?value=b+c']
|
||||
|
||||
const path = buildPostNewReviewPath (urls)
|
||||
|
||||
expect (path).toBe (
|
||||
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
|
||||
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
|
||||
expect (parsePostNewReviewUrls (path.slice ('/posts/new'.length))).toEqual (urls)
|
||||
})
|
||||
|
||||
it ('uses only the raw urls parameter as review state', () => {
|
||||
expect (hasPostNewReviewState ('?session_id=old&meta=old')).toBe (false)
|
||||
expect (hasPostNewReviewState ('?unknown=value&urls=')).toBe (true)
|
||||
expect (parsePostNewReviewUrls ('?unknown=value&urls=one+two&meta=old'))
|
||||
.toEqual (['one', 'two'])
|
||||
})
|
||||
|
||||
it ('allows at most a 4095-byte request target', () => {
|
||||
const baseUrl = 'https://example.com/'
|
||||
const baseLength = postNewReviewPathByteLength ([baseUrl])
|
||||
const allowed = `${ baseUrl }${ 'a'.repeat (4_095 - baseLength) }`
|
||||
const denied = `${ allowed }a`
|
||||
|
||||
expect (postNewReviewPathByteLength ([allowed])).toBe (4_095)
|
||||
expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
|
||||
expect (postNewReviewPathByteLength ([denied])).toBe (4_096)
|
||||
expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
|
||||
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144
|
||||
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4_096
|
||||
|
||||
const textEncoder = new TextEncoder ()
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const GuardHarness = () => {
|
||||
const [dirty, setDirty] = useState (true)
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const discard = vi.fn (() => setDirty (false))
|
||||
const { allowNextNavigation } = useUnsavedChangesGuard ({
|
||||
dirty,
|
||||
onDiscard: discard })
|
||||
|
||||
return (
|
||||
<>
|
||||
<output aria-label="location">{location.pathname}{location.search}</output>
|
||||
<button type="button" onClick={() => navigate ('/next?tab=one')}>move</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
allowNextNavigation ()
|
||||
navigate ('/allowed')
|
||||
}}>
|
||||
allowed move
|
||||
</button>
|
||||
</>)
|
||||
}
|
||||
|
||||
describe ('useUnsavedChangesGuard', () => {
|
||||
it ('blocks route changes and resets a cancelled transition', async () => {
|
||||
renderWithProviders (<GuardHarness/>, { route: '/current' })
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
|
||||
|
||||
expect (within (await screen.findByRole ('dialog')).getByText (
|
||||
'変更が破棄してページ移動しますか?')).toBeInTheDocument ()
|
||||
expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取消' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.queryByRole ('dialog')).not.toBeInTheDocument ()
|
||||
})
|
||||
expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
|
||||
})
|
||||
|
||||
it ('proceeds with the blocked transition after discard is confirmed', async () => {
|
||||
renderWithProviders (<GuardHarness/>, { route: '/current' })
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
|
||||
fireEvent.click (await screen.findByRole ('button', {
|
||||
name: '変更を破棄して移動' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByLabelText ('location')).toHaveTextContent ('/next?tab=one')
|
||||
})
|
||||
})
|
||||
|
||||
it ('allows only the next navigation without leaving a bypass token', async () => {
|
||||
renderWithProviders (<GuardHarness/>, { route: '/current' })
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'allowed move' }))
|
||||
await waitFor (() => {
|
||||
expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
|
||||
})
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
|
||||
|
||||
expect (within (await screen.findByRole ('dialog')).getByText (
|
||||
'変更が破棄してページ移動しますか?')).toBeInTheDocument ()
|
||||
expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,7 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
const bypassNextNavigationRef = useRef<symbol | null> (null)
|
||||
const [revision, setRevision] = useState (0)
|
||||
const dialogueOpenRef = useRef (false)
|
||||
const handlingBlockedTransitionRef = useRef (false)
|
||||
|
||||
const registerUnsavedChangesSource = useCallback ((
|
||||
source: UnsavedChangesSource,
|
||||
@@ -73,6 +74,8 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
[sources],
|
||||
)
|
||||
const hasUnsavedChanges = dirtySources.length > 0
|
||||
const hasUnsavedChangesRef = useRef (hasUnsavedChanges)
|
||||
hasUnsavedChangesRef.current = hasUnsavedChanges
|
||||
const shouldBlock = useCallback (() => {
|
||||
if (bypassNextNavigationRef.current != null)
|
||||
{
|
||||
@@ -80,11 +83,11 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
return false
|
||||
}
|
||||
|
||||
return hasUnsavedChanges
|
||||
}, [hasUnsavedChanges])
|
||||
return hasUnsavedChangesRef.current
|
||||
}, [])
|
||||
const blocker = useBlocker (shouldBlock)
|
||||
|
||||
const discardDirtyChanges = useCallback (async (): Promise<boolean> => {
|
||||
const confirmDiscardChanges = useCallback (async (): Promise<boolean> => {
|
||||
if (!(hasUnsavedChanges))
|
||||
return true
|
||||
|
||||
@@ -96,54 +99,55 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
try
|
||||
{
|
||||
const confirmed = await dialogue.confirm ({
|
||||
title: '変更を破棄してページ移動しますか?',
|
||||
title: '変更が破棄してページ移動しますか?',
|
||||
confirmText: '変更を破棄して移動',
|
||||
variant: 'danger' })
|
||||
if (!(confirmed))
|
||||
return false
|
||||
|
||||
try
|
||||
{
|
||||
for (const source of dirtySources)
|
||||
await source.discard ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
finally
|
||||
{
|
||||
dialogueOpenRef.current = false
|
||||
}
|
||||
}, [dialogue, dirtySources, hasUnsavedChanges])
|
||||
}, [dialogue, hasUnsavedChanges])
|
||||
|
||||
useEffect (() => {
|
||||
if (blocker.state !== 'blocked')
|
||||
if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
|
||||
return
|
||||
|
||||
let active = true
|
||||
|
||||
handlingBlockedTransitionRef.current = true
|
||||
void (async () => {
|
||||
const confirmed = await discardDirtyChanges ()
|
||||
if (!(active))
|
||||
return
|
||||
try
|
||||
{
|
||||
const confirmed = await confirmDiscardChanges ()
|
||||
if (!(confirmed))
|
||||
{
|
||||
blocker.reset ()
|
||||
return
|
||||
}
|
||||
|
||||
if (confirmed)
|
||||
let discardResults: Array<void | Promise<void>>
|
||||
try
|
||||
{
|
||||
blocker.proceed ()
|
||||
return
|
||||
discardResults = dirtySources.map (source => source.discard ())
|
||||
}
|
||||
catch
|
||||
{
|
||||
blocker.reset ()
|
||||
return
|
||||
}
|
||||
|
||||
blocker.reset ()
|
||||
blocker.proceed ()
|
||||
await Promise.allSettled (discardResults)
|
||||
}
|
||||
finally
|
||||
{
|
||||
handlingBlockedTransitionRef.current = false
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [blocker, discardDirtyChanges])
|
||||
}, [blocker, confirmDiscardChanges, dirtySources])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(hasUnsavedChanges))
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe ('legacy post import routes', () => {
|
||||
it ('redirects /posts/import to /posts/new', () => {
|
||||
render (
|
||||
<MemoryRouter initialEntries={['/posts/import']}>
|
||||
<Routes>
|
||||
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
|
||||
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('redirects legacy review routes to /posts/new', () => {
|
||||
render (
|
||||
<MemoryRouter initialEntries={['/posts/import/legacy/review']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<Navigate to="/posts/new" replace/>}/>
|
||||
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('redirects legacy result routes to /posts', () => {
|
||||
render (
|
||||
<MemoryRouter initialEntries={['/posts/import/legacy/result']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<Navigate to="/posts" replace/>}/>
|
||||
<Route
|
||||
path="/posts/new/:sessionId/result"
|
||||
element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
@@ -1,448 +1,157 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession'
|
||||
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
toast: vi.fn (),
|
||||
}))
|
||||
|
||||
const dialogue = vi.hoisted (() => ({
|
||||
form: vi.fn (() => Promise.resolve ()),
|
||||
}))
|
||||
isApiError: vi.fn (() => false) }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('@/lib/dialogues/useDialogue', () => ({
|
||||
default: () => dialogue,
|
||||
}))
|
||||
vi.mock ('framer-motion', () => ({
|
||||
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
motion: {
|
||||
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
main: ({ children }: { children?: ReactNode }) => <main>{children}</main> } }))
|
||||
|
||||
const renderReviewPage = (route = '/posts/new/review') =>
|
||||
render (
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<Routes>
|
||||
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
|
||||
<Route
|
||||
path="/posts/new/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>)
|
||||
const metadata = (url: string, title: string) => ({
|
||||
url,
|
||||
title,
|
||||
thumbnailBase: 'https://example.com/thumbnail.jpg',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '',
|
||||
tags: '',
|
||||
parentPostIds: [],
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [],
|
||||
displayTags: [] })
|
||||
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation ()
|
||||
return <output aria-label="current-location">{location.pathname}{location.search}</output>
|
||||
}
|
||||
|
||||
|
||||
const renderReviewPage = (urls: string[]) => {
|
||||
const search = urls.map (url => encodeURIComponent (url)).join ('+')
|
||||
return renderWithProviders (
|
||||
<>
|
||||
<PostImportReviewPage user={buildUser ()}/>
|
||||
<LocationProbe/>
|
||||
</>,
|
||||
{ route: `/posts/new?urls=${ search }` })
|
||||
}
|
||||
|
||||
describe ('PostImportReviewPage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
|
||||
globalThis.URL.revokeObjectURL = vi.fn ()
|
||||
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('redirects to /posts/new when no current work exists', async () => {
|
||||
renderReviewPage ()
|
||||
it ('fetches metadata with at most four concurrent requests', async () => {
|
||||
const resolvers: Array<(value: ReturnType<typeof metadata>) => void> = []
|
||||
api.apiGet.mockImplementation ((_path, options) =>
|
||||
new Promise (resolve => {
|
||||
resolvers.push (resolve)
|
||||
const url = String (options.params.url)
|
||||
void url
|
||||
}))
|
||||
const urls = Array.from (
|
||||
{ length: 6 },
|
||||
(_, index) => `https://example.com/${ index + 1 }`)
|
||||
|
||||
renderReviewPage (urls)
|
||||
|
||||
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (4))
|
||||
|
||||
resolvers[0]?.(metadata (urls[0]!, 'first'))
|
||||
|
||||
expect (await screen.findAllByText ('first')).toHaveLength (2)
|
||||
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (5))
|
||||
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
|
||||
|
||||
for (let index = 1; index < resolvers.length; ++index)
|
||||
resolvers[index]?.(metadata (urls[index]!, `post ${ index + 1 }`))
|
||||
})
|
||||
|
||||
it ('keeps successful rows when another metadata request fails', async () => {
|
||||
api.apiGet
|
||||
.mockResolvedValueOnce (metadata ('https://example.com/one', 'first'))
|
||||
.mockRejectedValueOnce (new TypeError ('network'))
|
||||
|
||||
renderReviewPage (['https://example.com/one', 'https://example.com/two'])
|
||||
|
||||
expect (await screen.findAllByText ('first')).toHaveLength (2)
|
||||
await waitFor (() => {
|
||||
expect (screen.getAllByText ('登録不可')).toHaveLength (2)
|
||||
})
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
|
||||
})
|
||||
|
||||
it ('shows existing posts with Active Storage thumbnail precedence', async () => {
|
||||
api.apiGet.mockResolvedValue ({
|
||||
...metadata ('https://example.com/post', ''),
|
||||
existingPostId: 24,
|
||||
existingPost: {
|
||||
id: 24,
|
||||
title: 'existing post',
|
||||
url: 'https://example.com/post',
|
||||
thumbnail: 'https://example.com/storage.jpg',
|
||||
thumbnailBase: 'https://example.com/base.jpg' } })
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
const disclosure = await screen.findByRole ('button', {
|
||||
name: '既存投稿による自動スキップ 1件' })
|
||||
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
|
||||
|
||||
fireEvent.click (disclosure)
|
||||
|
||||
expect ((await screen.findAllByRole ('img', { name: 'サムネール' }))[0])
|
||||
.toHaveAttribute ('src', 'https://example.com/storage.jpg')
|
||||
})
|
||||
|
||||
it ('keeps a recoverable bulk failure on the review screen', async () => {
|
||||
api.apiGet.mockResolvedValue (
|
||||
metadata ('https://example.com/post', 'new post'))
|
||||
api.apiPost.mockResolvedValue ({
|
||||
results: [{
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid title'] } }] })
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
|
||||
expect (screen.getAllByText ('invalid title')).toHaveLength (2)
|
||||
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
|
||||
expect (screen.getAllByRole ('button', { name: '再試行' })[0]).toBeEnabled ()
|
||||
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts/new?')
|
||||
})
|
||||
|
||||
it ('navigates to /posts after every submitted row is created', async () => {
|
||||
api.apiGet.mockResolvedValue (
|
||||
metadata ('https://example.com/post', 'new post'))
|
||||
api.apiPost.mockResolvedValue ({
|
||||
results: [{ status: 'created', post: { id: 42 } }] })
|
||||
|
||||
renderReviewPage (['https://example.com/post'])
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
|
||||
})
|
||||
})
|
||||
|
||||
it ('restores the current work on review reload', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'restored row' } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
|
||||
expect (await screen.findByText ('restored row')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not expose メタデータ to the user', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
fieldWarnings: { title: ['自動取得に失敗しました.'] } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
expect (screen.queryByText (/メタデータ/)).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('returns to /posts/new while keeping the current work', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'restored row' } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (await screen.findByRole ('button', { name: 'URL リスト入力へ戻る' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
expect (loadPostImportSession ()?.rows[0]?.attributes.title).toBe ('restored row')
|
||||
})
|
||||
|
||||
it ('disables row editing and navigation while batch import is running', async () => {
|
||||
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost.mockImplementationOnce (() =>
|
||||
new Promise<{ rows: PostImportRow[] }> (resolve => {
|
||||
resolveValidation = resolve
|
||||
}))
|
||||
api.apiPost.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
|
||||
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
|
||||
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
|
||||
})
|
||||
|
||||
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
})
|
||||
|
||||
it ('does not call import when validation omits a requested source row', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post-1\nhttps://example.com/post-2',
|
||||
repairMode: 'all',
|
||||
rows: [
|
||||
buildPostImportRow ({ sourceRow: 1 }),
|
||||
buildPostImportRow ({ sourceRow: 2 })] })
|
||||
api.apiPost.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (toastApi.toast).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
|
||||
})
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
|
||||
it ('navigates to /posts when all rows finish as created or skipped', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'created',
|
||||
post: { id: 1 } }] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
expect (loadPostImportSession ()).toBeNull ()
|
||||
})
|
||||
|
||||
it ('stays on review when a recoverable failed row remains', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid'] } }] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
expect (await screen.findByText ('invalid')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'failed',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'old title', duration: '2' },
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title', duration: '2' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'edited title', duration: '2' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
rows: [{
|
||||
sourceRow: 1,
|
||||
status: 'failed',
|
||||
recoverable: true,
|
||||
errors: { title: ['invalid'] } }] })
|
||||
dialogue.form.mockImplementationOnce (async options => {
|
||||
let actions: DialogueFormAction[] = []
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn (),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }
|
||||
|
||||
render (options.body (controls))
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
fireEvent.change (screen.getByDisplayValue ('old title'), {
|
||||
target: { value: 'edited title' } })
|
||||
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
|
||||
})
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
|
||||
|
||||
await waitFor (() => {
|
||||
const saved = loadPostImportSession ()
|
||||
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
|
||||
expect (saved?.rows[0]?.importStatus).toBe ('pending')
|
||||
})
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
|
||||
.toBe ('edited title')
|
||||
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration)
|
||||
.toBe ('2')
|
||||
})
|
||||
})
|
||||
|
||||
it ('retries only failed rows and does not resend created or skipped rows', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'created',
|
||||
createdPostId: 1 }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
rows: [{
|
||||
sourceRow: 3,
|
||||
status: 'skipped',
|
||||
existingPostId: 9 }] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([
|
||||
expect.objectContaining ({ sourceRow: 3 })])
|
||||
})
|
||||
})
|
||||
|
||||
it ('allows ready rows to be manually skipped and excludes them from API payloads', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({ sourceRow: 1 })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('restores values and errors when manual skip is cleared', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
status: 'error',
|
||||
attributes: { title: 'kept title' },
|
||||
validationErrors: { title: ['invalid'] } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
const toggle = await screen.findByRole ('checkbox', { name: 'スキップ' })
|
||||
fireEvent.click (toggle)
|
||||
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
|
||||
|
||||
expect (screen.getByText ('kept title')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('invalid')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('moves existing skipped rows into the collapsed section', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'all',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'existing row' },
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'normal row' } })] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
expect (screen.getByText ('既存投稿による自動スキップ 1件')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('existing row')).not.toBeInTheDocument ()
|
||||
expect (screen.getByText ('normal row')).toBeInTheDocument ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '既存投稿による自動スキップ 1件' }))
|
||||
|
||||
expect (screen.getByText ('existing row')).toBeInTheDocument ()
|
||||
expect (screen.queryAllByRole ('button', { name: '編輯' })).toHaveLength (1)
|
||||
expect (screen.queryAllByRole ('checkbox', { name: 'スキップ' })).toHaveLength (1)
|
||||
})
|
||||
|
||||
it ('navigates to /posts when the last failed row completes', async () => {
|
||||
savePostImportSession ({
|
||||
source: 'https://example.com/post',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'created',
|
||||
createdPostId: 1 }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'failed',
|
||||
recoverable: true,
|
||||
importErrors: { base: ['failed'] } })] })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
importStatus: 'pending',
|
||||
recoverable: true })] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
rows: [{
|
||||
sourceRow: 2,
|
||||
status: 'created',
|
||||
post: { id: 2 } }] })
|
||||
|
||||
renderReviewPage ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
|
||||
savePostImportSession ({
|
||||
source: '',
|
||||
repairMode: 'failed',
|
||||
rows: [
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
attributes: { title: 'normal row' } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
attributes: { title: 'repair row' },
|
||||
importStatus: 'pending',
|
||||
recoverable: true,
|
||||
validationErrors: { title: ['invalid'] } })] })
|
||||
|
||||
const { container } = renderReviewPage ()
|
||||
|
||||
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
|
||||
node => node.textContent)
|
||||
expect (titles[0]).toBe ('repair row')
|
||||
expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -169,16 +169,17 @@ const buildInitialRows = (urls: string[]): PostImportRow[] => {
|
||||
urls.map ((url, index) => {
|
||||
const sourceRow = index + 1
|
||||
const rowIssues = issuesByRow[sourceRow] ?? []
|
||||
const validationErrors: Record<string, string[]> =
|
||||
rowIssues.length > 0
|
||||
? { url: [...rowIssues] }
|
||||
: { }
|
||||
return {
|
||||
sourceRow,
|
||||
url,
|
||||
attributes: emptyAttributes (),
|
||||
fieldWarnings: { },
|
||||
baseWarnings: [],
|
||||
validationErrors:
|
||||
rowIssues.length > 0
|
||||
? { url: [...rowIssues] }
|
||||
: { },
|
||||
validationErrors,
|
||||
provenance: emptyProvenance (),
|
||||
tagSources: emptyTagSources (),
|
||||
status: rowIssues.length > 0 ? 'error' : 'pending',
|
||||
@@ -481,7 +482,7 @@ const mergePreviewRow = (
|
||||
nextRow.attributes.tags = preview.tags ?? ''
|
||||
nextRow.displayTags = cloneDisplayTags (preview.displayTags)
|
||||
}
|
||||
nextRow.tagSources.automatic = preview.tags ?? ''
|
||||
nextRow.tagSources!.automatic = preview.tags ?? ''
|
||||
if (currentRow.provenance.parentPostIds !== 'manual')
|
||||
nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
|
||||
if (currentRow.provenance.duration !== 'manual')
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn () }))
|
||||
|
||||
const router = vi.hoisted (() => ({ navigate: vi.fn () }))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('react-router-dom', async importOriginal => ({
|
||||
...await importOriginal<typeof import('react-router-dom')> (),
|
||||
useNavigate: () => router.navigate }))
|
||||
@@ -24,71 +15,53 @@ describe ('PostImportSourcePage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('shows no empty error initially and validates only after Next is pressed', () => {
|
||||
it ('validates an empty source only after Next is pressed', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
|
||||
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||
expect (router.navigate).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('shows frontend URL issues with line numbers without requesting preview', () => {
|
||||
it ('reports frontend URL issues with original line numbers', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
const input = screen.getByRole ('textbox', { name: '' })
|
||||
|
||||
fireEvent.change (input, {
|
||||
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
|
||||
expect (screen.queryByText (/2 行目/)).not.toBeInTheDocument ()
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
|
||||
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
|
||||
expect (input).toHaveAttribute ('aria-invalid', 'true')
|
||||
expect (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues')
|
||||
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('shows backend URL errors against original input without a session', async () => {
|
||||
api.apiPost.mockResolvedValue ({
|
||||
rows: [buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
url: 'https://example.com/canonical',
|
||||
status: 'error',
|
||||
validationErrors: { url: ['URL が重複しています.'] } })] })
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
|
||||
target: { value: '\nhttps://example.com/original' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
expect (await screen.findByText ('2 行目: URL が重複しています.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByText ('https://example.com/original')).toHaveLength (2)
|
||||
expect (router.navigate).not.toHaveBeenCalled ()
|
||||
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||
sessionStorage.key (index))).not.toContainEqual(
|
||||
expect.stringMatching (/^post-import-session:/))
|
||||
})
|
||||
|
||||
it ('navigates to /posts/new with query state after a successful preview', async () => {
|
||||
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
|
||||
it ('navigates with individually encoded URLs without calling an API', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
|
||||
target: { value: 'https://example.com/post' } })
|
||||
target: {
|
||||
value: 'https://example.com/one+a\nhttps://example.com/two?value=b+c' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (router.navigate).toHaveBeenCalledWith (
|
||||
expect.stringMatching (/^\/posts\/new\?/))
|
||||
})
|
||||
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||
sessionStorage.key (index))).not.toContainEqual(
|
||||
expect.stringMatching (/^post-import-session:/))
|
||||
expect (router.navigate).toHaveBeenCalledWith (
|
||||
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
|
||||
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
|
||||
})
|
||||
|
||||
it ('disables Next when the encoded request target reaches 4096 bytes', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
const input = screen.getByRole ('textbox', { name: '' })
|
||||
|
||||
fireEvent.change (input, {
|
||||
target: { value: `https://example.com/${ 'a'.repeat (4_100) }` } })
|
||||
|
||||
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
@@ -7,6 +6,7 @@ import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn () }))
|
||||
|
||||
@@ -16,27 +16,23 @@ describe ('PostNewPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
api.apiGet.mockResolvedValue ({
|
||||
url: 'https://example.com/post',
|
||||
title: 'post',
|
||||
tags: '' })
|
||||
sessionStorage.clear ()
|
||||
})
|
||||
|
||||
it ('shows the source page on /posts/new', () => {
|
||||
renderWithProviders (
|
||||
<MemoryRouter initialEntries={['/posts/new']}>
|
||||
<Routes>
|
||||
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
|
||||
route: '/posts/new' })
|
||||
|
||||
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('shows the review page when query state is present', () => {
|
||||
renderWithProviders (
|
||||
<MemoryRouter initialEntries={['/posts/new?url=https%3A%2F%2Fexample.com%2Fpost']}>
|
||||
<Routes>
|
||||
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>)
|
||||
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
|
||||
route: '/posts/new?urls=https%3A%2F%2Fexample.com%2Fpost' })
|
||||
|
||||
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
|
||||
|
||||
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
|
||||
import { buildTag, buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { NicoTag } from '@/types'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
@@ -27,10 +26,10 @@ const scrollIntoView = vi.fn ()
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
|
||||
...await importOriginal<
|
||||
typeof import('@/components/dialogues/DialogueProvider')> (),
|
||||
useDialogue: () => dialogue }))
|
||||
|
||||
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
|
||||
...buildTag (),
|
||||
|
||||
@@ -40,10 +40,10 @@ const postEmbed = vi.hoisted (() => ({
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
useDialogue: () => dialogue,
|
||||
}))
|
||||
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
|
||||
...await importOriginal<
|
||||
typeof import('@/components/dialogues/DialogueProvider')> (),
|
||||
useDialogue: () => dialogue }))
|
||||
vi.mock ('@/components/PostEmbed', () => ({
|
||||
default: (props: {
|
||||
ref?: { current: unknown }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
export const buildPostImportRow = (
|
||||
overrides: Partial<PostImportRow> = {},
|
||||
|
||||
+33
-15
@@ -1,7 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render } from '@testing-library/react'
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom'
|
||||
|
||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||
@@ -13,6 +14,20 @@ type Options = {
|
||||
route?: string
|
||||
}
|
||||
|
||||
const TestContentContext = createContext<ReactNode> (null)
|
||||
|
||||
|
||||
const TestRoute = () => {
|
||||
const children = useContext (TestContentContext)
|
||||
|
||||
return (
|
||||
<UnsavedChangesGuardProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
{children}
|
||||
</KeyboardShortcutsProvider>
|
||||
</UnsavedChangesGuardProvider>)
|
||||
}
|
||||
|
||||
export const renderWithProviders = (
|
||||
ui: ReactElement,
|
||||
options: Options = {},
|
||||
@@ -22,20 +37,23 @@ export const renderWithProviders = (
|
||||
queries: { retry: false } },
|
||||
})
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={[options.route ?? '/']}>
|
||||
<DialogueProvider>
|
||||
<UnsavedChangesGuardProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
{children}
|
||||
</KeyboardShortcutsProvider>
|
||||
</UnsavedChangesGuardProvider>
|
||||
</DialogueProvider>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>)
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => {
|
||||
const [router] = useState (() => createMemoryRouter ([{
|
||||
path: '*',
|
||||
element: <TestRoute/> }], {
|
||||
initialEntries: [options.route ?? '/'] }))
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HelmetProvider>
|
||||
<DialogueProvider>
|
||||
<TestContentContext.Provider value={children}>
|
||||
<RouterProvider router={router}/>
|
||||
</TestContentContext.Provider>
|
||||
</DialogueProvider>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>)
|
||||
}
|
||||
|
||||
return {
|
||||
queryClient,
|
||||
|
||||
新しい課題から参照
ユーザをブロックする