このコミットが含まれているのは:
@@ -90,7 +90,9 @@ export const menuOutline = (
|
||||
{ name: '検索', to: '/help' },
|
||||
{ name: '全体履歴', to: '/help/changes' },
|
||||
{ component: <Separator/>, visible: helpVsbl },
|
||||
{ name: '履歴', to: `/help/changes?identifier=${ helpIdentifier }`, visible: helpVsbl }] },
|
||||
{ name: '履歴',
|
||||
to: `/help/changes?identifier=${ helpIdentifier }`,
|
||||
visible: helpVsbl }] },
|
||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
||||
{ name: '上映会 (β)', to: '/theatres/1' },
|
||||
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
||||
|
||||
+25
-18
@@ -6,51 +6,58 @@ import { createHelpFixture } from '@/test/helpFixtures'
|
||||
describe ('Help file catalogue', () => {
|
||||
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.getHelpArticle ('guide')?.languages.ja.currentEdition).toBe ('2026-10-02')
|
||||
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',
|
||||
title: 'ホーム', body: '\nタグを検索する' },
|
||||
{ identifier: 'guide', edition: '2026-10-02.10',
|
||||
title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' },
|
||||
{ identifier: '設定 手順', edition: '2026-10-03',
|
||||
title: '設定', body: '\nタグを設定する' }])
|
||||
})
|
||||
|
||||
it ('orders same-day editions numerically and separates languages', () => {
|
||||
it ('orders published editions numerically and separates languages', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.getHelpEditions ('guide', 'ja')).toEqual ([
|
||||
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 (api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
|
||||
expect (await api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
|
||||
expect (await api.getHelpPublishedEditions ('guide', 'ja'))
|
||||
.not.toContain ('2026-10-02.11')
|
||||
})
|
||||
|
||||
it ('derives each edition title from its first H1', async () => {
|
||||
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: '\r\n本文\r\n# 別見出し' })
|
||||
.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 (api.getHelpArticle ('absent')).toBeNull ()
|
||||
expect (api.getHelpEditions ('absent', 'ja')).toEqual ([])
|
||||
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 ('rejects a missing current file instead of silently hiding an article', async () => {
|
||||
const { api, raw } = createHelpFixture ()
|
||||
delete raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ()
|
||||
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 missing leading H1', async () => {
|
||||
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.getHelpSearchEntries ('ja')).rejects.toThrow ('fixture failure')
|
||||
loader.mockResolvedValueOnce ('本文\n# 遅い見出し')
|
||||
await expect (api.getHelpEditionText ('guide', 'ja', '2026-10-02')).rejects.toThrow ()
|
||||
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 ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,11 +23,13 @@ const Location = () => {
|
||||
|
||||
describe ('Help prefetch integration', () => {
|
||||
beforeEach (() => {
|
||||
helpFixture.reset ()
|
||||
settings.linkPreload = 'intent'
|
||||
vi.clearAllMocks ()
|
||||
})
|
||||
|
||||
it.each (['/help', '/help/guide', '/help/changes',
|
||||
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)
|
||||
@@ -43,13 +45,13 @@ describe ('Help prefetch integration', () => {
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
</Routes>
|
||||
</>)
|
||||
const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
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: 'ホーム' })
|
||||
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||
expect (loader).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
|
||||
@@ -69,11 +71,12 @@ describe ('Help prefetch integration', () => {
|
||||
const article = vi.spyOn (helpFixture.api, 'getHelpArticle')
|
||||
await prefetchers.prefetchForURL (new QueryClient (), '/help/changes')
|
||||
expect (article).not.toHaveBeenCalledWith ('changes')
|
||||
expect (helpFixture.raw['../../help/guide/editions/ja/2026-10-01.mdx'])
|
||||
.toHaveBeenCalled ()
|
||||
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.each (['intent', 'off', 'click']) ('respects %s intent settings and still prefetches clicks',
|
||||
it.each (['intent', 'off']) ('respects %s intent settings and still prefetches clicks',
|
||||
async mode => {
|
||||
settings.linkPreload = mode
|
||||
const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue ()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, screen, within } from '@testing-library/react'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
@@ -21,6 +21,8 @@ const tableSource = [
|
||||
|
||||
|
||||
describe ('Help MDX with the application compiler', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it ('compiles GFM table structure, links and emphasis', async () => {
|
||||
const Content = await compileMdx (tableSource)
|
||||
renderWithProviders (<Content/>)
|
||||
@@ -44,7 +46,8 @@ describe ('Help MDX with the application compiler', () => {
|
||||
|
||||
it ('keeps tables inside the Help dark-mode typography container', async () => {
|
||||
const Content = await compileMdx (tableSource)
|
||||
vi.spyOn (helpFixture.api, 'getHelpEdition').mockResolvedValue (Content)
|
||||
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||
.mockResolvedValue ({ default: Content })
|
||||
renderWithProviders (
|
||||
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
|
||||
{ route: '/help/guide' })
|
||||
@@ -52,9 +55,12 @@ describe ('Help MDX with the application compiler', () => {
|
||||
expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert')
|
||||
})
|
||||
|
||||
// jsdom cannot measure overflow. Requiring a particular Tailwind class rejects
|
||||
// valid CSS layouts, including the existing overflow-y-auto container.
|
||||
it.todo ('keeps wide tables within the viewport in a browser layout test')
|
||||
it ('preserves long cell contents when rendering a wide table', async () => {
|
||||
const cell = 'wide'.repeat (100)
|
||||
const Content = await compileMdx (`| 項目 |\n| --- |\n| ${ cell } |`)
|
||||
renderWithProviders (<Content/>)
|
||||
expect (screen.getByRole ('cell')).toHaveTextContent (cell)
|
||||
})
|
||||
|
||||
it.each (['.mdx?raw', '.mdx?url']) ('preserves the %s transform bypass', async suffix => {
|
||||
expect (await transformMdx (tableSource, suffix)).toBeNull ()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import App from '@/App'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
|
||||
vi.hoisted (() => {
|
||||
window.history.replaceState (null, '', '/help/changes')
|
||||
@@ -20,6 +21,8 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
||||
|
||||
|
||||
describe ('Help global edition history', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it ('routes /help/changes to published editions in date and numeric order', async () => {
|
||||
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
|
||||
const { container } = render (
|
||||
@@ -35,6 +38,15 @@ describe ('Help global edition history', () => {
|
||||
'/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02',
|
||||
'/help/guide?edition=2026-10-01'])
|
||||
expect (container).toHaveTextContent ('旧ホーム')
|
||||
expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent))
|
||||
.toEqual (['差分', 'タイトル', '有効開始年月日'])
|
||||
expect (container).not.toHaveTextContent ('未公開ホーム')
|
||||
expect (screen.getAllByRole ('link', { name: '差分' }).map (link =>
|
||||
link.getAttribute ('href'))).toEqual ([
|
||||
'/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10',
|
||||
'/help/guide/diff?from=2026-10-02.2&to=2026-10-02.3',
|
||||
'/help/guide/diff?from=2026-10-02&to=2026-10-02.2',
|
||||
'/help/guide/diff?from=2026-10-01&to=2026-10-02'])
|
||||
expect (apiGet).not.toHaveBeenCalled ()
|
||||
expect (window.location.pathname).toBe ('/help/changes')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
@@ -35,12 +35,15 @@ const renderDetail = (route = '/help/guide') => renderWithProviders (
|
||||
|
||||
|
||||
describe ('Help details', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it ('displays current_edition H1 and body even when newer files exist', async () => {
|
||||
renderDetail ()
|
||||
expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument ()
|
||||
expect (screen.getByText ('タグを検索する')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('第十版本文')).not.toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('ホーム ヘルプ'))
|
||||
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
||||
.toBeInTheDocument ()
|
||||
expect (screen.getByText ('タグを検索する 第十版本文')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('未公開限定本文')).not.toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('ホーム第十版 ヘルプ'))
|
||||
})
|
||||
|
||||
it ('uses the historical H1 in both page and document titles', async () => {
|
||||
@@ -66,7 +69,8 @@ describe ('Help details', () => {
|
||||
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each (['/help/missing', '/help/guide?edition=missing']) (
|
||||
it.each (['/help/missing', '/help/guide?edition=missing',
|
||||
'/help/guide?edition=2026-10-02.11']) (
|
||||
'shows NotFound without redirecting %s', async route => {
|
||||
renderDetail (route)
|
||||
expect (await screen.findByText ('404')).toBeInTheDocument ()
|
||||
@@ -81,9 +85,9 @@ describe ('Help details', () => {
|
||||
helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx']
|
||||
.mockReturnValueOnce (pending)
|
||||
renderDetail ()
|
||||
await screen.findByRole ('heading', { name: 'ホーム' })
|
||||
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch article' }))
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('タグを検索する 第十版本文')).not.toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).not.toContain ('ホーム'))
|
||||
await act (async () => finish ({ default: () => <h1>設定</h1> }))
|
||||
await screen.findByRole ('heading', { name: '設定' })
|
||||
@@ -95,9 +99,10 @@ describe ('Help details', () => {
|
||||
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
.mockReturnValueOnce (pending)
|
||||
const loader = helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||
loader.mockReturnValueOnce (pending)
|
||||
renderDetail ()
|
||||
await waitFor (() => expect (loader).toHaveBeenCalled ())
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch edition' }))
|
||||
await screen.findByRole ('heading', { name: 'ホーム第三版' })
|
||||
await act (async () => finish ({ default: () => <h1>遅延した旧本文</h1> }))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HelpListPage from '@/pages/help/HelpListPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
@@ -9,23 +9,25 @@ vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFi
|
||||
|
||||
|
||||
describe ('Help search', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it ('initially lists each current article once with encoded detail links', async () => {
|
||||
renderWithProviders (<HelpListPage/>, { route: '/help' })
|
||||
expect (await screen.findByRole ('link', { name: 'ホーム' }))
|
||||
expect (await screen.findByRole ('link', { name: 'ホーム第十版' }))
|
||||
.toHaveAttribute ('href', '/help/guide')
|
||||
expect (screen.getByRole ('link', { name: '設定' }))
|
||||
.toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`)
|
||||
expect (screen.getByText ('2 件')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('ホーム第十版')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each ([
|
||||
['ホーム', '', ['ホーム']],
|
||||
['', '検索', ['ホーム']],
|
||||
['', 'タグ', ['ホーム', '設定']],
|
||||
['ホーム', '', ['ホーム第十版']],
|
||||
['', '検索', ['ホーム第十版']],
|
||||
['', 'タグ', ['設定', 'ホーム第十版']],
|
||||
['ホーム', '設定', []],
|
||||
['ホーム', '検索', ['ホーム']],
|
||||
['ホーム', '検索', ['ホーム第十版']],
|
||||
['旧ホーム', '', []],
|
||||
['', '過去限定本文', []]]) (
|
||||
'searches title %s and body %s together', async (title, body, expected) => {
|
||||
@@ -43,10 +45,11 @@ describe ('Help search', () => {
|
||||
})
|
||||
|
||||
it ('displays loading failures without stale results', async () => {
|
||||
helpFixture.raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
.mockRejectedValueOnce (new Error ('fixture loading failed'))
|
||||
helpFixture.loads.search.mockImplementationOnce (() => {
|
||||
throw new Error ('fixture loading failed')
|
||||
})
|
||||
renderWithProviders (<HelpListPage/>)
|
||||
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
|
||||
expect (screen.queryByRole ('link', { name: 'ホーム' })).not.toBeInTheDocument ()
|
||||
expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { apiGet } from '@/lib/api'
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||
import { buildWikiPage } from '@/test/factories'
|
||||
import { editionRows, helpFixture } from '@/test/helpFixtures'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
||||
@@ -38,10 +38,7 @@ const renderWiki = (title: string) => {
|
||||
|
||||
describe ('Legacy Wiki to Help compatibility', () => {
|
||||
beforeEach (() => {
|
||||
editionRows.forEach (([id, language, edition, title, body]) => {
|
||||
helpFixture.raw[`../../help/${id}/editions/${language}/${edition}.mdx`]
|
||||
.mockReset ().mockResolvedValue (`# ${title}\n\n${body}`)
|
||||
})
|
||||
helpFixture.reset ()
|
||||
vi.mocked (apiGet).mockReset ()
|
||||
vi.mocked (apiGet).mockImplementation (async path => {
|
||||
if (path === '/wiki')
|
||||
@@ -57,31 +54,41 @@ describe ('Legacy Wiki to Help compatibility', () => {
|
||||
await screen.findByText ('Wiki の現行本文')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム']) (
|
||||
it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム', 'ホーム第十版']) (
|
||||
'redirects a unique current or historical Japanese title: %s', async title => {
|
||||
renderWiki (title)
|
||||
await waitFor (() => {
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/help/guide')
|
||||
})
|
||||
expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument ()
|
||||
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
||||
.toBeInTheDocument ()
|
||||
Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
expect (apiGet).toHaveBeenCalledWith (
|
||||
`/wiki/title/${encodeURIComponent (title)}`, expect.anything ())
|
||||
})
|
||||
|
||||
it ('keeps the original URL and displays NotFound when no title matches', async () => {
|
||||
const { route } = renderWiki ('該当なし')
|
||||
const { route, queryClient } = renderWiki ('該当なし')
|
||||
await screen.findByText ('404')
|
||||
await waitFor (() => {
|
||||
expect (helpFixture.loads.titles).toHaveBeenCalled ()
|
||||
expect (queryClient.isFetching ()).toBe (0)
|
||||
})
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not choose between articles with the same title', async () => {
|
||||
vi.spyOn (helpFixture.raw, '../../help/設定 手順/editions/ja/2026-10-03.mdx')
|
||||
.mockResolvedValue ('# ホーム\n\n別の記事')
|
||||
const { route } = renderWiki ('ホーム')
|
||||
helpFixture.assets.titles.default.ja['ホーム'] = ['guide', '設定 手順']
|
||||
const { route, queryClient } = renderWiki ('ホーム')
|
||||
await screen.findByText ('404')
|
||||
await waitFor (() => {
|
||||
expect (helpFixture.loads.titles).toHaveBeenCalled ()
|
||||
expect (queryClient.isFetching ()).toBe (0)
|
||||
})
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
})
|
||||
|
||||
@@ -94,5 +101,6 @@ describe ('Legacy Wiki to Help compatibility', () => {
|
||||
})
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,62 +1,58 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
||||
import { buildWikiPage } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
}))
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
describe ('WikiSearchPage', () => {
|
||||
describe ('Wiki resource identity', () => {
|
||||
beforeEach (() => vi.mocked (apiGet).mockReset ())
|
||||
|
||||
it ('loads initial wiki pages and renders links', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
|
||||
|
||||
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
|
||||
expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute (
|
||||
'href',
|
||||
'/wiki/%E8%99%B9%E5%A4%8F',
|
||||
)
|
||||
expect (api.apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
|
||||
expect (await screen.findByRole ('link', { name: '虹夏' }))
|
||||
.toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F')
|
||||
expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
|
||||
})
|
||||
|
||||
it ('searches by title on submit', async () => {
|
||||
api.apiGet
|
||||
.mockResolvedValueOnce ([])
|
||||
.mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })])
|
||||
|
||||
vi.mocked (apiGet).mockResolvedValueOnce ([])
|
||||
.mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
|
||||
await waitFor (() => expect (apiGet).toHaveBeenCalledTimes (1))
|
||||
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiGet).toHaveBeenLastCalledWith (
|
||||
'/wiki',
|
||||
{ params: { title: '検索' } },
|
||||
)
|
||||
})
|
||||
await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith (
|
||||
'/wiki', { params: { title: '検索' } }))
|
||||
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('marks deprecated wiki tags in the result title', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ([
|
||||
buildWikiPage ({
|
||||
title: '旧タグ',
|
||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||
}),
|
||||
])
|
||||
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({
|
||||
title: '旧タグ', deprecatedAt: '2026-06-01T00:00:00.000Z' })])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
expect (await screen.findByRole ('link', { name: '旧タグ' })).toBeInTheDocument ()
|
||||
expect (screen.getByText ('(廃止)').closest ('a')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('renders colliding description and group IDs with stable distinct keys', async () => {
|
||||
const errors = vi.spyOn (console, 'error').mockImplementation (() => {})
|
||||
const description = buildWikiPage ({ id: 51, title: '説明', resourceType: 'tag_description' })
|
||||
const group = buildWikiPage ({ id: 51, title: 'タググループ:一覧', resourceType: 'tag_group' })
|
||||
vi.mocked (apiGet).mockResolvedValue ([description, group])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
|
||||
const link = await screen.findByRole ('link', { name: '旧タグ' })
|
||||
const marker = screen.getByText ('(廃止)')
|
||||
|
||||
expect (link).toBeInTheDocument ()
|
||||
expect (marker.closest ('a')).toBeNull ()
|
||||
await screen.findByRole ('link', { name: '説明' })
|
||||
expect (screen.getByRole ('link', { name: 'タググループ:一覧' }))
|
||||
.toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`)
|
||||
await waitFor (() => {
|
||||
expect (errors.mock.calls.some (args => args.some (value =>
|
||||
String (value).includes ('same key')))).toBe (false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,7 +81,8 @@ const WikiSearchPage: FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map (page => (
|
||||
<tr key={page.id} className="even:bg-gray-100 dark:even:bg-gray-700">
|
||||
<tr key={`${ page.resourceType }:${ page.id }`}
|
||||
className="even:bg-gray-100 dark:even:bg-gray-700">
|
||||
<td className="p-2">
|
||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
|
||||
{page.title}
|
||||
|
||||
@@ -59,6 +59,7 @@ export const buildUser = (overrides: Partial<User> = {}): User => ({
|
||||
})
|
||||
|
||||
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
|
||||
resourceType: 'tag_description',
|
||||
id: 1,
|
||||
title: 'テストWiki',
|
||||
deprecatedAt: null,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
|
||||
import { vi } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
import source from '@/lib/help.ts?raw'
|
||||
|
||||
@@ -14,51 +13,113 @@ export const editionRows = [
|
||||
['guide', 'ja', '2026-10-02', 'ホーム', 'タグを検索する'],
|
||||
['guide', 'ja', '2026-10-02.2', 'ホーム第二版', '第二版本文'],
|
||||
['guide', 'ja', '2026-10-02.3', 'ホーム第三版', '第三版本文'],
|
||||
['guide', 'ja', '2026-10-02.10', 'ホーム第十版', '第十版本文'],
|
||||
['guide', 'ja', '2026-10-02.10', 'ホーム第十版', 'タグを検索する 第十版本文'],
|
||||
['guide', 'ja', '2026-10-02.11', '未公開ホーム', '未公開限定本文'],
|
||||
['guide', 'en', '2026-10-02', 'Home', 'English body'],
|
||||
['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const
|
||||
|
||||
const compiled = transpileModule (source.replaceAll ('import.meta', 'helpMeta'), {
|
||||
const compiled = transpileModule (source, {
|
||||
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
|
||||
|
||||
|
||||
// Replace Vite's file-discovery boundary, executing the actual Help module.
|
||||
// Supply generated virtual modules, executing the actual runtime Help module.
|
||||
export const createHelpFixture = () => {
|
||||
const articles: Record<string, string> = {
|
||||
'../../help/guide/article.yml':
|
||||
'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02\n'
|
||||
+ ' en:\n current_edition: 2026-10-02\n',
|
||||
'../../help/設定 手順/article.yml':
|
||||
'identifier: 設定 手順\nlanguages:\n ja:\n current_edition: 2026-10-03\n' }
|
||||
const articles: Record<string, Help.ArticleMeta> = {
|
||||
guide: { identifier: 'guide', canonicalLanguage: 'ja', languages: {
|
||||
ja: { currentEdition: '2026-10-02.10', publishedEditions: [
|
||||
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] },
|
||||
en: { currentEdition: '2026-10-02', publishedEditions: ['2026-10-02'] } } },
|
||||
'設定 手順': { identifier: '設定 手順', canonicalLanguage: 'ja', languages: {
|
||||
ja: { currentEdition: '2026-10-03', publishedEditions: ['2026-10-03'] } } } }
|
||||
const published = editionRows.filter (([id, language, edition]) =>
|
||||
articles[id].languages[language].publishedEditions.includes (edition))
|
||||
const raw = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
|
||||
`../../help/${id}/editions/${language}/${edition}.mdx`,
|
||||
vi.fn (async () => `# ${title}\n\n${body}`)]))
|
||||
const moduleLoads = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
|
||||
const moduleLoads = Object.fromEntries (published.map (([id, language, edition, title, body]) => [
|
||||
`../../help/${id}/editions/${language}/${edition}.mdx`,
|
||||
vi.fn (async (): Promise<{ default: () => ReactElement }> => ({
|
||||
default: () => createElement ('div', null,
|
||||
createElement ('h1', null, title), createElement ('p', null, body)) }))]))
|
||||
// Dynamic imports reuse a module namespace even when called more than once.
|
||||
const caches: Record<string, { promise?: Promise<{ default: () => ReactElement }> }> = { }
|
||||
const modules = Object.fromEntries (Object.entries (moduleLoads).map (([path, load]) => {
|
||||
const cache: { promise?: ReturnType<typeof load> } = { }
|
||||
return [path, vi.fn (() => cache.promise ??= load ())]
|
||||
caches[path] = { }
|
||||
return [path, vi.fn (() => caches[path].promise ??= load ())]
|
||||
}))
|
||||
const glob = (pattern: string, options?: { query?: string }) => {
|
||||
if (pattern.endsWith ('article.yml'))
|
||||
return articles
|
||||
if (pattern.endsWith ('*.mdx'))
|
||||
return options?.query === '?raw' ? raw : modules
|
||||
throw new Error (`Unexpected Help glob: ${pattern}`)
|
||||
}
|
||||
const texts = Object.fromEntries (published.map (([id, language, edition, title, body]) => [
|
||||
`${ id }/${ language }/${ edition }`,
|
||||
vi.fn (async () => ({ default: { title, body: `\n${ body }` } }))]))
|
||||
const components = Object.fromEntries (published.map (([id, language, edition]) => [
|
||||
`${ id }/${ language }/${ edition }`,
|
||||
modules[`../../help/${ id }/editions/${ language }/${ edition }.mdx`]]))
|
||||
const sources = Object.fromEntries (published.map (([id, language, edition]) => [
|
||||
`${ id }/${ language }/${ edition }`,
|
||||
async () => ({ default: await raw[
|
||||
`../../help/${ id }/editions/${ language }/${ edition }.mdx`] () })]))
|
||||
const search: Record<string, Help.HelpSearchEntry[]> = { }
|
||||
const changes: Record<string, Help.HelpChange[]> = { }
|
||||
const titles: Record<string, Help.HelpTitleIndex> = { }
|
||||
published.forEach (([id, language, edition, title, body]) => {
|
||||
const editions = articles[id].languages[language].publishedEditions
|
||||
const index = editions.indexOf (edition)
|
||||
const searchRows = search[language] ??= []
|
||||
if (articles[id].languages[language].currentEdition === edition)
|
||||
searchRows.push ({ identifier: id, edition, title, body: `\n${ body }` })
|
||||
const rows = changes[language] ??= []
|
||||
rows.push ({ identifier: id, edition, title,
|
||||
pred: editions[index - 1] ?? null,
|
||||
change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] })
|
||||
const lookup = (titles[language] ??= { })
|
||||
const identifiers = lookup[title.trim ().normalize ('NFKC').toLowerCase ()] ??= []
|
||||
if (!(identifiers.includes (id)))
|
||||
identifiers.push (id)
|
||||
})
|
||||
Object.values (changes).forEach (rows => rows.sort ((a, b) => {
|
||||
const [dateA, revisionA = '1'] = a.edition.split ('.')
|
||||
const [dateB, revisionB = '1'] = b.edition.split ('.')
|
||||
return dateB.localeCompare (dateA) || Number (revisionB) - Number (revisionA)
|
||||
|| a.identifier.localeCompare (b.identifier)
|
||||
}))
|
||||
const assets = {
|
||||
articles: { default: articles }, search: { default: search },
|
||||
changes: { default: changes }, titles: { default: titles } }
|
||||
const snapshot = JSON.stringify (assets)
|
||||
const loaders = { components, sources, texts }
|
||||
const loads = {
|
||||
articles: vi.fn (() => assets.articles), search: vi.fn (() => assets.search),
|
||||
changes: vi.fn (() => assets.changes), titles: vi.fn (() => assets.titles),
|
||||
loaders: vi.fn (() => loaders) }
|
||||
const api = { } as typeof Help
|
||||
const requireFixture = (name: string) => {
|
||||
if (name === 'yaml')
|
||||
return { parse }
|
||||
const key = name.replace ('virtual:help/', '') as keyof typeof loads
|
||||
if (Object.hasOwn (loads, key))
|
||||
return loads[key] ()
|
||||
throw new Error (`Unexpected Help dependency: ${name}`)
|
||||
}
|
||||
new Function ('require', 'exports', 'helpMeta', compiled) (
|
||||
requireFixture, api, { glob })
|
||||
return { api, articles, raw, modules, moduleLoads }
|
||||
new Function ('require', 'exports', compiled) (requireFixture, api)
|
||||
const reset = () => {
|
||||
Object.assign (assets, JSON.parse (snapshot))
|
||||
editionRows.forEach (([id, language, edition, title, body]) => {
|
||||
const path = `../../help/${ id }/editions/${ language }/${ edition }.mdx`
|
||||
raw[path].mockReset ().mockResolvedValue (`# ${ title }\n\n${ body }`)
|
||||
})
|
||||
published.forEach (([id, language, edition, title, body]) => {
|
||||
const path = `../../help/${ id }/editions/${ language }/${ edition }.mdx`
|
||||
caches[path].promise = undefined
|
||||
moduleLoads[path].mockClear ()
|
||||
modules[path].mockReset ().mockImplementation (() =>
|
||||
caches[path].promise ??= moduleLoads[path] ())
|
||||
texts[`${ id }/${ language }/${ edition }`].mockReset ().mockResolvedValue ({
|
||||
default: { title, body: `\n${ body }` } })
|
||||
})
|
||||
loads.articles.mockReset ().mockImplementation (() => assets.articles)
|
||||
loads.search.mockReset ().mockImplementation (() => assets.search)
|
||||
loads.changes.mockReset ().mockImplementation (() => assets.changes)
|
||||
loads.titles.mockReset ().mockImplementation (() => assets.titles)
|
||||
loads.loaders.mockReset ().mockImplementation (() => loaders)
|
||||
}
|
||||
return { api, assets, loads, raw, modules, moduleLoads, texts, reset }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -452,6 +452,7 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha
|
||||
|
||||
export type WikiPage = {
|
||||
id: number
|
||||
resourceType: 'tag_description' | 'tag_group'
|
||||
title: string
|
||||
deprecatedAt: string | null
|
||||
createdUserId: number
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする