このコミットが含まれているのは:
2026-10-29 19:49:05 +09:00
コミット e857a33ec3
14個のファイルの変更、1129行の追加、193行の削除
+86 -137
ファイルの表示
@@ -1,157 +1,106 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CountsPage from '@/pages/CountsPage'
import CountsResourcePage from '@/pages/CountsResourcePage'
import { detailResult, executionAt, postDefinition } from '@/test/fixtures/counts'
import { renderWithProviders } from '@/test/render'
import type { CountsResult } from '@/lib/counts'
const { fetchCounts, fetchTagByName } = vi.hoisted (() => ({
fetchCounts: vi.fn (), fetchTagByName: vi.fn () }))
vi.mock ('@/lib/counts', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/counts')> (), fetchCounts }))
vi.mock ('@/lib/tags', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/tags')> (), fetchTagByName }))
beforeEach (() => {
fetchCounts.mockReset ()
fetchTagByName.mockReset ()
fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 })
})
const conditions = () => screen.getByRole ('button', { name: '集計条件' })
const { fetchCounts, fetchCountsDefinition } = vi.hoisted (() => ({
fetchCounts: vi.fn (), fetchCountsDefinition: vi.fn () }))
vi.mock ('@/lib/counts', async original => ({
...await original<typeof import ('@/lib/counts')> (), fetchCounts }))
vi.mock ('@/lib/countsDefinitions', async original => ({
...await original<typeof import ('@/lib/countsDefinitions')> (), fetchCountsDefinition }))
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: '集計条件' }))
const renderPage = (extra = '') => renderWithProviders (<>
<CountsResourcePage resource="posts"/><Location/>
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
beforeEach (() => {
fetchCounts.mockReset ().mockResolvedValue (detailResult)
fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition)
})
describe ('counts workspace', () => {
it ('opens the posts workspace at the root with a resource sidebar', async () => {
renderWithProviders (<CountsPage/>, { route: '/ja/counts' })
fireEvent.click (screen.getByRole ('button', { name: '集計対象' }))
expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('href', '/ja/counts/posts')
expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('aria-current', 'page')
expect (screen.getByRole ('link', { name: 'タグ' }))
.toHaveAttribute ('href', '/ja/counts/tags')
expect (conditions ()).toHaveAttribute ('aria-expanded', 'false')
await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('axis')).toBe ('month'))
describe ('Counts execution and URL state', () => {
it ('enriches the initial URL without a second query or removing the page shell', async () => {
renderPage ()
const heading = screen.getByRole ('heading', { name: '広場集計' })
await screen.findByText ('Recorded title')
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 ('preserves draft filters on collapse without querying until execution', async () => {
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?page=3&mode=details' })
await screen.findByText ('履歴レコード件数:0')
const calls = fetchCounts.mock.calls.length
fireEvent.click (conditions ())
const output = document.querySelector ('[data-counts-output]')!
const filters = document.querySelector ('[data-counts-filters]')!
expect (output.compareDocumentPosition (filters) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy ()
fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), {
target: { value: '7' } })
fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), {
target: { value: '9' } })
fireEvent.click (conditions ())
expect (screen.getByRole ('table')).toBeInTheDocument ()
fireEvent.click (conditions ())
expect (screen.getByRole ('textbox', { name: '投稿 ID' })).toHaveValue ('7')
expect (fetchCounts).toHaveBeenCalledTimes (calls)
it ('keeps edits local until execution then commits the server timestamp and filters together',
async () => {
renderPage ()
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: '表示件数' }), {
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<CountsResult> (resolve => {
complete = resolve
}))
const oldURL = currentParams ().toString ()
execute ()
await waitFor (() => expect (fetchCounts).toHaveBeenCalledTimes (2))
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 () => {
renderPage ()
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 waitFor (() => {
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams
expect (params.get ('resource_id')).toBe ('7')
expect (params.get ('user_id')).toBe ('9')
expect (params.has ('page')).toBe (false)
})
const refreshed = fetchCounts.mock.calls.length
execute ()
await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (refreshed))
await screen.findByRole ('alert')
expect (screen.getByRole ('textbox', { name: 'Id.' })).toHaveValue ('99')
expect (currentParams ().has ('resource_id')).toBe (false)
})
it ('applies selected columns only on execution and retains pagination state', async () => {
fetchCounts.mockResolvedValue ({ count: 21, page: 1, limit: 20,
versions: [{ postId: 7, versionNo: 2, url: { current: 'https://example.com' },
title: { current: 'Snapshot' } }] as CountsResult['versions'] })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3' })
expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument ()
expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
'href', '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3&page=2')
fireEvent.click (conditions ())
expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked ()
fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' }))
expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title')
execute ()
await waitFor (() => {
expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url')
})
})
it ('removes legacy event fields and axes from public query state', async () => {
renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ja/counts/tags?axis=event_type&event_type=update&columns=event_type,name' })
await waitFor (() => {
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams
expect (params.get ('axis')).toBe ('month')
expect (params.has ('event_type')).toBe (false)
expect (params.get ('columns')).toBe ('name')
})
fireEvent.click (conditions ())
expect (screen.queryByText ('操作種別')).not.toBeInTheDocument ()
expect (screen.getByRole ('combobox', { name: '集計軸' })).toHaveValue ('month')
})
it ('resolves a tag name with the existing lookup before running the query', async () => {
fetchTagByName.mockResolvedValue ({ id: 12 })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts' })
fireEvent.click (conditions ())
fireEvent.change (screen.getByRole ('textbox', { name: 'タグ名または ID' }), {
target: { value: '日本語タグ' } })
execute ()
await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('tag')).toBe ('12'))
expect (fetchTagByName).toHaveBeenCalledWith ('日本語タグ', 'ja')
})
it ('uses Japanese fallback metadata in the Ainu workspace without exposing keys', async () => {
const { container } = renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ain-Kana/counts/tags' })
const title = screen.getByRole ('heading', { name: 'タグの集計・明細' })
expect (title.querySelector ('[lang="ja"]')).toHaveTextContent ('タグの集計・明細')
expect (conditions ().querySelector ('[lang="ja"]')).toBeInTheDocument ()
fireEvent.click (conditions ())
await screen.findByRole ('table')
expect (container.textContent).not.toMatch (/counts:|columns\.|fields\.|axes\./)
})
it ('drills through an actor group while preserving the existing filters', async () => {
fetchCounts.mockResolvedValue ({ count: 3, groupCount: 1, page: 1, limit: 20,
groups: [{ key: 9, name: 'Actor name', count: 3 }] })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?axis=user&resource_id=7' })
const link = await screen.findByRole ('link', { name: 'Actor name' })
const url = new URL (link.getAttribute ('href')!, 'http://localhost')
expect (url.searchParams.get ('resource_id')).toBe ('7')
expect (url.searchParams.get ('user_id')).toBe ('9')
expect (url.searchParams.get ('mode')).toBe ('details')
})
it ('keeps partial-month boundaries when drilling through to details', async () => {
fetchCounts.mockResolvedValue ({ count: 4, groupCount: 1, page: 1, limit: 20,
groups: [{ key: '2024-01', name: null, count: 4 }] })
renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z' })
const link = await screen.findByRole ('link', { name: '2024-01' })
const url = new URL (link.getAttribute ('href')!, 'http://localhost')
expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z')
expect (url.searchParams.get ('before')).toBe ('2024-02-01T00:00Z')
expect (url.searchParams.get ('mode')).toBe ('details')
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 ('列数が上限')
})
})