|
|
|
@@ -3,12 +3,14 @@ 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 { MemoryRouter, useLocation } from 'react-router-dom'
|
|
|
|
|
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'
|
|
|
|
@@ -19,6 +21,10 @@ 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<typeof import ('@/lib/counts')> (), fetchCounts }))
|
|
|
|
|
vi.mock ('@/lib/countsDefinitions', async original => ({
|
|
|
|
@@ -27,12 +33,27 @@ vi.mock ('@/lib/countsDefinitions', async original => ({
|
|
|
|
|
const Location = () => <output data-testid="location">{useLocation ().search}</output>
|
|
|
|
|
const currentParams = () => new URLSearchParams (screen.getByTestId ('location').textContent ?? '')
|
|
|
|
|
const execute = () => fireEvent.submit (
|
|
|
|
|
screen.getByRole ('button', { name: '集計実行' }).closest ('form')!)
|
|
|
|
|
const openConditions = () => fireEvent.click (screen.getByRole ('button', { name: '集計条件' }))
|
|
|
|
|
screen.getByText ('集計実行').closest ('form')!)
|
|
|
|
|
const openConditions = () => fireEvent.click (
|
|
|
|
|
screen.getByText ('集計条件').closest ('button')!)
|
|
|
|
|
const renderPage = (extra = '') => renderWithProviders (<>
|
|
|
|
|
<CountsResourcePage resource="posts"/><Location/>
|
|
|
|
|
</>, { 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)
|
|
|
|
@@ -40,19 +61,30 @@ beforeEach (() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 (<SortableColumns ids={['title']}
|
|
|
|
|
name={name} onChange={() => {}}>
|
|
|
|
|
{() => <div>Title</div>}
|
|
|
|
|
</SortableColumns>)).not.toThrow ()
|
|
|
|
|
expect (() => renderToString (<SortableList groups={{ fields: ['title'] }}
|
|
|
|
|
name={name} onChange={() => {}} renderItem={() => <div>Title</div>}
|
|
|
|
|
renderGroup={(_, content) => <section>{content}</section>}/>)).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 page = <QueryClientProvider client={client}>
|
|
|
|
|
<HelmetProvider><MemoryRouter initialEntries={[
|
|
|
|
|
'/ja/counts/posts?mode=details&timezone=UTC&resource_id=7']}>
|
|
|
|
|
const router = createMemoryRouter ([{ path: '*', element: (
|
|
|
|
|
<DialogueProvider><UnsavedChangesGuardProvider>
|
|
|
|
|
<KeyboardShortcutsProvider>
|
|
|
|
|
<CountsResourcePage resource="posts"/>
|
|
|
|
|
<CountsResourcePage resource="posts"/><Location/>
|
|
|
|
|
</KeyboardShortcutsProvider>
|
|
|
|
|
</UnsavedChangesGuardProvider></DialogueProvider>
|
|
|
|
|
</MemoryRouter></HelmetProvider>
|
|
|
|
|
</UnsavedChangesGuardProvider></DialogueProvider>) }], {
|
|
|
|
|
initialEntries: ['/ja/counts/posts?mode=details&timezone=UTC&resource_id=7'] })
|
|
|
|
|
const page = <QueryClientProvider client={client}>
|
|
|
|
|
<HelmetProvider><RouterProvider router={router}/></HelmetProvider>
|
|
|
|
|
</QueryClientProvider>
|
|
|
|
|
const html = renderToString (page)
|
|
|
|
|
expect (fetchCounts).not.toHaveBeenCalled ()
|
|
|
|
@@ -71,23 +103,26 @@ describe ('Counts execution and URL state', () => {
|
|
|
|
|
const root = hydrateRoot (container, page, { onRecoverableError: recoverable })
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await screen.findByText ('Recorded title')
|
|
|
|
|
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 () => {
|
|
|
|
|
renderPage ()
|
|
|
|
|
const { queryClient } = renderPage ()
|
|
|
|
|
const heading = screen.getByRole ('heading', { name: '広場集計' })
|
|
|
|
|
await screen.findByText ('Recorded title')
|
|
|
|
|
await waitForResult (queryClient)
|
|
|
|
|
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
|
|
|
|
|
expect (fetchCounts).toHaveBeenCalledTimes (1)
|
|
|
|
|
expect (screen.getByRole ('heading', { name: '広場集計' })).toBe (heading)
|
|
|
|
@@ -98,24 +133,25 @@ describe ('Counts execution and URL state', () => {
|
|
|
|
|
|
|
|
|
|
it ('retries an initial CSR failure with the URL filter intact', async () => {
|
|
|
|
|
fetchCounts.mockRejectedValueOnce (new Error ('offline'))
|
|
|
|
|
renderPage ('&resource_id=7')
|
|
|
|
|
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 screen.findByText ('Recorded title')
|
|
|
|
|
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 () => {
|
|
|
|
|
renderPage ()
|
|
|
|
|
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.getByRole ('combobox', { name: '表示件数' }), {
|
|
|
|
|
fireEvent.change (screen.getByLabelText ('表示件数', { selector: 'select' }), {
|
|
|
|
|
target: { value: '20' } })
|
|
|
|
|
openConditions ()
|
|
|
|
|
openConditions ()
|
|
|
|
@@ -129,6 +165,7 @@ describe ('Counts execution and URL state', () => {
|
|
|
|
|
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,
|
|
|
|
@@ -141,7 +178,8 @@ describe ('Counts execution and URL state', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it ('keeps the filter and identifier after a failed execution', async () => {
|
|
|
|
|
renderPage ()
|
|
|
|
|
const { queryClient } = renderPage ()
|
|
|
|
|
await waitForResult (queryClient)
|
|
|
|
|
await waitFor (() => expect (currentParams ().get ('execution_at')).toBe (executionAt))
|
|
|
|
|
openConditions ()
|
|
|
|
|
fireEvent.change (screen.getByRole ('textbox', { name: 'Id.' }), {
|
|
|
|
@@ -149,6 +187,8 @@ describe ('Counts execution and URL state', () => {
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
@@ -170,17 +210,17 @@ describe ('Counts execution and URL state', () => {
|
|
|
|
|
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 } } })
|
|
|
|
|
renderPage ()
|
|
|
|
|
const { queryClient } = renderPage ()
|
|
|
|
|
await screen.findByRole ('alert')
|
|
|
|
|
execute ()
|
|
|
|
|
await screen.findByText ('Recorded title')
|
|
|
|
|
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 () => {
|
|
|
|
|
renderPage ()
|
|
|
|
|
await screen.findByText ('Recorded title')
|
|
|
|
|
const { queryClient } = renderPage ()
|
|
|
|
|
await waitForResult (queryClient)
|
|
|
|
|
await waitFor (() => expect (document.title).toContain ('広場集計 | '))
|
|
|
|
|
expect (screen.getByRole ('heading', { name: '広場集計' })).toBeInTheDocument ()
|
|
|
|
|
})
|
|
|
|
|