このコミットが含まれているのは:
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
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.getHelpSearchEntries ('ja')).toEqual ([
|
||||
{ identifier: 'guide', edition: '2026-10-02',
|
||||
title: 'ホーム', body: '\nタグを検索する' },
|
||||
{ identifier: '設定 手順', edition: '2026-10-03',
|
||||
title: '設定', body: '\nタグを設定する' }])
|
||||
})
|
||||
|
||||
it ('orders same-day editions numerically and separates languages', () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (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'])
|
||||
})
|
||||
|
||||
it ('derives each edition title from its first H1', 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# 別見出し' })
|
||||
})
|
||||
|
||||
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.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 raw loading failures and missing leading H1', 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 ()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
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 { 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 (() => {
|
||||
settings.linkPreload = 'intent'
|
||||
vi.clearAllMocks ()
|
||||
})
|
||||
|
||||
it.each (['/help', '/help/guide', '/help/changes',
|
||||
'/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 client = new QueryClient ()
|
||||
const loader = helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
await Promise.all ([
|
||||
prefetchers.prefetchForURL (client, '/help/guide'),
|
||||
prefetchers.prefetchForURL (client, '/help/guide')])
|
||||
expect (loader).toHaveBeenCalledTimes (1)
|
||||
renderWithProviders (
|
||||
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
|
||||
{ route: '/help/guide' })
|
||||
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.raw['../../help/guide/editions/ja/2026-10-01.mdx'])
|
||||
.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it.each (['intent', 'click', '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'))
|
||||
})
|
||||
})
|
||||
+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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, screen, within } from '@testing-library/react'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import { compileMdx, transformMdx } from '@/test/compileMdx'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const prefetch = vi.hoisted (() => vi.fn (async () => {}))
|
||||
vi.hoisted (() => {
|
||||
vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor)
|
||||
})
|
||||
afterAll (() => vi.unstubAllGlobals ())
|
||||
vi.mock ('@/lib/prefetchers', () => ({ prefetchForURL: prefetch }))
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
const tableSource = [
|
||||
'# 表のある記事', '', '| 項目 | 説明 |', '| --- | --- |',
|
||||
'| [ヘルプ](/help/guide) | **強調** |', '| 通常 | 本文 |'].join ('\n')
|
||||
|
||||
|
||||
describe ('Help MDX with the application compiler', () => {
|
||||
it ('compiles GFM table structure, links and emphasis', async () => {
|
||||
const Content = await compileMdx (tableSource)
|
||||
renderWithProviders (<Content/>)
|
||||
const table = screen.getByRole ('table')
|
||||
expect (table.querySelector ('thead')).not.toBeNull ()
|
||||
expect (table.querySelector ('tbody')).not.toBeNull ()
|
||||
expect (within (table).getAllByRole ('columnheader')).toHaveLength (2)
|
||||
expect (within (table).getAllByRole ('cell')).toHaveLength (4)
|
||||
expect (within (table).getByRole ('link', { name: 'ヘルプ' }))
|
||||
.toHaveAttribute ('href', '/help/guide')
|
||||
expect (table.querySelector ('strong')).toHaveTextContent ('強調')
|
||||
})
|
||||
|
||||
it ('retains headings, paragraphs and lists', async () => {
|
||||
const Content = await compileMdx ('# 見出し\n\n本文\n\n- 第一\n- 第二')
|
||||
renderWithProviders (<Content/>)
|
||||
expect (screen.getByRole ('heading', { level: 1 })).toHaveTextContent ('見出し')
|
||||
expect (screen.getByText ('本文').tagName).toBe ('P')
|
||||
expect (screen.getAllByRole ('listitem')).toHaveLength (2)
|
||||
})
|
||||
|
||||
it ('keeps tables inside the Help dark-mode typography container', async () => {
|
||||
const Content = await compileMdx (tableSource)
|
||||
vi.spyOn (helpFixture.api, 'getHelpEdition').mockResolvedValue (Content)
|
||||
renderWithProviders (
|
||||
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
|
||||
{ route: '/help/guide' })
|
||||
const table = await screen.findByRole ('table')
|
||||
expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert')
|
||||
})
|
||||
|
||||
it ('contains wide tables in a horizontal scroll area', async () => {
|
||||
const Content = await compileMdx (tableSource.replace ('通常', 'W'.repeat (200)))
|
||||
vi.spyOn (helpFixture.api, 'getHelpEdition').mockResolvedValue (Content)
|
||||
renderWithProviders (
|
||||
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
|
||||
{ route: '/help/guide' })
|
||||
const table = await screen.findByRole ('table')
|
||||
expect (table.closest ('.overflow-x-auto, .overflow-x-scroll')).not.toBeNull ()
|
||||
})
|
||||
|
||||
it.each (['.mdx?raw', '.mdx?url']) ('preserves the %s transform bypass', async suffix => {
|
||||
expect (await transformMdx (tableSource, suffix)).toBeNull ()
|
||||
})
|
||||
|
||||
it ('uses the common PrefetchLink interaction for internal MDX links', async () => {
|
||||
prefetch.mockClear ()
|
||||
const Content = await compileMdx ('[内部リンク](/help/guide)')
|
||||
renderWithProviders (<Content/>)
|
||||
fireEvent.mouseEnter (screen.getByRole ('link', { name: '内部リンク' }))
|
||||
expect (prefetch).toHaveBeenCalledWith (expect.anything (),
|
||||
new URL ('/help/guide', window.location.origin).href)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
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 { apiGet } from '@/lib/api'
|
||||
import App from '@/App'
|
||||
|
||||
vi.hoisted (() => {
|
||||
window.history.replaceState (null, '', '/help/changes')
|
||||
})
|
||||
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn (), apiPost: async () => ({}),
|
||||
isApiError: () => false }))
|
||||
vi.mock ('@/components/TopNav', () => ({ default: () => null }))
|
||||
vi.mock ('@/components/DevModeWatermark', () => ({ default: () => null }))
|
||||
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
||||
useClientBehaviourSettings: () => ({ animation: 'off', linkPreload: 'off' }) }))
|
||||
|
||||
|
||||
describe ('Help global edition history', () => {
|
||||
it ('routes /help/changes to published editions in date and numeric order', async () => {
|
||||
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
|
||||
const { container } = render (
|
||||
<QueryClientProvider client={client}>
|
||||
<HelmetProvider><App/></HelmetProvider>
|
||||
</QueryClientProvider>)
|
||||
const links = await screen.findAllByRole ('link')
|
||||
const editions = links.map (link => link.getAttribute ('href'))
|
||||
.filter (href => href?.startsWith ('/help/') && href.includes ('edition='))
|
||||
expect (editions).toEqual ([
|
||||
`/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`,
|
||||
'/help/guide?edition=2026-10-02.10', '/help/guide?edition=2026-10-02.3',
|
||||
'/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02',
|
||||
'/help/guide?edition=2026-10-01'])
|
||||
expect (container).toHaveTextContent ('旧ホーム')
|
||||
expect (apiGet).not.toHaveBeenCalled ()
|
||||
expect (window.location.pathname).toBe ('/help/changes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
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 HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
|
||||
const Navigation = () => {
|
||||
const navigate = useNavigate ()
|
||||
const location = useLocation ()
|
||||
return (
|
||||
<>
|
||||
<output data-testid="location">{location.pathname}{location.search}</output>
|
||||
<button onClick={() => navigate ('/help/設定%20手順')}>switch article</button>
|
||||
<button onClick={() => navigate ('/help/guide?edition=2026-10-02.3')}>
|
||||
switch edition
|
||||
</button>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
const renderDetail = (route = '/help/guide') => renderWithProviders (
|
||||
<>
|
||||
<Navigation/>
|
||||
<Routes>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
</Routes>
|
||||
</>, { route })
|
||||
|
||||
|
||||
describe ('Help details', () => {
|
||||
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 ('ホーム ヘルプ'))
|
||||
})
|
||||
|
||||
it ('uses the historical H1 in both page and document titles', async () => {
|
||||
renderDetail ('/help/guide?edition=2026-10-01')
|
||||
expect (await screen.findByRole ('heading', { name: '旧ホーム' })).toBeInTheDocument ()
|
||||
expect (screen.getByText ('過去限定本文')).toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('旧ホーム ヘルプ'))
|
||||
expect (screen.getByText ('(最古)')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('link', { name: '新 >' }))
|
||||
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02')
|
||||
})
|
||||
|
||||
it ('navigates .2, .3 and .10 in numeric edition order', async () => {
|
||||
renderDetail ('/help/guide?edition=2026-10-02.3')
|
||||
await screen.findByRole ('heading', { name: 'ホーム第三版' })
|
||||
expect (screen.getByRole ('link', { name: '< 古' }))
|
||||
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02.2')
|
||||
const next = screen.getByRole ('link', { name: '新 >' })
|
||||
expect (next).toHaveAttribute ('href', '/help/guide?edition=2026-10-02.10')
|
||||
fireEvent.click (next)
|
||||
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||
expect (screen.getByText ('(最新)')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each (['/help/missing', '/help/guide?edition=missing']) (
|
||||
'shows NotFound without redirecting %s', async route => {
|
||||
renderDetail (route)
|
||||
expect (await screen.findByText ('404')).toBeInTheDocument ()
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
})
|
||||
|
||||
it ('hides the old body and title immediately while another article loads', async () => {
|
||||
let finish!: (value: { default: () => ReactElement }) => void
|
||||
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx']
|
||||
.mockReturnValueOnce (pending)
|
||||
renderDetail ()
|
||||
await screen.findByRole ('heading', { name: 'ホーム' })
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch article' }))
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).not.toContain ('ホーム'))
|
||||
await act (async () => finish ({ default: () => <h1>設定</h1> }))
|
||||
await screen.findByRole ('heading', { name: '設定' })
|
||||
await waitFor (() => expect (document.title).toContain ('設定 ヘルプ'))
|
||||
})
|
||||
|
||||
it ('ignores a late response for the previous edition', async () => {
|
||||
let finish!: (value: { default: () => ReactElement }) => void
|
||||
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
.mockReturnValueOnce (pending)
|
||||
renderDetail ()
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch edition' }))
|
||||
await screen.findByRole ('heading', { name: 'ホーム第三版' })
|
||||
await act (async () => finish ({ default: () => <h1>遅延した旧本文</h1> }))
|
||||
expect (screen.queryByText ('遅延した旧本文')).not.toBeInTheDocument ()
|
||||
expect (screen.getByRole ('heading', { name: 'ホーム第三版' })).toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('ホーム第三版 ヘルプ'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HelpListPage from '@/pages/help/HelpListPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
|
||||
describe ('Help search', () => {
|
||||
it ('initially lists each current article once with encoded detail links', async () => {
|
||||
renderWithProviders (<HelpListPage/>, { route: '/help' })
|
||||
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 ()
|
||||
})
|
||||
|
||||
it.each ([
|
||||
['ホーム', '', ['ホーム']],
|
||||
['', '検索', ['ホーム']],
|
||||
['', 'タグ', ['ホーム', '設定']],
|
||||
['ホーム', '設定', []],
|
||||
['ホーム', '検索', ['ホーム']],
|
||||
['旧ホーム', '', []],
|
||||
['', '過去限定本文', []]]) (
|
||||
'searches title %s and body %s together', async (title, body, expected) => {
|
||||
const { container } = renderWithProviders (<HelpListPage/>)
|
||||
await screen.findByText ('2 件')
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: 'タイトル' }), {
|
||||
target: { value: title } })
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '内容' }), {
|
||||
target: { value: body } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link => link.textContent))
|
||||
.toEqual (expected)
|
||||
if (expected.length === 0)
|
||||
expect (screen.getByText ('該当する記事がありません.')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('displays loading failures without stale results', async () => {
|
||||
helpFixture.raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
.mockRejectedValueOnce (new Error ('fixture loading failed'))
|
||||
renderWithProviders (<HelpListPage/>)
|
||||
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
|
||||
expect (screen.queryByRole ('link', { name: 'ホーム' })).not.toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||
import { buildWikiPage } from '@/test/factories'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
vi.mock ('@/lib/tags', () => ({ fetchTagByName: async () => null }))
|
||||
vi.mock ('@/lib/posts', () => ({ fetchPosts: async () => ({ posts: [], count: 0 }) }))
|
||||
|
||||
|
||||
const Location = () => {
|
||||
const location = useLocation ()
|
||||
return <output data-testid="location">{location.pathname}{location.search}</output>
|
||||
}
|
||||
|
||||
|
||||
const renderWiki = (title: string) => {
|
||||
const route = `/wiki/${encodeURIComponent (title)}`
|
||||
renderWithProviders (
|
||||
<>
|
||||
<Location/>
|
||||
<Routes>
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
<Route path="/404" element={<div>unexpected redirect</div>}/>
|
||||
</Routes>
|
||||
</>, { route })
|
||||
return route
|
||||
}
|
||||
|
||||
|
||||
describe ('Legacy Wiki to Help compatibility', () => {
|
||||
beforeEach (() => {
|
||||
vi.mocked (apiGet).mockReset ()
|
||||
vi.mocked (apiGet).mockImplementation (async path => {
|
||||
if (path === '/wiki')
|
||||
return []
|
||||
throw { isAxiosError: true, response: { status: 404 } }
|
||||
})
|
||||
})
|
||||
|
||||
it ('keeps a successful Wiki result even when Help has the same title', async () => {
|
||||
vi.mocked (apiGet).mockResolvedValueOnce (buildWikiPage ({
|
||||
title: 'ホーム', body: 'Wiki の現行本文' }))
|
||||
const route = renderWiki ('ホーム')
|
||||
await screen.findByText ('Wiki の現行本文')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
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 (apiGet).toHaveBeenCalledWith (
|
||||
`/wiki/title/${encodeURIComponent (title)}`, expect.anything ())
|
||||
})
|
||||
|
||||
it ('keeps the original URL and displays NotFound when no title matches', async () => {
|
||||
const route = renderWiki ('該当なし')
|
||||
await screen.findByText ('404')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not choose between articles with the same title', async () => {
|
||||
const loader = helpFixture.raw['../../help/設定 手順/editions/ja/2026-10-03.mdx']
|
||||
loader.mockResolvedValueOnce ('# ホーム\n\n別の記事')
|
||||
const route = renderWiki ('ホーム')
|
||||
await screen.findByText ('404')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
})
|
||||
|
||||
it.each ([403, 500]) ('does not redirect HTTP %s to Help', async status => {
|
||||
vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } })
|
||||
const route = renderWiki ('ホーム')
|
||||
await waitFor (() => expect (apiGet).toHaveBeenCalled ())
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
|
||||
|
||||
import { useMDXComponents } from '@/mdx-components'
|
||||
import viteConfig from '../../vite.config'
|
||||
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
type Transform = (source: string, id: string) => Promise<{ code: string } | null>
|
||||
|
||||
|
||||
export const transformMdx = async (source: string, suffix = '.mdx') => {
|
||||
const plugins = viteConfig.plugins?.flat () as { name: string; transform: Transform }[]
|
||||
const plugin = plugins.find (entry => entry.name === '@mdx-js/rollup')
|
||||
if (plugin == null)
|
||||
throw new Error ('The application MDX compiler is not registered')
|
||||
return plugin.transform.call (plugin, source, `/test/help-table${suffix}`)
|
||||
}
|
||||
|
||||
|
||||
export const compileMdx = async (source: string): Promise<ComponentType> => {
|
||||
const result = await transformMdx (source)
|
||||
if (result == null)
|
||||
throw new Error ('MDX was not compiled')
|
||||
const code = transpileModule (result.code, {
|
||||
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
|
||||
const exports = { } as { default: ComponentType }
|
||||
const requireFixture = (name: string) => {
|
||||
if (name === 'react/jsx-runtime')
|
||||
return jsxRuntime
|
||||
if (name === '@/mdx-components')
|
||||
return { useMDXComponents }
|
||||
throw new Error (`Unexpected MDX dependency: ${name}`)
|
||||
}
|
||||
new Function ('require', 'exports', code) (requireFixture, exports)
|
||||
return exports.default
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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'
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
import type * as Help from '@/lib/help'
|
||||
|
||||
export const editionRows = [
|
||||
['guide', 'ja', '2026-10-01', '旧ホーム', '過去限定本文'],
|
||||
['guide', 'ja', '2026-10-02', 'ホーム', 'タグを検索する'],
|
||||
['guide', 'ja', '2026-10-02.2', 'ホーム第二版', '第二版本文'],
|
||||
['guide', 'ja', '2026-10-02.3', 'ホーム第三版', '第三版本文'],
|
||||
['guide', 'ja', '2026-10-02.10', 'ホーム第十版', '第十版本文'],
|
||||
['guide', 'en', '2026-10-02', 'Home', 'English body'],
|
||||
['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const
|
||||
|
||||
const compiled = transpileModule (source.replaceAll ('import.meta', 'helpMeta'), {
|
||||
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
|
||||
|
||||
|
||||
// Replace Vite's file-discovery boundary, executing the actual 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 raw = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
|
||||
`../../help/${id}/editions/${language}/${edition}.mdx`,
|
||||
vi.fn (async () => `# ${title}\n\n${body}`)]))
|
||||
const modules = Object.fromEntries (editionRows.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)) }))]))
|
||||
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 api = { } as typeof Help
|
||||
const requireFixture = (name: string) => {
|
||||
if (name === 'yaml')
|
||||
return { parse }
|
||||
throw new Error (`Unexpected Help dependency: ${name}`)
|
||||
}
|
||||
new Function ('require', 'exports', 'helpMeta', compiled) (
|
||||
requireFixture, api, { glob })
|
||||
return { api, articles, raw, modules }
|
||||
}
|
||||
|
||||
|
||||
export const helpFixture = createHelpFixture ()
|
||||
@@ -1,9 +1,11 @@
|
||||
import mdx from '@mdx-js/rollup'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import remarkGFM from 'remark-gfm'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components' })
|
||||
const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components',
|
||||
remarkPlugins: [remarkGFM] })
|
||||
const originalTransform = mdxPlugin.transform
|
||||
|
||||
if (typeof originalTransform === 'function')
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする