diff --git a/backend/spec/services/certified_tag_name_translations_spec.rb b/backend/spec/services/certified_tag_name_translations_spec.rb new file mode 100644 index 0000000..9a6ccd7 --- /dev/null +++ b/backend/spec/services/certified_tag_name_translations_spec.rb @@ -0,0 +1,116 @@ +require 'rails_helper' +require 'tempfile' + +RSpec.describe CertifiedTagNameTranslations do + let!(:language) { Language.create!(code: 'ain', name: 'ain') } + let!(:script) { Script.create!(code: 'Kana', name: 'Kana') } + let!(:tag) { create(:tag, primary_name: 'source') } + let(:entries) { [{ 'ja' => 'source', 'ain-Kana' => 'レ_ハイタ' }] } + let(:document) do + { 'format' => described_class::FORMAT, + 'source_locale' => 'ja', 'target_locale' => 'ain-Kana', 'entries' => entries } + end + let(:locale) do + Locale.new(code: 'ain-Kana', language_code: 'ain', script_code: 'Kana', + region_code: 392, variants: '', name: 'Ainu') + end + + around do |example| + Tempfile.create(['certified-names', '.json']) do |file| + @path = file.path + example.run + end + end + + def write_document + File.write(@path, JSON.generate(document)) + locale.certified_tag_names_path = @path + end + + it 'registers certified names before automatic names and records their provenance' do + other = create(:tag, primary_name: 'レ_ハイタ') + write_document + + locale.save! + + expect(tag.reload.tag_name('ain')).to have_attributes( + name: 'レ_ハイタ', primary_flg: true, auto_generated: false) + expect(other.reload.tag_name('ain')).to have_attributes( + name: "Tag_##{ other.id }", auto_generated: true) + version = tag.tag_versions.order(:version_no).last + expect(version.created_by_user_id).to be_nil + expect(version.names.fetch('ain')).to include( + 'name' => 'レ_ハイタ', 'auto_generated' => false) + end + + it 'skips missing sources without rolling back the locale or matching entries' do + entries.unshift({ 'ja' => 'missing', 'ain-Kana' => 'unused' }) + write_document + + locale.save! + + expect(locale).to be_persisted + expect(tag.reload.name('ain')).to eq('レ_ハイタ') + expect(TagName.exists?(language_code: 'ain', name: 'unused')).to be(false) + end + + it 'matches source names exactly rather than using a case-insensitive collation' do + entries.first['ja'] = 'SOURCE' + write_document + + expect(described_class.load!(@path, locale:)).to eq({}) + end + + it 'rejects ambiguous primary matches' do + write_document + relation = instance_double(ActiveRecord::Relation) + allow(TagName).to receive(:where).with( + language_code: 'ja', primary_flg: true, name: ['source']).and_return(relation) + allow(relation).to receive(:pluck).with(:name, :tag_id) + .and_return([['source', tag.id], ['source', tag.id + 1]]) + + expect { described_class.load!(@path, locale:) } + .to raise_error(described_class::Error, /source.*exactly one tag/) + end + + it 'rejects duplicate sources and identifies the offending name' do + entries.push({ 'ja' => 'source', 'ain-Kana' => 'another' }) + write_document + + expect { locale.save! }.to raise_error(described_class::Error, /duplicate source.*source/) + expect(Locale.exists?(code: 'ain-Kana')).to be(false) + end + + it 'rejects duplicate targets even when a source is missing' do + entries.push({ 'ja' => 'missing', 'ain-Kana' => 'レ_ハイタ' }) + write_document + + expect { locale.save! }.to raise_error(described_class::Error, /duplicate target.*レ_ハイタ/) + expect(Locale.exists?(code: 'ain-Kana')).to be(false) + end + + it 'rolls back names and histories if a later certified name is invalid' do + create(:tag, primary_name: 'second') + entries.push({ 'ja' => 'second', 'ain-Kana' => 'nico:invalid' }) + write_document + original_versions = TagVersion.order(:id).map(&:attributes) + + expect { locale.save! }.to raise_error(described_class::Error, /nico:invalid/) + + expect(Locale.exists?(code: 'ain-Kana')).to be(false) + expect(TagName.where(language_code: 'ain')).to be_empty + expect(TagVersion.order(:id).map(&:attributes)).to eq(original_versions) + end + + it 'does not add names or versions on repeated completion' do + write_document + locale.save! + original_names = TagName.order(:id).map(&:attributes) + original_versions = TagVersion.order(:id).map(&:attributes) + + TagNameCompletion.for_locale!(locale) + + expect(TagName.order(:id).map(&:attributes)).to eq(original_names) + expect(TagVersion.order(:id).map(&:attributes)).to eq(original_versions) + end +end diff --git a/frontend/src/components/LocalisedText.test.tsx b/frontend/src/components/LocalisedText.test.tsx new file mode 100644 index 0000000..851cac1 --- /dev/null +++ b/frontend/src/components/LocalisedText.test.tsx @@ -0,0 +1,47 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import LocalisedText from '@/components/LocalisedText' + + +describe ('LocalisedText', () => { + it.each ([ + ['ネコ チャペ', ['ネコ', 'チャペ']], + ['ネコ、チャペ', ['ネコ、', 'チャペ']], + ['著作者人格権、商標権、肖像権、パブリシティー権', + ['著作者人格権、', '商標権、', '肖像権、', 'パブリシティー権']], + ['ネコ、チャペ。', ['ネコ、', 'チャペ。']], + ['ネコ、「チャペ」。', ['ネコ、', '「チャペ」。']], + ['ネコ,、」チャペ', ['ネコ,、」', 'チャペ']]]) ( + 'adds word boundaries without changing %s', (text, words) => { + const { container } = render ({text}) + const wrapper = container.firstElementChild! + expect (wrapper).toHaveAttribute ('lang', 'ain-Kana') + expect (wrapper.textContent).toBe (text) + expect (Array.from (wrapper.querySelectorAll ('.ainu-ui-word'), node => node.textContent)) + .toEqual (words) + expect (wrapper.querySelectorAll ('wbr')).toHaveLength (words.length - 1) + }) + + it ('preserves Japanese fallback and links and code inside an Ainu paragraph', () => { + const { container } = render ( + + 日本語、本文 + ネコ チャペ + ネコ チャペ + ネコ チャペ + ) + expect (container.querySelector ('[lang="ja"] .ainu-ui-word')).toBeNull () + expect (container.querySelector ('a .ainu-ui-word')).toBeNull () + expect (container.querySelector ('code .ainu-ui-word')).toBeNull () + expect (container.querySelectorAll ('strong .ainu-ui-word')).toHaveLength (2) + }) + + it ('does not apply Ainu word splitting to Japanese text', () => { + const { container } = render ( + ネコ、チャペ。) + expect (container.firstElementChild).toHaveAttribute ('lang', 'ja') + expect (container.querySelector ('.ainu-ui-word')).toBeNull () + expect (container.textContent).toBe ('ネコ、チャペ。') + }) +}) diff --git a/frontend/src/lib/help.test.ts b/frontend/src/lib/help.test.ts index 79ee9b0..2e3144b 100644 --- a/frontend/src/lib/help.test.ts +++ b/frontend/src/lib/help.test.ts @@ -29,7 +29,7 @@ describe ('Help file catalogue', () => { 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]) + expect (date.toISOString ()).toBe ('2026-10-01T15:00:00.000Z') }) it.each (['missing', '2026-02-30', '2026-10-02.0', '2026-10-02.2.extra', @@ -51,9 +51,9 @@ describe ('Help file catalogue', () => { 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.10', + { identifier: 'guide', language: 'ja', edition: '2026-10-02.10', title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' }, - { identifier: '設定 手順', edition: '2026-10-03', + { identifier: '設定 手順', language: 'ja', edition: '2026-10-03', title: '設定', body: '\nタグを設定する' }]) }) @@ -66,6 +66,15 @@ describe ('Help file catalogue', () => { .not.toContain ('2026-10-02.11') }) + it ('does not substitute Japanese articles, search or changes for missing Ainu content', + async () => { + const { api } = createHelpFixture () + const article = await api.getHelpArticle ('guide') + expect (api.resolveHelpLanguage (article, 'ain')).toBeNull () + expect (await api.getHelpSearchEntries ('ain')).toEqual ([]) + expect (await api.getHelpChanges ('ain')).toEqual ([]) + }) + it ('uses generated edition titles without reparsing raw sources', async () => { const { api, raw } = createHelpFixture () expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01')) diff --git a/frontend/src/lib/posts.test.ts b/frontend/src/lib/posts.test.ts index 972775d..2e84da0 100644 --- a/frontend/src/lib/posts.test.ts +++ b/frontend/src/lib/posts.test.ts @@ -48,6 +48,7 @@ describe ('posts API functions', () => { expect (api.apiGet).toHaveBeenCalledWith ( '/posts', { + locale: 'ja', params: { title: 'title', tags: 'a b', @@ -89,6 +90,7 @@ describe ('posts API functions', () => { original_created_before: '2026-01-02T00:00:00Z', }, { + locale: 'ja', params: { base_version_no: '7', force: '1', @@ -113,7 +115,7 @@ describe ('posts API functions', () => { expect (api.apiGet).toHaveBeenCalledWith ( '/posts/versions', - { params: { page: 2, limit: 50 } }, + { locale: 'ja', params: { page: 2, limit: 50 } }, ) }) @@ -129,6 +131,7 @@ describe ('posts API functions', () => { expect (api.apiGet).toHaveBeenCalledWith ( '/posts/versions', { + locale: 'ja', params: { external_tag: '7', page: 2, diff --git a/frontend/src/lib/queryKeys.test.ts b/frontend/src/lib/queryKeys.test.ts index 45a9ee5..68ee693 100644 --- a/frontend/src/lib/queryKeys.test.ts +++ b/frontend/src/lib/queryKeys.test.ts @@ -1,14 +1,46 @@ import { describe, expect, it } from 'vitest' +import { QueryClient } from '@tanstack/react-query' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' +import type { FetchNicoTagsParams } from '@/types' + describe ('query keys', () => { it ('uses stable namespaces for posts, tags, and wiki', () => { - expect (postsKeys.show ('3')).toEqual (['posts', '3']) - expect (postsKeys.related ('3')).toEqual (['related', '3']) - expect (tagsKeys.deerjikists ('7')).toEqual (['tags', 'deerjikists', '7']) + expect (postsKeys.show ('3')).toEqual (['posts', 'ja', '3']) + expect (postsKeys.related ('3')).toEqual (['related', 'ja', '3']) + expect (tagsKeys.deerjikists ('7')).toEqual (['tags', 'ja', 'deerjikists', '7']) expect (wikiKeys.show ('Title', { version: '2' })).toEqual ( - ['wiki', 'Title', { version: '2' }], - ) + ['wiki', 'ja', 'Title', { version: '2' }]) + }) + + it ('isolates the same resource by viewing locale', () => { + const client = new QueryClient () + client.setQueryData (postsKeys.show ('3', 'ja'), { title: '日本語' }) + client.setQueryData (postsKeys.show ('3', 'ain-Kana'), { title: 'アイヌ語' }) + + expect (client.getQueryData (postsKeys.show ('3', 'ja'))).toEqual ({ title: '日本語' }) + expect (client.getQueryData (postsKeys.show ('3', 'ain-Kana'))) + .toEqual ({ title: 'アイヌ語' }) + client.clear () + }) + + it ('invalidates both nico locales without invalidating ordinary tags', async () => { + const client = new QueryClient () + const params: FetchNicoTagsParams = { + name: '', linkedTag: '', linkStatus: 'all', page: 1, limit: 20, + order: 'name:asc' } + const ja = tagsKeys.nicoIndex (params, 'ja') + const ain = tagsKeys.nicoIndex (params, 'ain-Kana') + const ordinary = tagsKeys.show ('3', 'ja') + for (const key of [ja, ain, ordinary]) + client.setQueryData (key, []) + + await client.invalidateQueries ({ queryKey: tagsKeys.nicoRoot }) + + expect (client.getQueryState (ja)?.isInvalidated).toBe (true) + expect (client.getQueryState (ain)?.isInvalidated).toBe (true) + expect (client.getQueryState (ordinary)?.isInvalidated).toBe (false) + client.clear () }) }) diff --git a/frontend/src/lib/remark-wiki-autolink.test.ts b/frontend/src/lib/remark-wiki-autolink.test.ts index 57e634b..f164b65 100644 --- a/frontend/src/lib/remark-wiki-autolink.test.ts +++ b/frontend/src/lib/remark-wiki-autolink.test.ts @@ -4,65 +4,90 @@ import remarkWikiAutolink from '@/lib/remark-wiki-autolink' import type { Root } from 'mdast' -describe ('remarkWikiAutolink', () => { - it ('links matching wiki page names and prefers longer matches', () => { - const tree: Root = { - type: 'root', - children: [{ - type: 'paragraph', - children: [{ type: 'text', value: '虹夏 and 虹' }], - }], - } +import type { WikiAutolinkCandidate } from '@/types' - remarkWikiAutolink (['虹', '虹夏']) (tree) + +const candidate = ( + name: string, + resourceId: number, + overrides: Partial = {}, +): WikiAutolinkCandidate => ({ + name, resourceId, tagId: resourceId + 100, tagDescriptionId: resourceId, + resourceType: 'tag_description', languageCode: 'ja', localeCode: 'ain-Kana', + ...overrides }) + + +const paragraph = (value: string): Root => ({ + type: 'root', children: [{ + type: 'paragraph', children: [{ type: 'text', value }] }] }) + + +describe ('remarkWikiAutolink', () => { + it ('prefers longer names and keeps the viewing locale for Japanese text', () => { + const tree = paragraph ('虹夏 and 虹') + + remarkWikiAutolink ([candidate ('虹', 1), candidate ('虹夏', 2)], + 'ja', 'ain-Kana') (tree) expect (tree.children[0]).toMatchObject ({ - type: 'paragraph', - children: [ - { - type: 'link', - url: '/wiki/%E8%99%B9%E5%A4%8F', - children: [{ type: 'text', value: '虹夏' }], - }, - { type: 'text', value: ' and ' }, - { - type: 'link', - url: '/wiki/%E8%99%B9', - children: [{ type: 'text', value: '虹' }], - }, - ], - }) + children: [ + { type: 'link', url: '/ain-Kana/wiki/2', + children: [{ type: 'text', value: '虹夏' }] }, + { type: 'text', value: ' and ' }, + { type: 'link', url: '/ain-Kana/wiki/1', + children: [{ type: 'text', value: '虹' }] }] }) }) - it ('does not link text inside existing links or code', () => { + it ('does not rewrite existing links, code or images', () => { const tree: Root = { - type: 'root', - children: [ - { - type: 'paragraph', - children: [{ - type: 'link', - url: '/existing', - children: [{ type: 'text', value: '虹' }], - }], - }, - { - type: 'code', - value: '虹', - }, - ], - } + type: 'root', children: [ + { type: 'paragraph', children: [ + { type: 'link', url: '/existing', children: [{ type: 'text', value: '虹' }] }, + { type: 'inlineCode', value: '虹' }, + { type: 'image', url: '/image', alt: '虹' }] }, + { type: 'code', value: '虹' }] } + const original = structuredClone (tree) - remarkWikiAutolink (['虹']) (tree) + remarkWikiAutolink ([candidate ('虹', 1)], 'ja', 'ain-Kana') (tree) + expect (tree).toEqual (original) + }) + + it ('links an alias to its description rather than its tag ID or matched name', () => { + const tree = paragraph ('別名') + remarkWikiAutolink ([candidate ('別名', 9, { tagId: 123 })], + 'ja', 'ain-Kana') (tree) expect (tree.children[0]).toMatchObject ({ - type: 'paragraph', - children: [{ - type: 'link', - url: '/existing', - children: [{ type: 'text', value: '虹' }], - }], - }) - expect (tree.children[1]).toMatchObject ({ type: 'code', value: '虹' }) + children: [{ type: 'link', url: '/ain-Kana/wiki/9' }] }) + }) + + it ('leaves ambiguous names and other locales or languages unlinked', () => { + const tree = paragraph ('虹 名前 name') + const original = structuredClone (tree) + remarkWikiAutolink ([candidate ('虹', 1), candidate ('虹', 2), + candidate ('名前', 3, { localeCode: 'ja' }), + candidate ('name', 4, { languageCode: 'ain' })], 'ja', 'ain-Kana') (tree) + expect (tree).toEqual (original) + }) + + it ('links Ainu words only at word boundaries', () => { + const tree = paragraph ('チャペ チャペ。チャペア') + remarkWikiAutolink ([candidate ('チャペ', 5, { languageCode: 'ain' })], + 'ain', 'ain-Kana') (tree) + expect (tree.children[0]).toMatchObject ({ + children: [ + { type: 'link', url: '/ain-Kana/wiki/5' }, + { type: 'text', value: ' ' }, + { type: 'link', url: '/ain-Kana/wiki/5' }, + { type: 'text', value: '。チャペア' }] }) + }) + + it ('preserves tag-group resource identity', () => { + const tree = paragraph ('group') + remarkWikiAutolink ([candidate ('group', 8, { + resourceType: 'tag_group', tagId: null, tagDescriptionId: null })], + 'ja', 'ain-Kana') (tree) + expect (tree.children[0]).toMatchObject ({ + children: [{ type: 'link', url: '/ain-Kana/wiki/8' }] }) }) }) diff --git a/frontend/src/lib/tags.test.ts b/frontend/src/lib/tags.test.ts index 1937093..809e810 100644 --- a/frontend/src/lib/tags.test.ts +++ b/frontend/src/lib/tags.test.ts @@ -45,6 +45,7 @@ describe ('tags API functions', () => { expect (api.apiGet).toHaveBeenCalledWith ( '/tags', { + locale: 'ja', params: { name: '虹', category: 'character', @@ -68,7 +69,7 @@ describe ('tags API functions', () => { expect (api.apiGet).toHaveBeenCalledWith ( '/tags', - { params: expect.objectContaining ({ deprecated: expected }) }, + { locale: 'ja', params: expect.objectContaining ({ deprecated: expected }) }, ) }) @@ -91,6 +92,6 @@ describe ('tags API functions', () => { api.apiGet.mockRejectedValueOnce (error) await expect (fetchTagByName ('名前/#%')).rejects.toBe (error) expect (api.apiGet).toHaveBeenLastCalledWith ( - `/tags/name/${ encodeURIComponent ('名前/#%') }`) + `/tags/name/${ encodeURIComponent ('名前/#%') }`, { locale: 'ja' }) }) }) diff --git a/frontend/src/lib/wiki.test.ts b/frontend/src/lib/wiki.test.ts index eafcfe2..725850f 100644 --- a/frontend/src/lib/wiki.test.ts +++ b/frontend/src/lib/wiki.test.ts @@ -21,11 +21,11 @@ describe ('wiki API functions', () => { expect (api.apiGet).toHaveBeenNthCalledWith ( 1, '/wiki', - { params: { title: '虹' } }) + { locale: 'ja', params: { title: '虹' } }) expect (api.apiGet).toHaveBeenNthCalledWith ( 2, '/wiki/1', - { params: { version: '3' } }) + { locale: 'ja', params: { version: '3' } }) }) it ('encodes title path segments and returns null on misses', async () => { @@ -38,7 +38,7 @@ describe ('wiki API functions', () => { expect (api.apiGet).toHaveBeenNthCalledWith ( 1, '/wiki/title/a%2Fb%20c', - { params: { version: undefined } }) + { locale: 'ja', params: { version: undefined } }) }) it.each ([403, 500]) ('preserves HTTP %s errors', async status => { diff --git a/frontend/src/test/helpFixtures.tsx b/frontend/src/test/helpFixtures.tsx index 5f560e6..3ffd48f 100644 --- a/frontend/src/test/helpFixtures.tsx +++ b/frontend/src/test/helpFixtures.tsx @@ -3,6 +3,8 @@ import { ModuleKind, ScriptTarget, transpileModule } from 'typescript' import { vi } from 'vitest' import source from '@/lib/help.ts?raw' +import * as locales from '@/lib/locales' +import * as queryKeys from '@/lib/queryKeys' import type { ComponentType } from 'react' @@ -92,6 +94,10 @@ export const createHelpFixture = () => { loaders: vi.fn (() => loaders) } const api = { } as typeof Help const requireFixture = (name: string) => { + if (name === '@/lib/locales') + return locales + if (name === '@/lib/queryKeys') + return queryKeys const key = name.replace ('virtual:help/', '') as keyof typeof loads if (Object.hasOwn (loads, key)) return loads[key] () diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx index 5cae139..4f6c0ac 100644 --- a/frontend/src/test/render.tsx +++ b/frontend/src/test/render.tsx @@ -21,11 +21,13 @@ const TestRoute = () => { const children = useContext (TestContentContext) return ( - - - {children} - - ) + + + + {children} + + + ) } export const renderWithProviders = ( @@ -46,11 +48,9 @@ export const renderWithProviders = ( return ( - - ) }