import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { hydrateRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { HelmetProvider } from 'react-helmet-async' import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import CountsResourcePage from '@/pages/CountsResourcePage' import DialogueProvider from '@/components/dialogues/DialogueProvider' import { countsKeys } from '@/lib/queryKeys' import SortableColumns from '@/components/common/SortableColumns' import SortableList from '@/components/common/SortableList' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { detailResult, executionAt, postDefinition } from '@/test/fixtures/counts' import { renderWithProviders } from '@/test/render' import type { CountsResult } from '@/lib/counts' const { fetchCounts, fetchCountsDefinition } = vi.hoisted (() => ({ fetchCounts: vi.fn (), fetchCountsDefinition: vi.fn () })) // Layout animation is outside this state/URL contract and adds asynchronous frames. vi.mock ('@/lib/useClientBehaviourSettings', () => ({ useClientBehaviourSettings: () => ({ animation: 'off', linkPreload: 'off' }) })) vi.mock ('@/lib/counts', async original => ({ ...await original (), fetchCounts })) vi.mock ('@/lib/countsDefinitions', async original => ({ ...await original (), fetchCountsDefinition })) const Location = () => {useLocation ().search} const currentParams = () => new URLSearchParams (screen.getByTestId ('location').textContent ?? '') const execute = () => fireEvent.submit ( screen.getByText ('集計実行').closest ('form')!) const openConditions = () => fireEvent.click ( screen.getByText ('集計条件').closest ('button')!) const renderPage = (extra = '') => renderWithProviders (<> , { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` }) const waitForResult = async (client: QueryClient) => { await waitFor (() => { expect (fetchCounts).toHaveBeenCalled () const queries = client.getQueryCache ().getAll ().filter (query => query.queryKey[0] === 'counts' && query.queryKey[1] === 'posts') expect (queries.map (query => ({ key: query.queryKey, status: query.state.status, data: query.state.data, error: query.state.error }))).toEqual (expect.arrayContaining ([ expect.objectContaining ({ status: 'success', data: detailResult })])) }) await waitFor (() => expect (currentParams ().get ('mode')).toBe ('details')) await screen.findByText ('Recorded title') } beforeEach (() => { fetchCounts.mockReset ().mockResolvedValue (detailResult) fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition) }) describe ('Counts execution and URL state', () => { it ('renders sortable shells on the server even when JSDOM provides document', () => { const name = () => ({ text: 'Title', language: 'en' }) expect (() => renderToString ( {}}> {() =>
Title
}
)).not.toThrow () expect (() => renderToString ( {}} renderItem={() =>
Title
} renderGroup={(_, content) =>
{content}
}/>)).not.toThrow () }) it ('hydrates the server shell before fetching results with the URL conditions', async () => { const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } }) client.setQueryData (countsKeys.definition ('posts', 'ja'), postDefinition) const router = createMemoryRouter ([{ path: '*', element: ( ) }], { initialEntries: ['/ja/counts/posts?mode=details&timezone=UTC&resource_id=7'] }) const page = const html = renderToString (page) expect (fetchCounts).not.toHaveBeenCalled () expect (html).toContain ('counts-targets') expect (html).not.toContain ('Recorded title') expect (html).not.toContain ('counts-grid') const resultKey = client.getQueryCache ().getAll ().find (query => query.queryKey[0] === 'counts' && query.queryKey[1] === 'posts')!.queryKey client.setQueryData (resultKey, detailResult) expect (renderToString (page)).not.toContain ('Recorded title') client.removeQueries ({ queryKey: resultKey, exact: true }) const container = document.createElement ('div') container.innerHTML = html document.body.appendChild (container) const recoverable = vi.fn () const root = hydrateRoot (container, page, { onRecoverableError: recoverable }) try { await waitForResult (client) expect (fetchCounts).toHaveBeenCalledTimes (1) expect (fetchCounts.mock.calls[0][1].get ('resource_id')).toBe ('7') await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) expect (currentParams ().get ('resource_id')).toBe ('7') expect (recoverable).not.toHaveBeenCalled () } finally { await act (async () => root.unmount ()) container.remove () router.dispose () client.clear () } }) it ('enriches the initial URL without a second query or removing the page shell', async () => { const { queryClient } = renderPage () const heading = screen.getByRole ('heading', { name: '広場集計' }) await waitForResult (queryClient) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) expect (fetchCounts).toHaveBeenCalledTimes (1) expect (screen.getByRole ('heading', { name: '広場集計' })).toBe (heading) expect (screen.getByText ('件数:1 件')).toBeInTheDocument () expect (screen.queryByText (/閲覧権限のないレコード/)).not.toBeInTheDocument () expect (currentParams ().get ('limit')).toBe ('100') }) it ('retries an initial CSR failure with the URL filter intact', async () => { fetchCounts.mockRejectedValueOnce (new Error ('offline')) const { queryClient } = renderPage ('&resource_id=7') await screen.findByRole ('alert') expect (screen.queryByText ('Recorded title')).not.toBeInTheDocument () expect (currentParams ().get ('resource_id')).toBe ('7') execute () await waitForResult (queryClient) expect (fetchCounts).toHaveBeenCalledTimes (2) expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('7') }) it ('keeps edits local until execution then commits the server timestamp and filters together', async () => { const { queryClient } = renderPage () await waitForResult (queryClient) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) openConditions () const input = screen.getByRole ('textbox', { name: 'Id.' }) fireEvent.change (input, { target: { value: '7' } }) fireEvent.change (screen.getByLabelText ('表示件数', { selector: 'select' }), { target: { value: '20' } }) openConditions () openConditions () expect (input).toHaveValue ('7') expect (fetchCounts).toHaveBeenCalledTimes (1) expect (currentParams ().has ('resource_id')).toBe (false) let complete!: (result: CountsResult) => void fetchCounts.mockImplementationOnce (() => new Promise (resolve => { complete = resolve })) const oldURL = currentParams ().toString () execute () await waitFor (() => expect (fetchCounts).toHaveBeenCalledTimes (2)) expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('7') expect (currentParams ().toString ()).toBe (oldURL) expect (screen.getByRole ('textbox', { name: 'Id.' })).toBe (input) await act (async () => complete ({ ...detailResult, executionAt: '2024-03-02T00:00:00.000000Z', limit: 20 })) await waitFor (() => expect (currentParams ().get ('resource_id')).toBe ('7')) expect (currentParams ().get ('limit')).toBe ('20') expect (currentParams ().has ('page')).toBe (false) expect (currentParams ().get ('execution_at')).toBe ('2024-03-02T00:00:00.000000Z') expect (fetchCounts).toHaveBeenCalledTimes (2) }) it ('keeps the filter and identifier after a failed execution', async () => { const { queryClient } = renderPage () await waitForResult (queryClient) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) openConditions () fireEvent.change (screen.getByRole ('textbox', { name: 'Id.' }), { target: { value: '99' } }) fetchCounts.mockRejectedValueOnce (new Error ('offline')) execute () await screen.findByRole ('alert') expect (fetchCounts).toHaveBeenCalledTimes (2) expect (fetchCounts.mock.calls[1][1].get ('resource_id')).toBe ('99') expect (screen.getByRole ('textbox', { name: 'Id.' })).toHaveValue ('99') expect (currentParams ().has ('resource_id')).toBe (false) }) it.each (['initial', 'execution']) ('shows the server column limit on %s failure', async phase => { const failure = { isAxiosError: true, response: { status: 422, data: { error: 'too_many_columns', max_columns: 73 } } } if (phase === 'initial') fetchCounts.mockRejectedValueOnce (failure) renderPage () if (phase === 'execution') { await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) fetchCounts.mockRejectedValueOnce (failure) execute () } expect (await screen.findByRole ('alert')).toHaveTextContent ('73') expect (screen.getByRole ('alert')).toHaveTextContent ('列数が上限') }) it ('can execute after an initial column-limit failure with no execution timestamp', async () => { fetchCounts.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 422, data: { error: 'too_many_columns', max_columns: 73 } } }) const { queryClient } = renderPage () await screen.findByRole ('alert') execute () await waitForResult (queryClient) await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt)) expect (fetchCounts).toHaveBeenCalledTimes (2) }) it ('keeps the page heading and common site title consistent', async () => { const { queryClient } = renderPage () await waitForResult (queryClient) await waitFor (() => expect (document.title).toContain ('広場集計 | ')) expect (screen.getByRole ('heading', { name: '広場集計' })).toBeInTheDocument () }) })