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