このコミットが含まれているのは:
2026-10-29 19:49:05 +09:00
コミット e857a33ec3
14個のファイルの変更、1129行の追加、193行の削除
+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 })