このコミットが含まれているのは:
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 ('<tag>')
|
||||
expect (copy.html).toContain ('first<br>second')
|
||||
expect (copy.html).toContain (''=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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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: [] }] }
|
||||
新しいイシューから参照
ユーザーをブロックする