From 5246ac5c611cdbdba4655104f351369ee5ac43b2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 12 Oct 2026 23:23:31 +0900 Subject: [PATCH] #424 --- frontend/src/lib/help.test.ts | 30 ++++++ frontend/src/lib/help.ts | 36 +++++++ frontend/src/lib/tags.test.ts | 18 +++- frontend/src/lib/tags.ts | 10 +- .../src/pages/help/HelpChangesPage.test.tsx | 2 + frontend/src/pages/help/HelpChangesPage.tsx | 8 +- .../src/pages/help/HelpDetailPage.test.tsx | 15 +++ frontend/src/pages/help/HelpDetailPage.tsx | 15 ++- frontend/src/pages/help/HelpListPage.test.tsx | 61 ++++++++++++ frontend/src/pages/help/HelpListPage.tsx | 23 +++-- .../src/pages/wiki/WikiDetailPage.test.tsx | 95 ++++++++++++++++++- frontend/src/pages/wiki/WikiDetailPage.tsx | 59 ++++++------ .../pages/wiki/WikiHelpCompatibility.test.tsx | 44 ++++++++- frontend/src/pages/wiki/WikiNewPage.tsx | 2 +- 14 files changed, 360 insertions(+), 58 deletions(-) diff --git a/frontend/src/lib/help.test.ts b/frontend/src/lib/help.test.ts index 8de955b..a4b246b 100644 --- a/frontend/src/lib/help.test.ts +++ b/frontend/src/lib/help.test.ts @@ -4,6 +4,36 @@ import { createHelpFixture } from '@/test/helpFixtures' 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 () => { const { api } = createHelpFixture () expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition) diff --git a/frontend/src/lib/help.ts b/frontend/src/lib/help.ts index 5379457..de61501 100644 --- a/frontend/src/lib/help.ts +++ b/frontend/src/lib/help.ts @@ -31,6 +31,42 @@ export type HelpDiff = { to: HelpEditionSource 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 => { const { default: articles } = await import ('virtual:help/articles') diff --git a/frontend/src/lib/tags.test.ts b/frontend/src/lib/tags.test.ts index 7075997..1937093 100644 --- a/frontend/src/lib/tags.test.ts +++ b/frontend/src/lib/tags.test.ts @@ -72,11 +72,25 @@ describe ('tags API functions', () => { ) }) - it ('returns null when tag fetches fail', async () => { - api.apiGet.mockRejectedValueOnce (new Error ('missing')) + it ('returns null for a missing named tag while retaining the ID helper contract', async () => { api.apiGet.mockRejectedValueOnce (new Error ('missing')) + api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } }) await expect (fetchTag ('1')).resolves.toBeNull () await expect (fetchTagByName ('unknown')).resolves.toBeNull () }) + + it.each ([403, 500]) ('propagates named tag HTTP %s', async status => { + const error = { isAxiosError: true, response: { status } } + api.apiGet.mockRejectedValueOnce (error) + await expect (fetchTagByName ('ホーム')).rejects.toBe (error) + }) + + it ('propagates named tag network failures and encodes the name', async () => { + const error = new Error ('offline') + api.apiGet.mockRejectedValueOnce (error) + await expect (fetchTagByName ('名前/#%')).rejects.toBe (error) + expect (api.apiGet).toHaveBeenLastCalledWith ( + `/tags/name/${ encodeURIComponent ('名前/#%') }`) + }) }) diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index d27173b..bc16d19 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,3 +1,5 @@ +import { isAxiosError } from 'axios' + import { apiGet } from '@/lib/api' import type { Deerjikist, @@ -68,11 +70,13 @@ export const fetchExternalTag = async (id: string): Promise => { export const fetchTagByName = async (name: string): Promise => { try { - return await apiGet (`/tags/name/${ name }`) + return await apiGet (`/tags/name/${ encodeURIComponent (name) }`) } - catch + catch (error) { - return null + if (isAxiosError (error) && error.response?.status === 404) + return null + throw error } } diff --git a/frontend/src/pages/help/HelpChangesPage.test.tsx b/frontend/src/pages/help/HelpChangesPage.test.tsx index eead8ba..a0f8177 100644 --- a/frontend/src/pages/help/HelpChangesPage.test.tsx +++ b/frontend/src/pages/help/HelpChangesPage.test.tsx @@ -41,6 +41,8 @@ describe ('Help global edition history', () => { expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent)) .toEqual (['差分', 'タイトル', '有効開始年月日']) 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 => link.getAttribute ('href'))).toEqual ([ '/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10', diff --git a/frontend/src/pages/help/HelpChangesPage.tsx b/frontend/src/pages/help/HelpChangesPage.tsx index d85cf53..1fac81e 100644 --- a/frontend/src/pages/help/HelpChangesPage.tsx +++ b/frontend/src/pages/help/HelpChangesPage.tsx @@ -8,7 +8,7 @@ import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' -import { getHelpChanges } from '@/lib/help' +import { getHelpChanges, helpEditionDate } from '@/lib/help' import { helpKeys } from '@/lib/queryKeys' import { LIST_LIMIT_OPTIONS } from '@/lib/settings' import { dateString } from '@/lib/utils' @@ -85,7 +85,7 @@ const HelpChangesPage: FC = () => { const diffQuery = new URLSearchParams ({ from: entry.pred ?? '', to: entry.edition }) const diffTo = `${ path }/diff?${ diffQuery.toString () }` - const updatedAt = `${ entry.date }T00:00:00` + const updatedAt = helpEditionDate (entry.edition) return ( { {entry.title} - {dateString (updatedAt, 'hour')} + + {updatedAt != null && dateString (updatedAt, 'hour')} + ) })} diff --git a/frontend/src/pages/help/HelpDetailPage.test.tsx b/frontend/src/pages/help/HelpDetailPage.test.tsx index ef80d88..6bc3489 100644 --- a/frontend/src/pages/help/HelpDetailPage.test.tsx +++ b/frontend/src/pages/help/HelpDetailPage.test.tsx @@ -37,6 +37,20 @@ const renderDetail = (route = '/help/guide') => renderWithProviders ( describe ('Help details', () => { 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 () => { renderDetail () expect (await screen.findByRole ('heading', { name: 'ホーム第十版' })) @@ -70,6 +84,7 @@ describe ('Help details', () => { }) 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']) ( 'shows NotFound without redirecting %s', async route => { renderDetail (route) diff --git a/frontend/src/pages/help/HelpDetailPage.tsx b/frontend/src/pages/help/HelpDetailPage.tsx index 0464610..8e3d621 100644 --- a/frontend/src/pages/help/HelpDetailPage.tsx +++ b/frontend/src/pages/help/HelpDetailPage.tsx @@ -5,7 +5,10 @@ import { useLocation, useParams } from 'react-router-dom' import PrefetchLink from '@/components/PrefetchLink' import MainArea from '@/components/layout/MainArea' 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 { helpKeys } from '@/lib/queryKeys' import NotFound from '@/pages/NotFound' @@ -31,6 +34,8 @@ const HelpDetailPage: FC = () => { const languageMeta = article?.languages[language] const edition = requestedEdition ?? languageMeta?.currentEdition + const editionInfo = parseHelpEdition (edition ?? '') + const editionDate = helpEditionDate (edition ?? '') const editions = languageMeta?.publishedEditions ?? [] @@ -42,7 +47,7 @@ const HelpDetailPage: FC = () => { : undefined 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 ?? ''), queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''), staleTime: Infinity }) @@ -51,6 +56,7 @@ const HelpDetailPage: FC = () => { && (article == null || languageMeta == null || edition == null + || editionInfo == null || !(editions.includes (edition)))) return @@ -76,7 +82,10 @@ const HelpDetailPage: FC = () => { ) : '(最古)'} - {dateString (edition, 'hour')} + + {editionDate != null && dateString (editionDate, 'hour')} + {editionInfo?.revision != null && ` .${ editionInfo.revision }`} + {succ ? ( diff --git a/frontend/src/pages/help/HelpListPage.test.tsx b/frontend/src/pages/help/HelpListPage.test.tsx index 16a425f..385ad79 100644 --- a/frontend/src/pages/help/HelpListPage.test.tsx +++ b/frontend/src/pages/help/HelpListPage.test.tsx @@ -52,4 +52,65 @@ describe ('Help search', () => { expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed') 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 (, { 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 (, { 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 (, { + 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 (, { + 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']) + }) }) diff --git a/frontend/src/pages/help/HelpListPage.tsx b/frontend/src/pages/help/HelpListPage.tsx index ebbe472..0b4976d 100644 --- a/frontend/src/pages/help/HelpListPage.tsx +++ b/frontend/src/pages/help/HelpListPage.tsx @@ -10,7 +10,10 @@ import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' -import { getHelpSearchEntries } from '@/lib/help' +import { compareHelpEditions, + getHelpSearchEntries, + helpEditionDate, + normaliseHelpSearchText } from '@/lib/help' import { helpKeys } from '@/lib/queryKeys' import { LIST_LIMIT_OPTIONS } from '@/lib/settings' import { dateString, inputClass } from '@/lib/utils' @@ -55,22 +58,20 @@ const HelpSearchPage: FC = () => { }, [conditions.title, conditions.text]) const results = useMemo (() => { - const titleQuery = conditions.title.trim ().normalize ('NFKC').toLowerCase () - const textQuery = conditions.text.trim ().normalize ('NFKC').toLowerCase () + const titleQuery = normaliseHelpSearchText (conditions.title.trim ()) + const textQuery = normaliseHelpSearchText (conditions.text.trim ()) const [field, direction] = order.split (':') return ( entries.filter (entry => ( (!(titleQuery) - || entry.title.normalize ('NFKC').toLowerCase ().includes (titleQuery)) + || normaliseHelpSearchText (entry.title).includes (titleQuery)) && (!(textQuery) - || entry.body.normalize ('NFKC').toLowerCase ().includes (textQuery)))) + || normaliseHelpSearchText (entry.body).includes (textQuery)))) .sort ((a, b) => { - const [dateA, revisionA = '1'] = a.edition.split ('.') - const [dateB, revisionB = '1'] = b.edition.split ('.') const comparison = field === 'title' ? a.title.localeCompare (b.title, 'ja') - : (dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB)) + : compareHelpEditions (a.edition, b.edition) return (direction === 'asc' ? comparison : -comparison) || a.identifier.localeCompare (b.identifier) @@ -180,7 +181,7 @@ const HelpSearchPage: FC = () => { {pageResults.map (entry => { - const updatedAt = new Date (entry.edition.replace (/\..*$/, '')) + const updatedAt = helpEditionDate (entry.edition) return ( { {entry.title} - {dateString (updatedAt, 'hour')} + + {updatedAt != null && dateString (updatedAt, 'hour')} + ) })} diff --git a/frontend/src/pages/wiki/WikiDetailPage.test.tsx b/frontend/src/pages/wiki/WikiDetailPage.test.tsx index 4b7b8c7..fac93da 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.test.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.test.tsx @@ -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 { 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 { buildTag, buildWikiPage } from '@/test/factories' +import WikiNewPage from '@/pages/wiki/WikiNewPage' +import { buildTag, buildUser, buildWikiPage } from '@/test/factories' import { renderWithProviders } from '@/test/render' const wikiApi = vi.hoisted (() => ({ fetchWikiPage: vi.fn (), fetchWikiPageByTitle: vi.fn (), + fetchWikiPages: vi.fn (), })) const tagsApi = vi.hoisted (() => ({ @@ -22,8 +26,93 @@ const postsApi = vi.hoisted (() => ({ vi.mock ('@/lib/wiki', () => wikiApi) vi.mock ('@/lib/tags', () => tagsApi) 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 }) => ( +