このコミットが含まれているのは:
@@ -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')
|
||||
})
|
||||
新しいイシューから参照
ユーザーをブロックする