import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import CountsPage from '@/pages/CountsPage' import CountsResourcePage from '@/pages/CountsResourcePage' 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 (), fetchCounts })) vi.mock ('@/lib/tags', async importOriginal => ({ ...await importOriginal (), fetchTagByName })) beforeEach (() => { fetchCounts.mockReset () fetchTagByName.mockReset () fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 }) }) const conditions = () => screen.getByRole ('button', { name: '集計条件' }) const execute = () => fireEvent.submit ( screen.getByRole ('button', { name: '集計実行' }).closest ('form')!) describe ('counts workspace', () => { it ('opens the posts workspace at the root with a resource sidebar', async () => { renderWithProviders (, { 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')) }) it ('preserves draft filters on collapse without querying until execution', async () => { renderWithProviders (, { 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) 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)) }) 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 (, { 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 (, { 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 (, { 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 (, { 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 (, { 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 (, { 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') }) })