画面上でのテストは完了. 移行にす早さが求められるので,準備ができたらマージする. Reviewed-on: #430 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #430 でマージされました。
このコミットが含まれているのは:
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createHelpFixture } from '@/test/helpFixtures'
|
||||
|
||||
|
||||
describe ('Help file catalogue', () => {
|
||||
it.each ([['Éclair', 'eclair'], ['home', 'HOME'], ['e\u0301clair', 'Éclair']]) (
|
||||
'compares title %s and %s equally at primary strength', (a, b) => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.compareHelpTitles (a, b)).toBe (0)
|
||||
})
|
||||
|
||||
it ('orders distinct Latin titles in both directions', () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.compareHelpTitles ('Apple', 'Zebra')).toBeLessThan (0)
|
||||
expect (api.compareHelpTitles ('Zebra', 'Apple')).toBeGreaterThan (0)
|
||||
})
|
||||
|
||||
it.each ([['ほーむ', 'ホーム'], ['ホーム', 'ほーむ'], ['ホーム', 'ホーム'],
|
||||
['ABC', 'abc'], ['ガイド', 'がいど']]) (
|
||||
'normalises search text %s and %s identically', (query, text) => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.normaliseHelpSearchText (query)).toBe (api.normaliseHelpSearchText (text))
|
||||
})
|
||||
|
||||
it.each ([['2026-10-02', null], ['2026-10-02.2', 2],
|
||||
['2026-10-02.3', 3], ['2026-10-02.10', 10]] as const) (
|
||||
'separates the date and revision of %s', (edition, revision) => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.parseHelpEdition (edition)).toEqual ({ date: '2026-10-02', revision })
|
||||
const date = api.helpEditionDate (edition)!
|
||||
expect ([date.getFullYear (), date.getMonth (), date.getDate ()]).toEqual ([2026, 9, 2])
|
||||
})
|
||||
|
||||
it.each (['missing', '2026-02-30', '2026-10-02.0', '2026-10-02.2.extra',
|
||||
'2026-10-02.9007199254740992']) ('rejects invalid edition %s', edition => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.parseHelpEdition (edition)).toBeNull ()
|
||||
expect (api.helpEditionDate (edition)).toBeNull ()
|
||||
})
|
||||
|
||||
it ('compares numeric revision suffixes without losing the edition identifiers', () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (['2026-10-02.10', '2026-10-02.3', '2026-10-02', '2026-10-02.2']
|
||||
.sort (api.compareHelpEditions)).toEqual ([
|
||||
'2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
|
||||
})
|
||||
|
||||
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition)
|
||||
.toBe ('2026-10-02.10')
|
||||
expect (await api.getHelpSearchEntries ('ja')).toEqual ([
|
||||
{ identifier: 'guide', edition: '2026-10-02.10',
|
||||
title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' },
|
||||
{ identifier: '設定 手順', edition: '2026-10-03',
|
||||
title: '設定', body: '\nタグを設定する' }])
|
||||
})
|
||||
|
||||
it ('orders published editions numerically and separates languages', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (await api.getHelpEditions ('guide', 'ja')).toEqual ([
|
||||
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
|
||||
expect (await api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
|
||||
expect (await api.getHelpPublishedEditions ('guide', 'ja'))
|
||||
.not.toContain ('2026-10-02.11')
|
||||
})
|
||||
|
||||
it ('uses generated edition titles without reparsing raw sources', async () => {
|
||||
const { api, raw } = createHelpFixture ()
|
||||
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
|
||||
.toEqual ({ title: '旧ホーム', body: '\n過去限定本文' })
|
||||
raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
.mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
|
||||
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-02'))
|
||||
.toEqual ({ title: 'ホーム', body: '\nタグを検索する' })
|
||||
expect (raw['../../help/guide/editions/ja/2026-10-02.mdx']).not.toHaveBeenCalled ()
|
||||
expect ((await api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))?.source)
|
||||
.toBe ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
|
||||
})
|
||||
|
||||
it ('returns missing resources without substituting another edition', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (await api.getHelpArticle ('absent')).toBeNull ()
|
||||
expect (await api.getHelpEditions ('absent', 'ja')).toEqual ([])
|
||||
expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull ()
|
||||
expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('reports generated search asset failures instead of hiding articles', async () => {
|
||||
const { api, loads } = createHelpFixture ()
|
||||
loads.search.mockImplementationOnce (() => { throw new Error ('asset failure') })
|
||||
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('asset failure')
|
||||
})
|
||||
|
||||
it ('reports raw loading failures and excludes unpublished editions', async () => {
|
||||
const { api, raw } = createHelpFixture ()
|
||||
const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
loader.mockRejectedValueOnce (new Error ('fixture failure'))
|
||||
await expect (api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))
|
||||
.rejects.toThrow ('fixture failure')
|
||||
expect (await api.getHelpEdition ('guide', 'ja', '2026-10-02.11')).toBeNull ()
|
||||
expect (await api.getHelpEditionSource ('guide', 'ja', '2026-10-02.11')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('compares raw H1 and body lines in the requested direction', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
const forward = await api.getHelpDiff ('guide', 'ja', '2026-10-01', '2026-10-02.10')
|
||||
expect (forward?.from.title).toBe ('旧ホーム')
|
||||
expect (forward?.to.title).toBe ('ホーム第十版')
|
||||
expect (forward?.lines).toEqual (expect.arrayContaining ([
|
||||
{ type: 'removed', content: '# 旧ホーム\n' },
|
||||
{ type: 'added', content: '# ホーム第十版\n' },
|
||||
{ type: 'unchanged', content: '\n' }]))
|
||||
const reverse = await api.getHelpDiff ('guide', 'ja', '2026-10-02.10', '2026-10-01')
|
||||
expect (reverse?.lines).toEqual (expect.arrayContaining ([
|
||||
{ type: 'added', content: '# 旧ホーム\n' },
|
||||
{ type: 'removed', content: '# ホーム第十版\n' }]))
|
||||
})
|
||||
|
||||
it.each ([['missing', 'ja', '2026-10-01', '2026-10-02.10'],
|
||||
['guide', 'ja', '2026-10-01', '2026-10-02.11'],
|
||||
['guide', 'en', '2026-10-01', '2026-10-02']]) (
|
||||
'rejects invalid diff editions for %s/%s without reading sources',
|
||||
async (id, language, from, to) => {
|
||||
const { api, raw } = createHelpFixture ()
|
||||
expect (await api.getHelpDiff (id, language, from, to)).toBeNull ()
|
||||
Object.values (raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
type ArticleLanguage = { currentEdition: string; publishedEditions: string[] }
|
||||
|
||||
export type ArticleMeta = {
|
||||
identifier: string
|
||||
canonicalLanguage: string
|
||||
languages: Record<string, ArticleLanguage> }
|
||||
|
||||
export type HelpEditionText = {
|
||||
title: string
|
||||
body: string }
|
||||
|
||||
export type HelpSearchEntry = HelpEditionText & {
|
||||
identifier: string
|
||||
edition: string }
|
||||
|
||||
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
|
||||
|
||||
export type HelpEditionSource = HelpEditionText & { source: string }
|
||||
export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & {
|
||||
pred: string | null
|
||||
change: 'new' | 'update'
|
||||
date: string }
|
||||
export type HelpTitleIndex = Record<string, string[]>
|
||||
export type HelpDiffLine = {
|
||||
type: 'added' | 'removed' | 'unchanged'
|
||||
content: string }
|
||||
export type HelpDiff = {
|
||||
from: HelpEditionSource
|
||||
to: HelpEditionSource
|
||||
lines: HelpDiffLine[] }
|
||||
|
||||
export type HelpEditionIdentifier = { date: string; revision: number | null }
|
||||
|
||||
// Fix the locale and primary strength; ICU versions still differ from MySQL UCA 9.0.
|
||||
const helpTitleCollator = new Intl.Collator ('en', {
|
||||
usage: 'sort', sensitivity: 'base', ignorePunctuation: false, numeric: false })
|
||||
|
||||
|
||||
export const compareHelpTitles = (a: string, b: string): number =>
|
||||
helpTitleCollator.compare (a, b)
|
||||
|
||||
|
||||
export const normaliseHelpSearchText = (value: string): string =>
|
||||
value.normalize ('NFKC').toLowerCase ().replace (/[ァ-ヶヽヾ]/g,
|
||||
character => String.fromCodePoint (character.codePointAt (0)! - 0x60))
|
||||
|
||||
|
||||
export const parseHelpEdition = (edition: string): HelpEditionIdentifier | null => {
|
||||
const matched = edition.match (/^(\d{4}-\d{2}-\d{2})(?:\.([1-9]\d*))?$/)
|
||||
if (matched == null)
|
||||
return null
|
||||
const date = new Date (matched[1])
|
||||
const revision = matched[2] == null ? null : Number (matched[2])
|
||||
if (Number.isNaN (date.getTime ()) || date.toISOString ().slice (0, 10) !== matched[1]
|
||||
|| (revision != null && !(Number.isSafeInteger (revision))))
|
||||
return null
|
||||
return { date: matched[1], revision }
|
||||
}
|
||||
|
||||
|
||||
export const compareHelpEditions = (a: string, b: string): number => {
|
||||
const before = parseHelpEdition (a)
|
||||
const after = parseHelpEdition (b)
|
||||
if (before == null || after == null)
|
||||
return a.localeCompare (b)
|
||||
return before.date.localeCompare (after.date)
|
||||
|| (before.revision ?? 1) - (after.revision ?? 1)
|
||||
}
|
||||
|
||||
|
||||
export const helpEditionDate = (edition: string): Date | null => {
|
||||
const parsed = parseHelpEdition (edition)
|
||||
return parsed == null ? null : new Date (`${ parsed.date }T00:00:00`)
|
||||
}
|
||||
|
||||
|
||||
export const getHelpArticle = async (identifier: string): Promise<ArticleMeta | null> => {
|
||||
const { default: articles } = await import ('virtual:help/articles')
|
||||
return Object.hasOwn (articles, identifier) ? articles[identifier] : null
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditions = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
): Promise<string[]> => {
|
||||
const article = await getHelpArticle (identifier)
|
||||
return article?.languages[language]?.publishedEditions ?? []
|
||||
}
|
||||
|
||||
|
||||
export const getHelpPublishedEditions = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
): Promise<string[]> => await getHelpEditions (identifier, language)
|
||||
|
||||
|
||||
export const getHelpEdition = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<ComponentType | null> => {
|
||||
const { components } = await import ('virtual:help/loaders')
|
||||
const loader = components[`${ identifier }/${ language }/${ edition }`]
|
||||
|
||||
if (!(loader))
|
||||
return null
|
||||
|
||||
return (await loader ()).default
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditionSource = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<HelpEditionSource | null> => {
|
||||
const { sources } = await import ('virtual:help/loaders')
|
||||
const loader = sources[`${ identifier }/${ language }/${ edition }`]
|
||||
|
||||
if (!(loader))
|
||||
return null
|
||||
|
||||
const [source, text] = await Promise.all ([
|
||||
loader (), getHelpEditionText (identifier, language, edition)])
|
||||
return text == null ? null : { source: source.default, ...text }
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditionText = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<HelpEditionText | null> => {
|
||||
const { texts } = await import ('virtual:help/loaders')
|
||||
const loader = texts[`${ identifier }/${ language }/${ edition }`]
|
||||
return loader == null ? null : (await loader ()).default
|
||||
}
|
||||
|
||||
|
||||
export const getHelpSearchEntries = async (
|
||||
language: string,
|
||||
): Promise<HelpSearchEntry[]> => {
|
||||
const { default: search } = await import ('virtual:help/search')
|
||||
return search[language] ?? []
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditionContent = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<HelpEditionContent | null> => {
|
||||
const [content, text] = await Promise.all ([
|
||||
getHelpEdition (identifier, language, edition),
|
||||
getHelpEditionText (identifier, language, edition)])
|
||||
|
||||
return content != null && text != null ? { content, ...text } : null
|
||||
}
|
||||
|
||||
|
||||
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
|
||||
const { default: changes } = await import ('virtual:help/changes')
|
||||
return changes[language] ?? []
|
||||
}
|
||||
|
||||
|
||||
export const getHelpTitleIndex = async (language: string): Promise<HelpTitleIndex> => {
|
||||
const { default: titles } = await import ('virtual:help/titles')
|
||||
return titles[language] ?? { }
|
||||
}
|
||||
|
||||
|
||||
const diffHelpLines = (from: string, to: string): HelpDiffLine[] => {
|
||||
const before = from.match (/[^\n]*\n|[^\n]+$/g) ?? []
|
||||
const after = to.match (/[^\n]*\n|[^\n]+$/g) ?? []
|
||||
const lengths = Array.from ({ length: before.length + 1 },
|
||||
() => new Uint32Array (after.length + 1))
|
||||
|
||||
for (let i = before.length - 1; i >= 0; --i)
|
||||
{
|
||||
for (let j = after.length - 1; j >= 0; --j)
|
||||
{
|
||||
lengths[i][j] =
|
||||
before[i] === after[j]
|
||||
? lengths[i + 1][j + 1] + 1
|
||||
: Math.max (lengths[i + 1][j], lengths[i][j + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const lines: HelpDiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < before.length || j < after.length)
|
||||
{
|
||||
if (i < before.length && j < after.length && before[i] === after[j])
|
||||
{
|
||||
lines.push ({ type: 'unchanged', content: before[i] })
|
||||
++i
|
||||
++j
|
||||
}
|
||||
else if (i < before.length
|
||||
&& (j === after.length || lengths[i + 1][j] >= lengths[i][j + 1]))
|
||||
{
|
||||
lines.push ({ type: 'removed', content: before[i] })
|
||||
++i
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.push ({ type: 'added', content: after[j] })
|
||||
++j
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
|
||||
export const getHelpDiff = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<HelpDiff | null> => {
|
||||
const editions = await getHelpPublishedEditions (identifier, language)
|
||||
if (!(editions.includes (from)) || !(editions.includes (to)))
|
||||
return null
|
||||
|
||||
const [before, after] = await Promise.all ([
|
||||
getHelpEditionSource (identifier, language, from),
|
||||
getHelpEditionSource (identifier, language, to)])
|
||||
if (before == null || after == null)
|
||||
return null
|
||||
|
||||
return { from: before, to: after, lines: diffHelpLines (before.source, after.source) }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import * as prefetchers from '@/lib/prefetchers'
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import HelpDiffPage from '@/pages/help/HelpDiffPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const settings = vi.hoisted (() => ({ linkPreload: 'intent' }))
|
||||
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
||||
useClientBehaviourSettings: () => settings }))
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
|
||||
const Location = () => {
|
||||
const location = useLocation ()
|
||||
return <output data-testid="location">{location.pathname}</output>
|
||||
}
|
||||
|
||||
|
||||
describe ('Help prefetch integration', () => {
|
||||
beforeEach (() => {
|
||||
helpFixture.reset ()
|
||||
settings.linkPreload = 'intent'
|
||||
vi.clearAllMocks ()
|
||||
})
|
||||
|
||||
it.each (['/help', '/help?title=ホーム&text=検索&page=2', '/help/guide', '/help/changes',
|
||||
'/help/guide/diff?from=2026-10-01&to=2026-10-02.10',
|
||||
'/help/%E8%A8%AD%E5%AE%9A%20%E6%89%8B%E9%A0%86?edition=2026-10-03']) (
|
||||
'registers a unique prefetcher for %s', path => {
|
||||
const url = new URL (path, window.location.origin)
|
||||
expect (prefetchers.routePrefetchers.filter (entry => entry.test (url))).toHaveLength (1)
|
||||
})
|
||||
|
||||
it ('loads and reuses the same MDX module for repeated prefetch and display', async () => {
|
||||
const { queryClient } = renderWithProviders (
|
||||
<>
|
||||
<PrefetchLink to="/help/guide">Help</PrefetchLink>
|
||||
<Routes>
|
||||
<Route path="/" element={null}/>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
</Routes>
|
||||
</>)
|
||||
const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||
await Promise.all ([
|
||||
prefetchers.prefetchForURL (queryClient, '/help/guide'),
|
||||
prefetchers.prefetchForURL (queryClient, '/help/guide')])
|
||||
expect (loader).toHaveBeenCalledTimes (1)
|
||||
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
|
||||
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||
expect (loader).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
|
||||
it ('prefetches the requested identifier and edition independently', async () => {
|
||||
const client = new QueryClient ()
|
||||
await prefetchers.prefetchForURL (client, '/help/guide?edition=2026-10-02.3')
|
||||
await prefetchers.prefetchForURL (client, `/help/${encodeURIComponent ('設定 手順')}`)
|
||||
expect (helpFixture.modules['../../help/guide/editions/ja/2026-10-02.3.mdx'])
|
||||
.toHaveBeenCalledTimes (1)
|
||||
expect (helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx'])
|
||||
.toHaveBeenCalledTimes (1)
|
||||
expect (helpFixture.modules['../../help/guide/editions/en/2026-10-02.mdx'])
|
||||
.not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('loads history editions without treating changes as an article', async () => {
|
||||
const article = vi.spyOn (helpFixture.api, 'getHelpArticle')
|
||||
await prefetchers.prefetchForURL (new QueryClient (), '/help/changes')
|
||||
expect (article).not.toHaveBeenCalledWith ('changes')
|
||||
expect (helpFixture.loads.changes).toHaveBeenCalledTimes (1)
|
||||
Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
})
|
||||
|
||||
it ('reuses two prefetched raw editions when displaying their diff', async () => {
|
||||
const path = '/help/guide/diff?from=2026-10-01&to=2026-10-02.10'
|
||||
const { queryClient } = renderWithProviders (
|
||||
<>
|
||||
<PrefetchLink to={path}>diff</PrefetchLink>
|
||||
<Routes>
|
||||
<Route path="/" element={null}/>
|
||||
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
|
||||
</Routes>
|
||||
</>)
|
||||
await Promise.all ([prefetchers.prefetchForURL (queryClient, path),
|
||||
prefetchers.prefetchForURL (queryClient, path)])
|
||||
fireEvent.click (screen.getByRole ('link', { name: 'diff' }))
|
||||
expect (await screen.findByRole ('link', { name: '旧ホーム (2026-10-01)' }))
|
||||
.toHaveAttribute ('href', '/help/guide?edition=2026-10-01')
|
||||
expect (screen.getByText ('+ # ホーム第十版')).toBeInTheDocument ()
|
||||
Object.entries (helpFixture.raw).forEach (([name, load]) => {
|
||||
const expected = /\/ja\/(2026-10-01|2026-10-02\.10)\.mdx$/.test (name) ? 1 : 0
|
||||
expect (load).toHaveBeenCalledTimes (expected)
|
||||
})
|
||||
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
})
|
||||
|
||||
it.each (['intent', 'off']) ('respects %s intent settings and still prefetches clicks',
|
||||
async mode => {
|
||||
settings.linkPreload = mode
|
||||
const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue ()
|
||||
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
|
||||
const link = screen.getByRole ('link', { name: 'Help' })
|
||||
fireEvent.mouseEnter (link)
|
||||
fireEvent.touchStart (link)
|
||||
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 2 : 0)
|
||||
fireEvent.click (link)
|
||||
await waitFor (() => expect (screen.getByTestId ('location'))
|
||||
.toHaveTextContent ('/help/guide'))
|
||||
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 3 : 1)
|
||||
})
|
||||
|
||||
it ('continues navigation after prefetch failure', async () => {
|
||||
vi.spyOn (console, 'error').mockImplementation (() => {})
|
||||
vi.spyOn (prefetchers, 'prefetchForURL').mockRejectedValue (new Error ('offline'))
|
||||
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
|
||||
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
|
||||
await waitFor (() => expect (screen.getByTestId ('location'))
|
||||
.toHaveTextContent ('/help/guide'))
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,13 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { match } from 'path-to-regexp'
|
||||
|
||||
import { getHelpArticle,
|
||||
getHelpChanges,
|
||||
getHelpDiff,
|
||||
getHelpEditionContent,
|
||||
getHelpSearchEntries } from '@/lib/help'
|
||||
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
||||
import { fetchWikiPage,
|
||||
fetchWikiPageByTitle,
|
||||
@@ -15,12 +20,69 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
|
||||
const mPost = match<{ id: string }> ('/posts/:id')
|
||||
const mWiki = match<{ title: string }> ('/wiki/:title')
|
||||
const mTag = match<{ id: string }> ('/tags/:id')
|
||||
const mHelp = match<{ identifier: string }> ('/help/:identifier')
|
||||
const mHelpDiff = match<{ identifier: string }> ('/help/:identifier/diff')
|
||||
|
||||
|
||||
const boolFromQuery = (value: string | null): boolean =>
|
||||
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
||||
|
||||
|
||||
const prefetchHelpIndex: Prefetcher = async qc => {
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: helpKeys.index ('ja'),
|
||||
queryFn: () => getHelpSearchEntries ('ja'),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchHelpShow: Prefetcher = async (qc, url) => {
|
||||
const matched = mHelp (url.pathname)
|
||||
if (!(matched))
|
||||
return
|
||||
|
||||
const { identifier } = matched.params
|
||||
const language = 'ja'
|
||||
const article = await qc.fetchQuery ({
|
||||
queryKey: helpKeys.article (identifier),
|
||||
queryFn: () => getHelpArticle (identifier),
|
||||
staleTime: Infinity })
|
||||
const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition
|
||||
if (edition == null)
|
||||
return
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: helpKeys.edition (identifier, language, edition),
|
||||
queryFn: () => getHelpEditionContent (identifier, language, edition),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchHelpChanges: Prefetcher = async qc => {
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: helpKeys.changes ('ja'),
|
||||
queryFn: () => getHelpChanges ('ja'),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchHelpDiff: Prefetcher = async (qc, url) => {
|
||||
const matched = mHelpDiff (url.pathname)
|
||||
const fromValues = url.searchParams.getAll ('from')
|
||||
const toValues = url.searchParams.getAll ('to')
|
||||
const [from] = fromValues
|
||||
const [to] = toValues
|
||||
if (!(matched) || fromValues.length !== 1 || toValues.length !== 1 || !(from) || !(to))
|
||||
return
|
||||
|
||||
const { identifier } = matched.params
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: helpKeys.diff (identifier, 'ja', from, to),
|
||||
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
||||
const title = url.searchParams.get ('title') ?? ''
|
||||
|
||||
@@ -223,6 +285,13 @@ const prefetchTagChanges: Prefetcher = async (qc, url) => {
|
||||
|
||||
|
||||
export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
|
||||
{ test: u => /^\/help\/?$/.test (u.pathname), run: prefetchHelpIndex },
|
||||
{ test: u => /^\/help\/changes\/?$/.test (u.pathname), run: prefetchHelpChanges },
|
||||
{ test: u => Boolean (mHelpDiff (u.pathname)), run: prefetchHelpDiff },
|
||||
{ test: u => {
|
||||
const matched = mHelp (u.pathname)
|
||||
return Boolean (matched && matched.params.identifier !== 'changes')
|
||||
}, run: prefetchHelpShow },
|
||||
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
|
||||
run: prefetchPostsIndex },
|
||||
{ test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname))
|
||||
|
||||
@@ -61,3 +61,14 @@ export const wikiKeys = {
|
||||
root: ['wiki'] as const,
|
||||
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
|
||||
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }
|
||||
|
||||
export const helpKeys = {
|
||||
root: ['help'] as const,
|
||||
index: (language: string) => ['help', 'index', language] as const,
|
||||
titles: (language: string) => ['help', 'titles', language] as const,
|
||||
article: (identifier: string) => ['help', 'article', identifier] as const,
|
||||
edition: (identifier: string, language: string, edition: string) =>
|
||||
['help', 'edition', identifier, language, edition] as const,
|
||||
diff: (identifier: string, language: string, from: string, to: string) =>
|
||||
['help', 'diff', identifier, language, from, to] as const,
|
||||
changes: (language: string) => ['help', 'changes', language] as const }
|
||||
|
||||
@@ -72,11 +72,25 @@ describe ('tags API functions', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it ('returns null when tag fetches fail', async () => {
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||
it ('returns null for a missing named tag while retaining the ID helper contract', async () => {
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||
api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
|
||||
|
||||
await expect (fetchTag ('1')).resolves.toBeNull ()
|
||||
await expect (fetchTagByName ('unknown')).resolves.toBeNull ()
|
||||
})
|
||||
|
||||
it.each ([403, 500]) ('propagates named tag HTTP %s', async status => {
|
||||
const error = { isAxiosError: true, response: { status } }
|
||||
api.apiGet.mockRejectedValueOnce (error)
|
||||
await expect (fetchTagByName ('ホーム')).rejects.toBe (error)
|
||||
})
|
||||
|
||||
it ('propagates named tag network failures and encodes the name', async () => {
|
||||
const error = new Error ('offline')
|
||||
api.apiGet.mockRejectedValueOnce (error)
|
||||
await expect (fetchTagByName ('名前/#%')).rejects.toBe (error)
|
||||
expect (api.apiGet).toHaveBeenLastCalledWith (
|
||||
`/tags/name/${ encodeURIComponent ('名前/#%') }`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { Deerjikist,
|
||||
@@ -68,11 +70,13 @@ export const fetchExternalTag = async (id: string): Promise<Tag | null> => {
|
||||
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/tags/name/${ name }`)
|
||||
return await apiGet (`/tags/name/${ encodeURIComponent (name) }`)
|
||||
}
|
||||
catch
|
||||
catch (error)
|
||||
{
|
||||
return null
|
||||
if (isAxiosError (error) && error.response?.status === 404)
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-17
@@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
}))
|
||||
const api = vi.hoisted (() => ({ apiGet: vi.fn () }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
@@ -21,28 +19,37 @@ describe ('wiki API functions', () => {
|
||||
await fetchWikiPage ('1', { version: '3' })
|
||||
|
||||
expect (api.apiGet).toHaveBeenNthCalledWith (
|
||||
1,
|
||||
'/wiki',
|
||||
{ params: { title: '虹' } },
|
||||
)
|
||||
1,
|
||||
'/wiki',
|
||||
{ params: { title: '虹' } })
|
||||
expect (api.apiGet).toHaveBeenNthCalledWith (
|
||||
2,
|
||||
'/wiki/1',
|
||||
{ params: { version: '3' } },
|
||||
)
|
||||
2,
|
||||
'/wiki/1',
|
||||
{ params: { version: '3' } })
|
||||
})
|
||||
|
||||
it ('encodes title path segments and returns null on misses', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ({ id: 2 })
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||
api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
|
||||
|
||||
await fetchWikiPageByTitle ('a/b c', { version: undefined })
|
||||
await expect (fetchWikiPageByTitle ('missing', {})).resolves.toBeNull ()
|
||||
await expect (fetchWikiPageByTitle ('missing', { })).resolves.toBeNull ()
|
||||
|
||||
expect (api.apiGet).toHaveBeenNthCalledWith (
|
||||
1,
|
||||
'/wiki/title/a%2Fb%20c',
|
||||
{ params: { version: undefined } },
|
||||
)
|
||||
1,
|
||||
'/wiki/title/a%2Fb%20c',
|
||||
{ params: { version: undefined } })
|
||||
})
|
||||
|
||||
it.each ([403, 500]) ('preserves HTTP %s errors', async status => {
|
||||
const error = { isAxiosError: true, response: { status } }
|
||||
api.apiGet.mockRejectedValueOnce (error)
|
||||
await expect (fetchWikiPageByTitle ('ホーム', { })).rejects.toBe (error)
|
||||
})
|
||||
|
||||
it ('preserves network failures for callers', async () => {
|
||||
const error = new Error ('network failure')
|
||||
api.apiGet.mockRejectedValueOnce (error)
|
||||
await expect (fetchWikiPageByTitle ('ホーム', { })).rejects.toBe (error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { WikiPage } from '@/types'
|
||||
@@ -21,8 +23,10 @@ export const fetchWikiPageByTitle = async (
|
||||
{
|
||||
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
|
||||
}
|
||||
catch
|
||||
catch (error)
|
||||
{
|
||||
return null
|
||||
if (isAxiosError (error) && error.response?.status === 404)
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする