このコミットが含まれているのは:
2026-10-12 23:23:31 +09:00
コミット 5246ac5c61
14個のファイルの変更、360行の追加、58行の削除
+30
ファイルの表示
@@ -4,6 +4,36 @@ import { createHelpFixture } from '@/test/helpFixtures'
describe ('Help file catalogue', () => { describe ('Help file catalogue', () => {
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 () => { it ('uses current_edition rather than the newest file, without YAML titles', async () => {
const { api } = createHelpFixture () const { api } = createHelpFixture ()
expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition) expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition)
+36
ファイルの表示
@@ -31,6 +31,42 @@ export type HelpDiff = {
to: HelpEditionSource to: HelpEditionSource
lines: HelpDiffLine[] } lines: HelpDiffLine[] }
export type HelpEditionIdentifier = { date: string; revision: number | null }
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> => { export const getHelpArticle = async (identifier: string): Promise<ArticleMeta | null> => {
const { default: articles } = await import ('virtual:help/articles') const { default: articles } = await import ('virtual:help/articles')
+16 -2
ファイルの表示
@@ -72,11 +72,25 @@ describe ('tags API functions', () => {
) )
}) })
it ('returns null when tag fetches fail', async () => { it ('returns null for a missing named tag while retaining the ID helper contract', async () => {
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
api.apiGet.mockRejectedValueOnce (new Error ('missing')) api.apiGet.mockRejectedValueOnce (new Error ('missing'))
api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
await expect (fetchTag ('1')).resolves.toBeNull () await expect (fetchTag ('1')).resolves.toBeNull ()
await expect (fetchTagByName ('unknown')).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 ('名前/#%') }`)
})
}) })
+6 -2
ファイルの表示
@@ -1,3 +1,5 @@
import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { Deerjikist, 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> => { export const fetchTagByName = async (name: string): Promise<Tag | null> => {
try try
{ {
return await apiGet (`/tags/name/${ name }`) return await apiGet (`/tags/name/${ encodeURIComponent (name) }`)
} }
catch catch (error)
{ {
if (isAxiosError (error) && error.response?.status === 404)
return null return null
throw error
} }
} }
+2
ファイルの表示
@@ -41,6 +41,8 @@ describe ('Help global edition history', () => {
expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent)) expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent))
.toEqual (['差分', 'タイトル', '有効開始年月日']) .toEqual (['差分', 'タイトル', '有効開始年月日'])
expect (container).not.toHaveTextContent ('未公開ホーム') expect (container).not.toHaveTextContent ('未公開ホーム')
expect (container).not.toHaveTextContent ('Invalid Date')
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
expect (screen.getAllByRole ('link', { name: '差分' }).map (link => expect (screen.getAllByRole ('link', { name: '差分' }).map (link =>
link.getAttribute ('href'))).toEqual ([ link.getAttribute ('href'))).toEqual ([
'/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10', '/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10',
+5 -3
ファイルの表示
@@ -8,7 +8,7 @@ import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { getHelpChanges } from '@/lib/help' import { getHelpChanges, helpEditionDate } from '@/lib/help'
import { helpKeys } from '@/lib/queryKeys' import { helpKeys } from '@/lib/queryKeys'
import { LIST_LIMIT_OPTIONS } from '@/lib/settings' import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
import { dateString } from '@/lib/utils' import { dateString } from '@/lib/utils'
@@ -85,7 +85,7 @@ const HelpChangesPage: FC = () => {
const diffQuery = new URLSearchParams ({ const diffQuery = new URLSearchParams ({
from: entry.pred ?? '', to: entry.edition }) from: entry.pred ?? '', to: entry.edition })
const diffTo = `${ path }/diff?${ diffQuery.toString () }` const diffTo = `${ path }/diff?${ diffQuery.toString () }`
const updatedAt = `${ entry.date }T00:00:00` const updatedAt = helpEditionDate (entry.edition)
return ( return (
<tr key={`${ entry.identifier }/${ entry.edition }`} <tr key={`${ entry.identifier }/${ entry.edition }`}
@@ -102,7 +102,9 @@ const HelpChangesPage: FC = () => {
{entry.title} {entry.title}
</PrefetchLink> </PrefetchLink>
</td> </td>
<td className="p-2">{dateString (updatedAt, 'hour')}</td> <td className="p-2">
{updatedAt != null && dateString (updatedAt, 'hour')}
</td>
</tr>) </tr>)
})} })}
</tbody> </tbody>
+15
ファイルの表示
@@ -37,6 +37,20 @@ const renderDetail = (route = '/help/guide') => renderWithProviders (
describe ('Help details', () => { describe ('Help details', () => {
beforeEach (() => helpFixture.reset ()) beforeEach (() => helpFixture.reset ())
it.each (['2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']) (
'displays a date without inventing a time for edition %s', async edition => {
const { container } = renderDetail (`/help/guide?edition=${ edition }`)
await screen.findByRole ('heading')
expect (container).toHaveTextContent ('10月2日')
expect (container).not.toHaveTextContent ('Invalid Date')
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
const revision = edition.split ('.')[1]
if (revision != null)
expect (container).toHaveTextContent (` .${ revision }`)
expect (helpFixture.modules[`../../help/guide/editions/ja/${ edition }.mdx`])
.toHaveBeenCalled ()
})
it ('displays current_edition H1 and body even when newer files exist', async () => { it ('displays current_edition H1 and body even when newer files exist', async () => {
renderDetail () renderDetail ()
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' })) expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
@@ -70,6 +84,7 @@ describe ('Help details', () => {
}) })
it.each (['/help/missing', '/help/guide?edition=missing', it.each (['/help/missing', '/help/guide?edition=missing',
'/help/guide?edition=2026-02-30', '/help/guide?edition=2026-10-02.2.extra',
'/help/guide?edition=2026-10-02.11']) ( '/help/guide?edition=2026-10-02.11']) (
'shows NotFound without redirecting %s', async route => { 'shows NotFound without redirecting %s', async route => {
renderDetail (route) renderDetail (route)
+12 -3
ファイルの表示
@@ -5,7 +5,10 @@ import { useLocation, useParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { getHelpArticle, getHelpEditionContent } from '@/lib/help' import { getHelpArticle,
getHelpEditionContent,
helpEditionDate,
parseHelpEdition } from '@/lib/help'
import { dateString } from '@/lib/utils' import { dateString } from '@/lib/utils'
import { helpKeys } from '@/lib/queryKeys' import { helpKeys } from '@/lib/queryKeys'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
@@ -31,6 +34,8 @@ const HelpDetailPage: FC = () => {
const languageMeta = article?.languages[language] const languageMeta = article?.languages[language]
const edition = requestedEdition ?? languageMeta?.currentEdition const edition = requestedEdition ?? languageMeta?.currentEdition
const editionInfo = parseHelpEdition (edition ?? '')
const editionDate = helpEditionDate (edition ?? '')
const editions = languageMeta?.publishedEditions ?? [] const editions = languageMeta?.publishedEditions ?? []
@@ -42,7 +47,7 @@ const HelpDetailPage: FC = () => {
: undefined : undefined
const { data: current, isSuccess, isError } = useQuery ({ const { data: current, isSuccess, isError } = useQuery ({
enabled: edition != null && editions.includes (edition), enabled: edition != null && editionInfo != null && editions.includes (edition),
queryKey: helpKeys.edition (identifier, language, edition ?? ''), queryKey: helpKeys.edition (identifier, language, edition ?? ''),
queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''), queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''),
staleTime: Infinity }) staleTime: Infinity })
@@ -51,6 +56,7 @@ const HelpDetailPage: FC = () => {
&& (article == null && (article == null
|| languageMeta == null || languageMeta == null
|| edition == null || edition == null
|| editionInfo == null
|| !(editions.includes (edition)))) || !(editions.includes (edition))))
return <NotFound/> return <NotFound/>
@@ -76,7 +82,10 @@ const HelpDetailPage: FC = () => {
</PrefetchLink>) </PrefetchLink>)
: '(最古)'} : '(最古)'}
<span>{dateString (edition, 'hour')}</span> <span>
{editionDate != null && dateString (editionDate, 'hour')}
{editionInfo?.revision != null && ` .${ editionInfo.revision }`}
</span>
{succ {succ
? ( ? (
+61
ファイルの表示
@@ -52,4 +52,65 @@ describe ('Help search', () => {
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed') expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument () expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument ()
}) })
it ('sorts edition suffixes numerically and displays their date without a time', async () => {
const editions = ['2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']
helpFixture.assets.search.default.ja = editions.map ((edition, index) => ({
identifier: `article-${ index }`, edition, title: `article-${ index }`, body: '' }))
const { container } = renderWithProviders (<HelpListPage/>, { route: '/help' })
await screen.findByText ('4 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link => link.textContent))
.toEqual (['article-3', 'article-2', 'article-1', 'article-0'])
const dates = Array.from (container.querySelectorAll ('tbody tr'), row =>
row.children[1].textContent)
expect (dates).toHaveLength (4)
dates.forEach (date => {
expect (date).toContain ('10月2日')
expect (date).not.toContain ('Invalid Date')
expect (date).not.toMatch (/\d{1,2}:\d{2}/)
})
})
it.each ([['ホーム', 'ほーむ', '', true], ['ほーむ', 'ホーム', '', true],
['ホーム', 'ホーム', '', true], ['ホーム', '', 'ホーム', true],
['ホーム', 'ほーむ', 'ホーム', true], ['ホーム', 'ほーむ', '不一致', false]] as const) (
'searches title %s with %s and body with %s', async (title, query, text, found) => {
const entry = { identifier: 'guide', edition: '2026-10-02.10',
title, body: 'ほーむの案内' }
helpFixture.assets.search.default.ja = [entry]
const params = new URLSearchParams ({ title: query, text })
renderWithProviders (<HelpListPage/>, { route: `/help?${ params }` })
await screen.findByText (`${ found ? 1 : 0 } 件`)
if (found)
expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/help/guide')
else
expect (screen.queryByRole ('link', { name: title })).not.toBeInTheDocument ()
expect (entry).toEqual ({ identifier: 'guide', edition: '2026-10-02.10',
title, body: 'ほーむの案内' })
})
// ai_ci equality is documented by MySQL; these are not captured DB sort results.
it.each (['asc', 'desc']) ('uses identifier ties for MySQL ai_ci title order %s',
async direction => {
helpFixture.assets.search.default.ja = [
{ identifier: 'alpha', edition: '2026-10-02', title: 'Éclair', body: '' },
{ identifier: 'beta', edition: '2026-10-02', title: 'eclair', body: '' }]
const { container } = renderWithProviders (<HelpListPage/>, {
route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
})
it.each (['asc', 'desc']) ('ignores title case before identifier ties in order %s',
async direction => {
helpFixture.assets.search.default.ja = [
{ identifier: 'alpha', edition: '2026-10-02', title: 'home', body: '' },
{ identifier: 'beta', edition: '2026-10-02', title: 'HOME', body: '' }]
const { container } = renderWithProviders (<HelpListPage/>, {
route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
})
}) })
+13 -10
ファイルの表示
@@ -10,7 +10,10 @@ import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { getHelpSearchEntries } from '@/lib/help' import { compareHelpEditions,
getHelpSearchEntries,
helpEditionDate,
normaliseHelpSearchText } from '@/lib/help'
import { helpKeys } from '@/lib/queryKeys' import { helpKeys } from '@/lib/queryKeys'
import { LIST_LIMIT_OPTIONS } from '@/lib/settings' import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
@@ -55,22 +58,20 @@ const HelpSearchPage: FC = () => {
}, [conditions.title, conditions.text]) }, [conditions.title, conditions.text])
const results = useMemo (() => { const results = useMemo (() => {
const titleQuery = conditions.title.trim ().normalize ('NFKC').toLowerCase () const titleQuery = normaliseHelpSearchText (conditions.title.trim ())
const textQuery = conditions.text.trim ().normalize ('NFKC').toLowerCase () const textQuery = normaliseHelpSearchText (conditions.text.trim ())
const [field, direction] = order.split (':') const [field, direction] = order.split (':')
return ( return (
entries.filter (entry => ( entries.filter (entry => (
(!(titleQuery) (!(titleQuery)
|| entry.title.normalize ('NFKC').toLowerCase ().includes (titleQuery)) || normaliseHelpSearchText (entry.title).includes (titleQuery))
&& (!(textQuery) && (!(textQuery)
|| entry.body.normalize ('NFKC').toLowerCase ().includes (textQuery)))) || normaliseHelpSearchText (entry.body).includes (textQuery))))
.sort ((a, b) => { .sort ((a, b) => {
const [dateA, revisionA = '1'] = a.edition.split ('.')
const [dateB, revisionB = '1'] = b.edition.split ('.')
const comparison = field === 'title' const comparison = field === 'title'
? a.title.localeCompare (b.title, 'ja') ? a.title.localeCompare (b.title, 'ja')
: (dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB)) : compareHelpEditions (a.edition, b.edition)
return (direction === 'asc' ? comparison : -comparison) return (direction === 'asc' ? comparison : -comparison)
|| a.identifier.localeCompare (b.identifier) || a.identifier.localeCompare (b.identifier)
@@ -180,7 +181,7 @@ const HelpSearchPage: FC = () => {
</thead> </thead>
<tbody> <tbody>
{pageResults.map (entry => { {pageResults.map (entry => {
const updatedAt = new Date (entry.edition.replace (/\..*$/, '')) const updatedAt = helpEditionDate (entry.edition)
return ( return (
<tr key={entry.identifier} <tr key={entry.identifier}
@@ -192,7 +193,9 @@ const HelpSearchPage: FC = () => {
{entry.title} {entry.title}
</PrefetchLink> </PrefetchLink>
</td> </td>
<td className="p-2">{dateString (updatedAt, 'hour')}</td> <td className="p-2">
{updatedAt != null && dateString (updatedAt, 'hour')}
</td>
</tr>) </tr>)
})} })}
</tbody> </tbody>
+92 -3
ファイルの表示
@@ -1,14 +1,18 @@
import { screen } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiPost } from '@/lib/api'
import { getHelpTitleIndex } from '@/lib/help'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildTag, buildWikiPage } from '@/test/factories' import WikiNewPage from '@/pages/wiki/WikiNewPage'
import { buildTag, buildUser, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const wikiApi = vi.hoisted (() => ({ const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (), fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (), fetchWikiPageByTitle: vi.fn (),
fetchWikiPages: vi.fn (),
})) }))
const tagsApi = vi.hoisted (() => ({ const tagsApi = vi.hoisted (() => ({
@@ -22,8 +26,93 @@ const postsApi = vi.hoisted (() => ({
vi.mock ('@/lib/wiki', () => wikiApi) vi.mock ('@/lib/wiki', () => wikiApi)
vi.mock ('@/lib/tags', () => tagsApi) vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () }))
vi.mock ('@/lib/api', () => ({ apiPost: vi.fn () }))
vi.mock ('@/components/ui/use-toast', () => ({ toast: vi.fn () }))
vi.mock ('react-markdown-editor-lite', () => ({
default: ({ value, onChange }: {
value: string
onChange: (event: { text: string }) => void }) => (
<textarea aria-label="本文エディタ" value={value}
onChange={event => onChange ({ text: event.target.value })}/>) }))
describe ('WikiDetailPage', () => { describe ('WikiDetailPage', () => {
beforeEach (() => {
wikiApi.fetchWikiPageByTitle.mockReset ().mockResolvedValue (null)
wikiApi.fetchWikiPages.mockReset ().mockResolvedValue ([])
tagsApi.fetchTagByName.mockReset ().mockResolvedValue (null)
postsApi.fetchPosts.mockReset ().mockResolvedValue ({ posts: [], count: 0 })
vi.mocked (getHelpTitleIndex).mockReset ().mockResolvedValue ({ })
vi.mocked (apiPost).mockReset ()
})
it ('offers creation for an existing tag after the Help lookup completes', async () => {
let finish!: (index: Record<string, string[]>) => void
const pending = new Promise<Record<string, string[]>> (resolve => {
finish = resolve
})
vi.mocked (getHelpTitleIndex).mockReturnValueOnce (pending)
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ', hasWiki: false }))
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
await waitFor (() => expect (getHelpTitleIndex).toHaveBeenCalled ())
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
await act (async () => finish ({ }))
expect (await screen.findByRole ('link', { name: '新規作成してください' }))
.toHaveAttribute ('href', `/wiki/new?title=${ encodeURIComponent ('既存タグ') }`)
})
it ('creates a tag description through the existing form and displays its content', async () => {
const page = buildWikiPage ({ title: '既存タグ', body: '説明文',
resourceType: 'tag_description' })
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false }))
vi.mocked (apiPost).mockResolvedValueOnce (page)
renderWithProviders (
<Routes>
<Route path="/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>, { route: `/wiki/${ encodeURIComponent (page.title) }` })
fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' }))
await screen.findByRole ('heading', { name: '新規 Wiki ページ' })
wikiApi.fetchWikiPageByTitle.mockResolvedValue (page)
expect (screen.getAllByRole ('textbox')[0]).toHaveValue (page.title)
fireEvent.change (screen.getByRole ('textbox', { name: '本文エディタ' }), {
target: { value: page.body } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText (page.body)).toBeInTheDocument ()
const form = vi.mocked (apiPost).mock.calls[0][1] as FormData
expect (form.get ('title')).toBe (page.title)
expect (form.get ('body')).toBe (page.body)
expect (apiPost).toHaveBeenCalledWith ('/wiki', form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
})
it ('shows NotFound when neither a tag nor a Help title exists', async () => {
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: '/wiki/missing' })
expect (await screen.findByText ('404')).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
})
it.each (['403', '500', 'offline']) ('keeps Wiki error %s distinct from missing content',
async message => {
wikiApi.fetchWikiPageByTitle.mockRejectedValue (new Error (message))
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ' }))
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
expect (await screen.findByRole ('alert')).toHaveTextContent (message)
expect (getHelpTitleIndex).not.toHaveBeenCalled ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
})
it ('renders deprecated state outside the wiki title link', async () => { it ('renders deprecated state outside the wiki title link', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({ wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
title: '旧タグ', title: '旧タグ',
+26 -27
ファイルの表示
@@ -1,4 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
@@ -29,48 +29,39 @@ const WikiDetailPage: FC = () => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const queryClient = useQueryClient ()
const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title]) const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title])
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const version = query.get ('version') || undefined const version = query.get ('version') || undefined
const { data: wikiPage, isLoading: loading, isSuccess, isFetching } = useQuery ({ const { data: wikiPage, isLoading: loading, isSuccess, isFetching, error } = useQuery ({
enabled: Boolean (title) && !(/^\d+$/.test (title)), enabled: Boolean (title) && !(/^\d+$/.test (title)),
queryKey: wikiKeys.show (title, { version }), queryKey: wikiKeys.show (title, { version }),
queryFn: () => fetchWikiPageByTitle (title, { version }) }) queryFn: () => fetchWikiPageByTitle (title, { version }) })
useEffect (() => { const missingWiki = isSuccess && !(isFetching) && wikiPage == null
if (!(isSuccess) || isFetching || wikiPage != null) const { data: titleIndex, isPending: lookupPending,
return isFetching: lookupFetching, error: lookupError } = useQuery ({
enabled: missingWiki,
let active = true
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
void queryClient.fetchQuery ({
queryKey: helpKeys.titles ('ja'), queryKey: helpKeys.titles ('ja'),
queryFn: () => getHelpTitleIndex ('ja'), queryFn: () => getHelpTitleIndex ('ja'),
staleTime: Infinity }).then (index => { staleTime: Infinity })
if (!(active)) const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
return const identifiers = useMemo (() => (
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
const identifiers = Object.hasOwn (index, lookupTitle) ? index[lookupTitle] : [] useEffect (() => {
if (identifiers.length === 1) if (missingWiki && !(lookupFetching) && lookupError == null && identifiers.length === 1)
navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true }) navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true })
}).catch (() => { }, [missingWiki, lookupFetching, lookupError, identifiers, navigate])
// A failed Help lookup leaves the original missing Wiki URL in place.
})
return () => {
active = false
}
}, [isSuccess, isFetching, wikiPage, title, version, queryClient, navigate])
const effectiveTitle = wikiPage?.title ?? title const effectiveTitle = wikiPage?.title ?? title
const deprecated = wikiPage?.deprecatedAt != null const deprecated = wikiPage?.deprecatedAt != null
const { data: tag } = useQuery ({ const { data: tag, isPending: tagPending,
isFetching: tagFetching, error: tagError } = useQuery ({
enabled: Boolean (effectiveTitle), enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) }) queryFn: () => fetchTagByName (effectiveTitle) })
@@ -121,7 +112,15 @@ const WikiDetailPage: FC = () => {
} }
}, [title, navigate]) }, [title, navigate])
if (isSuccess && wikiPage == null) const displayError = error ?? (missingWiki ? lookupError ?? tagError : null)
if (displayError != null)
return <MainArea><p role="alert">{displayError.message}</p></MainArea>
const waiting = loading || (isFetching && wikiPage == null)
|| (missingWiki && (lookupPending || lookupFetching || tagPending
|| (tagFetching && tag == null) || identifiers.length === 1))
if (missingWiki && !(waiting) && (tag == null || version != null))
return <NotFound/> return <NotFound/>
return ( return (
@@ -157,7 +156,7 @@ const WikiDetailPage: FC = () => {
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{deprecated && <span>(廃止)</span>} {deprecated && <span>(廃止)</span>}
</h1> </h1>
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} {waiting ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
{(!(version) && posts.length > 0) && ( {(!(version) && posts.length > 0) && (
<div className="not-prose"> <div className="not-prose">
+41 -3
ファイルの表示
@@ -1,8 +1,9 @@
import { screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom' import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import * as help from '@/lib/help'
import HelpDetailPage from '@/pages/help/HelpDetailPage' import HelpDetailPage from '@/pages/help/HelpDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildWikiPage } from '@/test/factories' import { buildWikiPage } from '@/test/factories'
@@ -17,7 +18,14 @@ vi.mock ('@/lib/posts', () => ({ fetchPosts: async () => ({ posts: [], count: 0
const Location = () => { const Location = () => {
const location = useLocation () const location = useLocation ()
return <output data-testid="location">{location.pathname}{location.search}</output> const navigate = useNavigate ()
return (
<>
<output data-testid="location">{location.pathname}{location.search}</output>
<button onClick={() => navigate (`/wiki/${ encodeURIComponent ('該当なし') }`)}>
switch title
</button>
</>)
} }
@@ -92,6 +100,36 @@ describe ('Legacy Wiki to Help compatibility', () => {
expect (screen.getByTestId ('location')).toHaveTextContent (route) expect (screen.getByTestId ('location')).toHaveTextContent (route)
}) })
it ('waits for title lookup and does not redirect a later Wiki URL using an old title',
async () => {
let finish!: (index: Record<string, string[]>) => void
const pending = new Promise<Record<string, string[]>> (resolve => {
finish = resolve
})
const lookup = vi.spyOn (help, 'getHelpTitleIndex').mockReturnValueOnce (pending)
renderWiki ('ホーム')
await waitFor (() => expect (lookup).toHaveBeenCalled ())
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: 'switch title' }))
await waitFor (() => expect (apiGet).toHaveBeenCalledWith (
`/wiki/title/${ encodeURIComponent ('該当なし') }`, expect.anything ()))
await act (async () => finish ({ 'ホーム': ['guide'] }))
await screen.findByText ('404')
expect (screen.getByTestId ('location'))
.toHaveTextContent (`/wiki/${ encodeURIComponent ('該当なし') }`)
})
it ('does not treat a Wiki network failure as a missing Wiki', async () => {
vi.mocked (apiGet).mockRejectedValueOnce (new Error ('offline'))
const { route } = renderWiki ('ホーム')
expect (await screen.findByRole ('alert')).toHaveTextContent ('offline')
expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
})
it.each ([403, 500]) ('does not redirect HTTP %s to Help', async status => { it.each ([403, 500]) ('does not redirect HTTP %s to Help', async status => {
vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } }) vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } })
const { route, queryClient } = renderWiki ('ホーム') const { route, queryClient } = renderWiki ('ホーム')
+1 -1
ファイルの表示
@@ -59,7 +59,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
const data = await apiPost<WikiPage> ('/wiki', formData, const data = await apiPost<WikiPage> ('/wiki', formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }) { headers: { 'Content-Type': 'multipart/form-data' } })
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate (`/wiki/${ data.title }`) navigate (`/wiki/${ encodeURIComponent (data.title) }`)
} }
catch (e) catch (e)
{ {