ヘルプを Wiki から分離 (#424) (#430)

画面上でのテストは完了.
移行にす早さが求められるので,準備ができたらマージする.

Reviewed-on: #430
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #430 でマージされました。
このコミットが含まれているのは:
2026-10-13 12:25:11 +09:00
committed by みてるぞ
コミット e44887cf15
85個のファイルの変更、4126行の追加、764行の削除
+130
ファイルの表示
@@ -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 ())
})
})
+237
ファイルの表示
@@ -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) }
}
+126
ファイルの表示
@@ -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'))
})
})
+70 -1
ファイルの表示
@@ -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))
+11
ファイルの表示
@@ -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 }
+16 -2
ファイルの表示
@@ -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 ('名前/#%') }`)
})
})
+7 -3
ファイルの表示
@@ -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)
})
})
+6 -2
ファイルの表示
@@ -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
}
}