このコミットが含まれているのは:
2026-10-12 21:34:02 +09:00
コミット 11e6f73287
17個のファイルの変更、524行の追加、22行の削除
+112
ファイルの表示
@@ -0,0 +1,112 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { generateHelpAssets } from './help-assets'
import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help'
vi.hoisted (() => {
vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor)
})
afterAll (() => vi.unstubAllGlobals ())
const readAsset = <T,> (modules: Map<string, string>, name: string): T => {
const source = modules.get (`virtual:help/${ name }`)!
const argument = source.slice ('export default JSON.parse('.length, -1)
return JSON.parse (JSON.parse (argument))
}
describe ('Build-time Help assets', () => {
let root: string
beforeEach (async () => {
root = await mkdtemp (path.join (tmpdir (), 'btrc-help-assets-'))
})
afterEach (async () => {
await rm (root, { recursive: true, force: true })
})
const writeArticle = async (
identifier: string,
editions: Record<string, string>,
current = '2026-10-02.10',
) => {
const articleRoot = path.join (root, identifier)
const editionRoot = path.join (articleRoot, 'editions', 'ja')
await mkdir (editionRoot, { recursive: true })
await writeFile (path.join (articleRoot, 'article.yml'), [
`identifier: ${ identifier }`, 'canonical_language: ja', 'title: stale YAML title',
'languages:', ' ja:', ` current_edition: ${ current }`].join ('\n'))
await Promise.all (Object.entries (editions).map (([edition, source]) =>
writeFile (path.join (editionRoot, `${ edition }.mdx`), source)))
}
it ('generates current search and ordered published history from each edition H1', async () => {
await writeArticle ('guide', {
'2026-10-01': '# 旧ホーム\n\n過去本文',
'2026-10-02': '# ホーム\n\n最初の同日版',
'2026-10-02.2': '# ホーム\n\n第二版',
'2026-10-02.3': '# ホーム\n\n第三版',
'2026-10-02.10': '\uFEFF# 最新ホーム\r\n\r\n現在本文\r\n# 別見出し',
'2026-10-02.11': '# 未公開\n\n検索に含めない' })
const { modules } = await generateHelpAssets (root)
const articles = readAsset<Record<string, ArticleMeta>> (modules, 'articles')
expect (articles.guide.languages.ja).toEqual ({
currentEdition: '2026-10-02.10', publishedEditions: [
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] })
expect (readAsset<Record<string, HelpSearchEntry[]>> (modules, 'search').ja).toEqual ([{
identifier: 'guide', edition: '2026-10-02.10', title: '最新ホーム',
body: '\r\n現在本文\r\n# 別見出し' }])
const changes = readAsset<Record<string, HelpChange[]>> (modules, 'changes').ja
expect (changes.map (row => [row.edition, row.title, row.pred, row.change, row.date]))
.toEqual ([
['2026-10-02.10', '最新ホーム', '2026-10-02.3', 'update', '2026-10-02'],
['2026-10-02.3', 'ホーム', '2026-10-02.2', 'update', '2026-10-02'],
['2026-10-02.2', 'ホーム', '2026-10-02', 'update', '2026-10-02'],
['2026-10-02', 'ホーム', '2026-10-01', 'update', '2026-10-02'],
['2026-10-01', '旧ホーム', null, 'new', '2026-10-01']])
const titles = readAsset<Record<string, HelpTitleIndex>> (modules, 'titles').ja
expect (titles).toEqual ({ '旧ホーム': ['guide'], 'ホーム': ['guide'],
'最新ホーム': ['guide'] })
expect (modules.get ('virtual:help/loaders')).not.toContain ('2026-10-02.11')
expect (modules.get ('virtual:help/loaders')).toContain ('2026-10-02.10.mdx?raw')
expect (modules.get ('virtual:help/search')).not.toContain ('import(')
})
it ('retains ambiguous title identifiers and normalises width and case', async () => {
await writeArticle ('alpha', { '2026-10-02': '# HOME\n本文' }, '2026-10-02')
await writeArticle ('beta', { '2026-10-02': '# Home\n本文' }, '2026-10-02')
const { modules } = await generateHelpAssets (root)
expect (readAsset<Record<string, HelpTitleIndex>> (modules, 'titles').ja.home)
.toEqual (['alpha', 'beta'])
expect (readAsset<Record<string, HelpChange[]>> (modules, 'changes').ja
.map (row => row.identifier)).toEqual (['alpha', 'beta'])
})
it ('rejects missing current files during generation', async () => {
await writeArticle ('guide', { '2026-10-02': '# ホーム' })
await expect (generateHelpAssets (root)).rejects.toThrow ('Missing current Help edition')
})
it.each (['本文\n# 遅い見出し', '# \n本文']) (
'rejects missing leading H1 at build time: %s', async source => {
await writeArticle ('guide', { '2026-10-02.10': source })
await expect (generateHelpAssets (root)).rejects.toThrow ('Missing leading Help H1')
})
it ('rejects absent canonical metadata instead of generating incomplete assets', async () => {
await writeArticle ('guide', { '2026-10-02.10': '# ホーム' })
await writeFile (path.join (root, 'guide', 'article.yml'),
'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02.10')
await expect (generateHelpAssets (root)).rejects.toThrow ('Invalid Help metadata')
})
it ('rejects an empty Help checkout', async () => {
await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found')
})
})
+25
ファイルの表示
@@ -60,4 +60,29 @@ describe ('Help file catalogue', () => {
expect (await api.getHelpEdition ('guide', 'ja', '2026-10-02.11')).toBeNull ()
expect (await api.getHelpEditionSource ('guide', 'ja', '2026-10-02.11')).toBeNull ()
})
it ('compares raw H1 and body lines in the requested direction', async () => {
const { api } = createHelpFixture ()
const forward = await api.getHelpDiff ('guide', 'ja', '2026-10-01', '2026-10-02.10')
expect (forward?.from.title).toBe ('旧ホーム')
expect (forward?.to.title).toBe ('ホーム第十版')
expect (forward?.lines).toEqual (expect.arrayContaining ([
{ type: 'removed', content: '# 旧ホーム\n' },
{ type: 'added', content: '# ホーム第十版\n' },
{ type: 'unchanged', content: '\n' }]))
const reverse = await api.getHelpDiff ('guide', 'ja', '2026-10-02.10', '2026-10-01')
expect (reverse?.lines).toEqual (expect.arrayContaining ([
{ type: 'added', content: '# 旧ホーム\n' },
{ type: 'removed', content: '# ホーム第十版\n' }]))
})
it.each ([['missing', 'ja', '2026-10-01', '2026-10-02.10'],
['guide', 'ja', '2026-10-01', '2026-10-02.11'],
['guide', 'en', '2026-10-01', '2026-10-02']]) (
'rejects invalid diff editions for %s/%s without reading sources',
async (id, language, from, to) => {
const { api, raw } = createHelpFixture ()
expect (await api.getHelpDiff (id, language, from, to)).toBeNull ()
Object.values (raw).forEach (load => expect (load).not.toHaveBeenCalled ())
})
})
+24
ファイルの表示
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import PrefetchLink from '@/components/PrefetchLink'
import * as prefetchers from '@/lib/prefetchers'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import HelpDiffPage from '@/pages/help/HelpDiffPage'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
@@ -76,6 +77,29 @@ describe ('Help prefetch integration', () => {
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
})
it ('reuses two prefetched raw editions when displaying their diff', async () => {
const path = '/help/guide/diff?from=2026-10-01&to=2026-10-02.10'
const { queryClient } = renderWithProviders (
<>
<PrefetchLink to={path}>diff</PrefetchLink>
<Routes>
<Route path="/" element={null}/>
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
</Routes>
</>)
await Promise.all ([prefetchers.prefetchForURL (queryClient, path),
prefetchers.prefetchForURL (queryClient, path)])
fireEvent.click (screen.getByRole ('link', { name: 'diff' }))
expect (await screen.findByRole ('link', { name: '旧ホーム (2026-10-01)' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-01')
expect (screen.getByText ('+ # ホーム第十版')).toBeInTheDocument ()
Object.entries (helpFixture.raw).forEach (([name, load]) => {
const expected = /\/ja\/(2026-10-01|2026-10-02\.10)\.mdx$/.test (name) ? 1 : 0
expect (load).toHaveBeenCalledTimes (expected)
})
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
})
it.each (['intent', 'off']) ('respects %s intent settings and still prefetches clicks',
async mode => {
settings.linkPreload = mode
+3 -3
ファイルの表示
@@ -4,7 +4,7 @@ import { vi } from 'vitest'
import source from '@/lib/help.ts?raw'
import type { ReactElement } from 'react'
import type { ComponentType } from 'react'
import type * as Help from '@/lib/help'
@@ -38,11 +38,11 @@ export const createHelpFixture = () => {
vi.fn (async () => `# ${title}\n\n${body}`)]))
const moduleLoads = Object.fromEntries (published.map (([id, language, edition, title, body]) => [
`../../help/${id}/editions/${language}/${edition}.mdx`,
vi.fn (async (): Promise<{ default: () => ReactElement }> => ({
vi.fn (async (): Promise<{ default: ComponentType }> => ({
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 caches: Record<string, { promise?: Promise<{ default: ComponentType }> }> = { }
const modules = Object.fromEntries (Object.entries (moduleLoads).map (([path, load]) => {
caches[path] = { }
return [path, vi.fn (() => caches[path].promise ??= load ())]