このコミットが含まれているのは:
2026-10-29 19:49:05 +09:00
コミット e857a33ec3
14個のファイルの変更、1129行の追加、193行の削除
+119
ファイルの表示
@@ -0,0 +1,119 @@
import { fireEvent, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import CountsControls from '@/components/counts/CountsControls'
import CountsFieldTree from '@/components/counts/CountsFieldTree'
import { CountsDefinitionContext } from '@/lib/countsDefinitions'
import { normaliseCountsParams } from '@/lib/counts'
import { readCountState, setCountState, encodeCountState } from '@/lib/queryState'
import { postDefinition } from '@/test/fixtures/counts'
import { renderWithProviders } from '@/test/render'
vi.mock ('@/lib/api', async original => ({
...await original<typeof import ('@/lib/api')> (), apiGet: vi.fn ().mockResolvedValue ([]) }))
const appliedParams = (extra: Record<string, string> = {}) => normaliseCountsParams ('posts',
new URLSearchParams ({ mode: 'details', timezone: 'UTC', ...extra }), postDefinition)
const renderControls = (params: URLSearchParams, labelsParams = params.toString ()) => {
const execute = vi.fn ()
const result = renderWithProviders (
<CountsDefinitionContext.Provider value={postDefinition}>
<CountsControls resource="posts" applied={params} open setOpen={vi.fn ()}
execute={execute} reorder={vi.fn ()} displayJSON={vi.fn ()}
displayRowNumbers={vi.fn ()} displayThumbnail={vi.fn ()}
conditionLabels={{ tags: { 'internal:7': 'Resolved tag' } }}
conditionLabelsParams={labelsParams}/>
</CountsDefinitionContext.Provider>, { route: '/ja/counts/posts' })
return { ...result, execute }
}
const submit = () => fireEvent.submit (
screen.getByRole ('button', { name: '集計実行' }).closest ('form')!)
describe ('Counts filter draft', () => {
it ('uses labels only for the executed condition set', () => {
const params = appliedParams ()
setCountState (params, 'conditions', encodeCountState ('conditions', [
{ field: 'tags', operator: 'axis_eq', value: 'internal:7' }]))
const { execute } = renderControls (params)
expect (screen.getByText ('Resolved tag')).toBeInTheDocument ()
fireEvent.change (screen.getByRole ('textbox', { name: 'Id.' }), { target: { value: '9' } })
expect (screen.queryByText ('Resolved tag')).not.toBeInTheDocument ()
expect (screen.getByText ('…')).toBeInTheDocument ()
expect (screen.queryByText ('internal:7')).not.toBeInTheDocument ()
submit ()
expect (readCountState (execute.mock.lastCall![0], 'conditions', []))
.toEqual ([{ field: 'tags', operator: 'axis_eq', value: 'internal:7' }])
})
it ('removes a visible drill condition from the submitted URL state', () => {
const params = appliedParams ()
setCountState (params, 'conditions', encodeCountState ('conditions', [
{ field: 'created_at', grain: 'month', operator: 'axis_eq', value: '2026-10' }]))
const { execute } = renderControls (params)
expect (screen.getByText (/令和8年10月/)).toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '削除' }))
submit ()
expect (readCountState (execute.mock.lastCall![0], 'conditions', [])).toEqual ([])
})
it ('opens the existing add dialogue without inserting a condition immediately', () => {
const { execute } = renderControls (appliedParams ())
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (screen.getByRole ('dialog')).toBeInTheDocument ()
expect (execute).not.toHaveBeenCalled ()
expect (document.querySelectorAll ('[role="dialog"] select.h-11').length).toBeGreaterThan (0)
expect (screen.queryByText ('pattern.input')).not.toBeInTheDocument ()
})
it ('retains stable references only for surviving search tokens', () => {
const params = appliedParams ({ tags: 'alpha beta' })
setCountState (params, 'tag_refs', encodeCountState ('tag_refs', [
{ name: 'alpha', tag_id: 7 }, { name: 'beta', external_tag_id: 8 }]))
const { execute } = renderControls (params)
const tags = screen.getByDisplayValue ('alpha beta')
fireEvent.change (tags, { target: { value: 'not:beta gamma alpha' } })
submit ()
expect (readCountState (execute.mock.lastCall![0], 'tag_refs', [])).toEqual ([
{ name: 'alpha', tag_id: 7 }, { name: 'not:beta', external_tag_id: 8, negative: true }])
fireEvent.change (tags, { target: { value: 'gamma' } })
submit ()
expect (readCountState (execute.mock.lastCall![0], 'tag_refs', [])).toEqual ([])
expect (execute.mock.lastCall![0].get ('tags')).toBe ('gamma')
expect (screen.queryByText (/internal:7|external:8/)).not.toBeInTheDocument ()
})
})
const FieldTree = () => {
const [params, setParams] = useState (() => appliedParams ({ columns: 'tags',
children: 'tags.name_ja' }))
return <CountsDefinitionContext.Provider value={postDefinition}>
<CountsFieldTree resource="posts" params={params} update={(key, value) => setParams (previous => {
const next = new URLSearchParams (previous)
next.set (key, value)
return next
})}/>
<output data-testid="fields">{params.toString ()}</output>
</CountsDefinitionContext.Provider>
}
it ('derives mixed parent selection and permits selecting all children again after clearing', () => {
renderWithProviders (<FieldTree/>, { route: '/ja/counts/posts' })
const parent = screen.getByRole ('checkbox', { name: 'タグ' })
expect (parent).toHaveAttribute ('aria-checked', 'mixed')
expect (parent).toBeEnabled ()
fireEvent.click (parent)
expect (parent).toBeChecked ()
fireEvent.click (parent)
expect (parent).not.toBeChecked ()
expect (parent).toBeEnabled ()
fireEvent.click (parent)
expect (parent).toBeChecked ()
const params = new URLSearchParams (screen.getByTestId ('fields').textContent ?? '')
expect (params.get ('children')?.split (' ')).toEqual ([
'tags.name_ja', 'tags.name_ain', 'tags.aliases_ja', 'tags.aliases_ain'])
expect (params.get ('columns')).toBe ('tags')
})
+2 -1
ファイルの表示
@@ -72,7 +72,8 @@ const CountsNestedTable = (
{params.get ('nested_json') === '1' ? (
<pre data-counts-scroll
aria-label={title.text} lang={title.language}
className="max-h-72 w-full overflow-auto whitespace-pre text-xs">
className="max-h-72 w-full overflow-y-auto whitespace-pre-wrap
[overflow-wrap:anywhere] font-sans text-xs">
{JSON.stringify (countsNestedExport (rows, fields, path, params, locale))}
</pre>) : (columns.length > 0 && rows.length > 0 && (
<div data-counts-scroll
+105
ファイルの表示
@@ -0,0 +1,105 @@
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import CountsNestedTable from '@/components/counts/CountsNestedTable'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsTableLayout from '@/components/counts/CountsTableLayout'
import { useCountsTableLayout } from '@/lib/countsTableLayout'
import { renderWithProviders } from '@/test/render'
import type { CountField } from '@/lib/counts'
const renderTable = () => renderWithProviders (<CountsTableLayout>
<CountsSelectableTable family="details" columns={['resource_id', 'version_no']}
identity="stable" name={{ text: 'Grid', language: 'en' }}>
<tbody>
<tr><td><span>first</span> <span>cell</span></td><td>second</td></tr>
<tr><td>third</td><td><input aria-label="editor" defaultValue="editable"/></td></tr>
</tbody>
</CountsSelectableTable>
</CountsTableLayout>, { route: '/ja/counts/posts' })
describe ('Counts cell keyboard contract', () => {
it ('moves Home/End on rows and Ctrl+Home/End on columns with a stable Shift anchor', () => {
renderTable ()
const table = screen.getByRole ('grid', { name: 'Grid' })
table.focus ()
fireEvent.keyDown (table, { key: 'End' })
expect (screen.getByText ('second')).toHaveAttribute ('data-counts-selected')
fireEvent.keyDown (table, { key: 'End', ctrlKey: true, shiftKey: true })
expect (screen.getByText ('second')).toHaveAttribute ('data-counts-selected')
expect (screen.getByRole ('textbox').closest ('td')).toHaveAttribute ('data-counts-selected')
expect (screen.getByText ('third')).not.toHaveAttribute ('data-counts-selected')
fireEvent.keyDown (table, { key: 'Home' })
expect (screen.getByText ('third')).toHaveAttribute ('data-counts-selected')
fireEvent.keyDown (table, { key: 'Home', ctrlKey: true })
expect (screen.getByText ('first').closest ('td')).toHaveAttribute ('data-counts-selected')
})
it.each (['Enter', 'Escape']) ('leaves text mode with %s and restores the cell focus', key => {
renderTable ()
const table = screen.getByRole ('grid', { name: 'Grid' })
const cell = screen.getByText ('first').closest ('td')!
table.focus ()
fireEvent.keyDown (table, { key: 'Home' })
fireEvent.keyDown (table, { key: 'Enter' })
expect (cell).toHaveAttribute ('data-counts-text')
expect (window.getSelection ()?.toString ()).toBe ('first cell')
const setData = vi.fn ()
fireEvent.copy (table, { clipboardData: { setData } })
expect (setData).not.toHaveBeenCalled ()
fireEvent.keyDown (table, { key })
expect (cell).not.toHaveAttribute ('data-counts-text')
expect (cell).toHaveAttribute ('data-counts-selected')
expect (window.getSelection ()?.toString ()).toBe ('')
expect (table).toHaveFocus ()
fireEvent.copy (table, { clipboardData: { setData } })
expect (setData).toHaveBeenCalledWith ('text/plain', 'first cell')
expect (setData).toHaveBeenCalledWith ('text/html', expect.stringContaining ('first cell'))
})
it ('does not consume input keyboard events and uses the sum of column widths', () => {
renderTable ()
const input = screen.getByRole ('textbox')
input.focus ()
expect (fireEvent.keyDown (input, { key: 'Enter' })).toBe (true)
expect (input).toHaveFocus ()
expect (screen.getByRole ('grid', { name: 'Grid' })).toHaveStyle ({ width: '176px' })
})
})
const WidthControls = () => {
const layout = useCountsTableLayout ()
return <>
<output>{layout.width ('details', 'title')}</output>
<button onClick={() => layout.fit ('details', 'title', 1000)}>fit</button>
<button onClick={() => layout.resize ('details', 'title', 180)}>resize</button>
</>
}
it ('caps automatic header sizing and preserves manual widths on subsequent fitting', () => {
renderWithProviders (<CountsTableLayout><WidthControls/></CountsTableLayout>)
fireEvent.click (screen.getByRole ('button', { name: 'fit' }))
expect (screen.getByRole ('status')).toHaveTextContent ('640')
fireEvent.click (screen.getByRole ('button', { name: 'resize' }))
fireEvent.click (screen.getByRole ('button', { name: 'fit' }))
expect (screen.getByRole ('status')).toHaveTextContent ('180')
})
it ('wraps nested JSON without truncating, reordering or changing its data', () => {
const fields: CountField[] = [
{ key: 'id', label: 'counts:columns.resourceId' },
{ key: 'name', label: 'counts:columns.name' }]
const rows = [{ id: 1, name: `https://example.invalid/${'a'.repeat (300)}` },
{ id: 2, name: 'line one\nline two "quoted"' }]
const params = new URLSearchParams ({ children: 'tags.id tags.name', nested_json: '1' })
const { container } = renderWithProviders (<CountsNestedTable rows={rows} fields={fields}
path="tags" resource="posts" title={{ text: 'Tags', language: 'en' }}
params={params} update={vi.fn ()}/>, { route: '/ja/counts/posts' })
const pre = container.querySelector ('pre')!
expect (pre.textContent).toBe (JSON.stringify (rows))
expect (pre).toHaveClass ('whitespace-pre-wrap', '[overflow-wrap:anywhere]', 'font-sans')
expect (pre).not.toHaveClass ('truncate', 'whitespace-pre', 'select-none')
})
+146 -28
ファイルの表示
@@ -1,41 +1,159 @@
import { describe, expect, it } from 'vitest'
import { columnsForCounts, countColumns, countsCSV, normaliseCountsParams } from '@/lib/counts'
import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams,
countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts'
import { countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
from '@/lib/countsDisplay'
import { countsClipboard } from '@/lib/countsClipboard'
import { createUII18n, translateUIMessage } from '@/lib/i18n'
import { encodeCountState, readCountState, setCountState } from '@/lib/queryState'
import { isCountsPath } from '@/lib/routeLocales'
import { nameColumns, postDefinition } from '@/test/fixtures/counts'
import type { CountsCondition } from '@/lib/countsDefinitions'
describe ('counts exports', () => {
it ('resolves every output and CSV header key in both locales', () => {
for (const locale of ['ja', 'ain-Kana'])
{
const instance = createUII18n (locale)
for (const key of Object.values (countColumns))
{
const translated = translateUIMessage (key, 'counts', locale, { }, instance)
expect (translated.text).not.toBe (key)
expect (translated.text).not.toContain ('counts:')
}
const fallback = translateUIMessage ('run', 'counts', locale, { }, instance)
expect (fallback).toEqual ({ text: '集計実行', language: 'ja' })
}
describe ('Counts URL contract', () => {
it ('defaults to latest summary, limit 100, and no axes', () => {
const params = normaliseCountsParams ('posts', new URLSearchParams (), postDefinition)
expect (params.get ('definition')).toBe ('latest')
expect (params.get ('mode')).toBe ('summary')
expect (params.get ('limit')).toBe ('100')
expect (params.get ('rows')).toBe ('')
expect (params.get ('cols')).toBe ('')
expect (params.has ('axis')).toBe (false)
expect (normaliseCountsParams ('posts', params, postDefinition).toString ())
.toBe (params.toString ())
})
it ('makes the monthly default explicit for the existing API', () => {
expect (normaliseCountsParams ('posts', new URLSearchParams ()).get ('axis'))
.toBe ('month')
it.each (['latest', 'history']) ('shares detail defaults for %s', definition => {
const params = normaliseCountsParams ('posts', new URLSearchParams ({
definition, mode: 'details' }), postDefinition)
expect (params.get ('sort')).toBe ('resource_id:asc version_no:asc')
expect ([...selectedCountChildren (params)]).toEqual (['tags.name_ja'])
expect (countsNestedIsScalar (nameColumns, 'tags', params)).toBe (true)
})
it ('escapes quotes, newlines and spreadsheet formulas in UTF-8 CSV', () => {
expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']]))
.toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"')
it.each (['20', '50', '100']) ('preserves an explicit limit of %s and empty selections', limit => {
const params = normaliseCountsParams ('posts', new URLSearchParams ({
mode: 'details', limit, sort: '', columns: '', children: '', rows: '', cols: '' }),
postDefinition)
for (const key of ['sort', 'columns', 'children', 'rows', 'cols'])
expect (params.get (key)).toBe ('')
expect (params.get ('limit')).toBe (limit)
})
it ('keeps resource-specific columns out of the other resource', () => {
expect (columnsForCounts ('posts')).toContain ('added_tags')
expect (columnsForCounts ('posts')).not.toContain ('aliases')
expect (columnsForCounts ('tags')).toContain ('aliases')
expect (columnsForCounts ('tags')).not.toContain ('url')
expect (columnsForCounts ('posts')).not.toContain ('event_type')
expect (columnsForCounts ('tags')).not.toContain ('event_type')
it ('normalises saved display keys without broadening script-specific predicates', () => {
const params = new URLSearchParams ({ mode: 'details', columns: 'tags',
children: 'tags.name_ain_kana tags.aliases_ja' })
setCountState (params, 'conditions', encodeCountState ('conditions', [
{ field: 'name_ain_kana', operator: 'eq', value: 'recorded' }]))
const result = normaliseCountsParams ('posts', params, postDefinition)
expect (result.get ('children')).toBe ('tags.name_ain tags.aliases_ja')
expect (result.get ('conditions.0.field')).toBe ('name_ain_kana')
expect (countsNestedIsScalar (nameColumns, 'tags', result)).toBe (false)
})
it ('unifies legacy drill conditions while keeping typed values, grain and execution time', () => {
const params = new URLSearchParams ({ execution_at: '2024-03-01T00:00:00Z',
timezone: 'Asia/Tokyo' })
const drills = [{ field: 'created_at', grain: 'month', value: '2024-02' },
{ field: 'user', value: null }, { field: 'resource_id', value: 7 },
{ field: 'flag', value: false }]
setCountState (params, 'drill', encodeCountState ('drill', drills))
const result = normaliseCountsParams ('posts', params)
expect (readCountState (result, 'conditions', []))
.toEqual (drills.map (entry => ({ ...entry, operator: 'axis_eq' })))
expect ([...result.keys ()].some (key => key === 'drill' || key.startsWith ('drill.')))
.toBe (false)
expect (result.get ('execution_at')).toBe (params.get ('execution_at'))
expect (result.get ('timezone')).toBe ('Asia/Tokyo')
setCountState (result, 'conditions', encodeCountState ('conditions', []))
expect (readCountState (result, 'conditions', [])).toEqual ([])
})
it ('round-trips free text and empty sets without JSON or delimiter splitting', () => {
const conditions: CountsCondition[] = [
{ field: 'title', operator: 'eq', value: 'a & b = c "quoted"' },
{ field: 'category', operator: 'in', input: 'list', values: [] },
{ field: 'version_no', operator: 'in', values: [1, 2] }]
const params = new URLSearchParams ()
setCountState (params, 'conditions', encodeCountState ('conditions', conditions))
expect (params.get ('conditions')).toBe ('')
expect (params.get ('conditions.0.value')).toBe (conditions[0].value)
expect (readCountState (params, 'conditions', [])).toEqual (conditions)
setCountState (params, 'conditions', encodeCountState ('conditions', [conditions[0]]))
expect ([...params.keys ()].some (key => key.startsWith ('condition_values'))).toBe (false)
})
it ('resets definition-dependent state but preserves timezone and row-number preference', () => {
const previous = new URLSearchParams ({ tags: 'alpha', rows: 'user', page: '4',
at: '2024-01-01', execution_at: '2024-02-01', timezone: 'Asia/Tokyo', row_numbers: '0' })
expect (Object.fromEntries (countsDefinitionParams ('history', previous))).toEqual ({
definition: 'history', mode: 'details', timezone: 'Asia/Tokyo', row_numbers: '0' })
})
it ('excludes presentation settings from API identity but retains extraction conditions', () => {
const params = new URLSearchParams ({ definition: 'history', columns: 'title',
children: 'tags.name_ja', order: 'title', nested_json: '1', row_numbers: '1',
resource_id: '7', limit: '50', sort: 'version_no:desc' })
expect (Object.fromEntries (countsRequestParams (params))).toEqual ({
definition: 'history', resource_id: '7', limit: '50', sort: 'version_no:desc' })
})
})
describe ('Counts logical display and exports', () => {
it ('resolves output labels in both supported locales', () => {
for (const locale of ['ja', 'ain-Kana'])
for (const key of Object.values (countColumns))
{
const message = translateUIMessage (key, 'counts', locale, {}, createUII18n (locale))
expect (message.text).not.toBe (key)
expect (message.text).not.toContain ('counts:')
}
expect (columnsForCounts ('posts')).toContain ('event_type')
expect (columnsForCounts ('tags')).not.toContain ('aliases')
})
it ('distinguishes unresolved, inaccessible and registered condition names', () => {
const axis = { field: 'tags' }
expect (countsConditionValue ('internal:7', axis, 'ja', 'UTC')).toBe ('…')
expect (countsConditionValue ('internal:7', axis, 'ja', 'UTC', { tags: {} })).toBe ('—')
expect (countsConditionValue ('internal:7', axis, 'ja', 'UTC', {
tags: { 'internal:7': 'recorded tag' } })).toBe ('recorded tag')
expect (countsConditionValue (7, { field: 'user' }, 'ja', 'UTC', {
user: { '7': 'actor' } })).toBe ('actor')
expect (countsConditionValue ('title', { field: 'changed_field' }, 'ja', 'UTC'))
.toBe (translateUIMessage ('columns.postTitle', 'counts', 'ja').text)
})
it ('formats calendar grain values without changing their timezone or identifier', () => {
expect (countsConditionValue ('2026', { field: 'created_at', grain: 'year' },
'ja', 'Asia/Tokyo')).toContain ('令和8年')
expect (countsConditionValue ('2026-10', { field: 'created_at', grain: 'month' },
'ja', 'America/New_York')).toContain ('令和8年10月')
})
it ('keeps all inner rows when choosing one display column', () => {
const params = new URLSearchParams ({ children: 'tags.name_ja' })
const rows = [{ name_ja: 'alpha' }, { name_ja: 'beta' }]
expect (countsNestedScalarText (rows, nameColumns, 'tags', params, 'ja')).toBe ('alpha beta')
expect (rows).toEqual ([{ name_ja: 'alpha' }, { name_ja: 'beta' }])
})
it ('escapes CSV and clipboard formulas, quotes, newlines and HTML', () => {
expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']]))
.toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"')
const copy = countsClipboard ([['<tag>', 'first\nsecond', '=1+1']])
expect (copy.text).toBe ('<tag>\t"first\nsecond"\t\'=1+1')
expect (copy.html).toContain ('&lt;tag&gt;')
expect (copy.html).toContain ('first<br>second')
expect (copy.html).toContain ('&#39;=1+1')
})
it.each (['/counts', '/counts/posts?q=1', '/ja/counts/tags', '/ain-Kana/counts/materials']) (
'classifies %s as excluded from search and speculative loading', path => {
expect (isCountsPath (path.split ('?')[0])).toBe (true)
})
})
+12
ファイルの表示
@@ -35,6 +35,18 @@ const qc = () => new QueryClient ({
})
describe ('prefetchForURL', () => {
it.each (['/counts', '/counts/posts?rows=user', '/ja/counts/tags',
'/ain-Kana/counts/materials?mode=details']) (
'does not prefetch Counts page %s', async path => {
const client = qc ()
const prefetch = vi.spyOn (client, 'prefetchQuery')
const fetch = vi.spyOn (client, 'fetchQuery')
await prefetchForURL (client, path)
expect (prefetch).not.toHaveBeenCalled ()
expect (fetch).not.toHaveBeenCalled ()
expect (client.getQueryCache ().getAll ()).toHaveLength (0)
})
beforeEach (() => {
vi.clearAllMocks ()
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
+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 ('列数が上限')
})
})
+49
ファイルの表示
@@ -0,0 +1,49 @@
import type { CountField, CountsResult } from '@/lib/counts'
import type { CountsDefinition } from '@/lib/countsDefinitions'
// camelCase transport fixture for the definitions/query endpoints, not the legacy API.
export const nameColumns: CountField[] = [
{ key: 'name_ja', label: 'counts:localeName', labelValues: { language: '日本語' },
nameLocale: { code: 'ja', languageCode: 'ja' } },
{ key: 'name_ain', label: 'counts:localeName', labelValues: { language: '北海道アイヌ語' },
nameLocale: { code: 'ain', languageCode: 'ain' } },
{ key: 'aliases_ja', label: 'counts:localeAliases', labelValues: { language: '日本語' },
nameLocale: { code: 'ja', languageCode: 'ja' }, multipleValues: true },
{ key: 'aliases_ain', label: 'counts:localeAliases', labelValues: { language: '北海道アイヌ語' },
nameLocale: { code: 'ain', languageCode: 'ain' }, multipleValues: true }]
export const postDefinition: CountsDefinition = {
fields: [
{ key: 'resource_id', type: 'integer', nullable: false, operators: ['eq', 'ne'] },
{ key: 'version_no', type: 'integer', nullable: false, operators: ['eq'] },
{ key: 'title', type: 'string', nullable: true, operators: ['eq', 'like'] },
{ key: 'user', type: 'integer', nullable: true, operators: ['eq'] },
{ key: 'created_at', type: 'datetime', nullable: false, operators: ['eq', 'range'] }],
outputFields: [
{ key: 'resource_id', label: 'counts:columns.resourceId' },
{ key: 'version_no', label: 'counts:columns.version' },
{ key: 'latest', label: 'counts:columns.latest' },
{ key: 'title', label: 'counts:columns.postTitle' },
{ key: 'tags', label: 'counts:columns.tags', children: nameColumns }],
nameColumns,
columnAliases: [{ key: 'name_ain_kana', value: 'name_ain' }],
conditionAliases: [{ key: 'name_ain_kana', value: 'name_ain_kana' }],
axes: { latest: ['resource_id', 'created_at', 'user', 'tags'],
history: ['resource_id', 'created_at', 'user', 'tags', 'changed_field'] },
grains: ['year', 'month', 'day'],
defaults: [
{ definition: 'latest', mode: 'summary', params: [
{ key: 'rows', value: '' }, { key: 'cols', value: '' },
{ key: 'conditions', value: '' }] },
...['latest', 'history'].map (definition => ({ definition, mode: 'details', params: [
{ key: 'columns', value: 'resource_id version_no latest title tags' },
{ key: 'children', value: 'tags.name_ja' },
{ key: 'sort', value: 'resource_id:asc version_no:asc' }] }))] }
export const executionAt = '2024-03-01T00:00:00.000000Z'
export const detailResult: CountsResult = {
count: 1, page: 1, limit: 100, outputCount: 1, visibleOutputCount: 1,
excludedCount: 0, excludedCountScope: 'current_query', executionAt,
conditionLabels: {}, rows: [], cols: [], versions: [{
id: 10, resourceId: 7, versionNo: 1, latest: true, createdAt: executionAt,
createdByUser: null, title: 'Recorded title', parents: [], tags: [] }] }