From d36540601928e16de37d2c3654dda0e9e8eee8d9 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 19 Oct 2026 20:56:07 +0900 Subject: [PATCH] #428 --- backend/spec/models/locale_spec.rb | 18 +++-- .../models/tag_name_sanitisation_rule_spec.rb | 2 + backend/spec/models/tag_spec.rb | 8 +-- .../spec/requests/locale_write_paths_spec.rb | 11 +++- backend/spec/requests/posts_spec.rb | 22 +++++-- .../spec/services/post_create_plan_spec.rb | 4 +- ...shared_resource_id_allocator_mysql_spec.rb | 34 ++++++++++ .../shared_resource_id_allocator_spec.rb | 42 ++++++++---- .../services/tag_name_transcription_spec.rb | 11 ++-- .../services/tag_version_recorder_spec.rb | 12 ++-- frontend/src/components/ErrorScreen.test.tsx | 9 +-- frontend/src/components/PostEditForm.test.tsx | 16 +++-- frontend/src/components/PostEmbed.test.tsx | 8 ++- .../src/components/PostFormTagsArea.test.tsx | 3 +- frontend/src/components/PostList.test.tsx | 4 +- .../PostOriginalCreatedTimeField.test.tsx | 3 +- frontend/src/components/SortHeader.test.tsx | 4 +- frontend/src/components/TagLink.test.tsx | 4 +- frontend/src/components/TopNavUser.test.tsx | 2 +- .../src/components/common/Pagination.test.tsx | 6 +- .../src/components/common/TagInput.test.tsx | 9 ++- .../dialogues/DialogueProvider.test.tsx | 7 +- .../layout/SidebarComponent.test.tsx | 3 +- .../PostCreationDataFieldsUsage.test.tsx | 3 +- .../posts/PostThumbnailPreview.test.tsx | 3 +- .../posts/import/PostImportRowForm.test.tsx | 20 +++--- .../PostImportThumbnailPreview.test.tsx | 3 +- frontend/src/lib/gekanator.test.ts | 10 +-- frontend/src/lib/helpPrefetch.test.tsx | 6 +- frontend/src/lib/prefetchers.test.ts | 43 ++++++------ frontend/src/mdx-components.test.tsx | 4 +- .../deerjikists/DeerjikistDetailPage.test.tsx | 2 +- .../src/pages/help/HelpChangesPage.test.tsx | 29 ++++---- .../src/pages/help/HelpDetailPage.test.tsx | 26 ++++---- frontend/src/pages/help/HelpListPage.test.tsx | 12 ++-- .../materials/MaterialDetailPage.test.tsx | 11 ++-- .../pages/materials/MaterialListPage.test.tsx | 19 +++--- .../pages/materials/MaterialNewPage.test.tsx | 25 +++---- .../src/pages/posts/PostDetailPage.test.tsx | 2 +- .../src/pages/posts/PostHistoryPage.test.tsx | 4 +- .../pages/posts/PostImportReviewPage.test.tsx | 13 ++-- .../pages/posts/PostImportSourcePage.test.tsx | 7 +- .../src/pages/posts/PostListPage.test.tsx | 10 +-- .../src/pages/posts/PostSearchPage.test.tsx | 10 +-- .../src/pages/tags/NicoTagListPage.test.tsx | 25 +++---- .../src/pages/tags/TagDetailPage.test.tsx | 8 ++- frontend/src/pages/tags/TagListPage.test.tsx | 18 ++--- .../pages/theatres/TheatreDetailPage.test.tsx | 66 ++++++++++++------- frontend/src/pages/users/SettingPage.test.tsx | 4 +- .../src/pages/wiki/WikiDetailPage.test.tsx | 39 ++++++----- frontend/src/pages/wiki/WikiDiffPage.test.tsx | 4 +- frontend/src/pages/wiki/WikiEditPage.test.tsx | 10 +-- .../pages/wiki/WikiHelpCompatibility.test.tsx | 2 +- .../src/pages/wiki/WikiHistoryPage.test.tsx | 2 +- frontend/src/pages/wiki/WikiNewPage.test.tsx | 6 +- .../src/pages/wiki/WikiSearchPage.test.tsx | 8 +-- frontend/src/test/compileMdx.ts | 2 +- frontend/src/test/render.tsx | 10 ++- frontend/src/test/text.tsx | 10 +++ frontend/vitest.config.ts | 1 + 60 files changed, 434 insertions(+), 285 deletions(-) create mode 100644 frontend/src/test/text.tsx diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb index 9e559a4..59dcba2 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -67,20 +67,18 @@ RSpec.describe Locale, type: :model do primary_flg: true, auto_generated: false) end - it 'creates a primary name when the language has only an alias' do - prepare_ainu_reference! - tag = create(:tag, primary_name: 'alias_only_tag') - alias_name = create(:tag_name, :alias, name: 'alias_en', - tag:, - language_code: 'ain', script_code: 'Kana') + it 'adds the new language primary while preserving existing names and aliases' do + tag = create(:tag, primary_name: 'named_tag') + create(:tag_name, :alias, name: 'existing_alias', tag:, + language_code: 'ja', script_code: 'Jpan') + original_names = tag.tag_names.order(:id).map(&:attributes) create_ainu_locale! - expect(alias_name.reload).to have_attributes( - tag_id: tag.id, - language_code: 'ain', script_code: 'Kana', primary_flg: false) + expect(tag.tag_names.where(language_code: 'ja').order(:id).map(&:attributes)) + .to eq(original_names) generated = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true) - expect(generated).to have_attributes(tag_id: tag.id, language_code: 'ain', + expect(generated).to have_attributes(name: 'named_tag', tag_id: tag.id, language_code: 'ain', script_code: 'Kana', primary_flg: true, auto_generated: true) expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true).count).to eq(1) diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index 3d88b96..ab77c84 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -113,6 +113,8 @@ RSpec.describe TagNameSanitisationRule, type: :model do it 'renames without merging tags or deleting either language identity' do locale nipponese = create(:tag, primary_name: 'foobar') + primary_tag_name_for(nipponese, 'ain').update!( + name: 'different_ainu_name', auto_generated: false) ainu_name = create(:tag_name, name: 'temporary', language_code: 'ain', script_code: 'Kana') ainu = create(:tag, primary_tag_name: ainu_name) diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 5645680..a247f93 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -66,7 +66,7 @@ RSpec.describe Tag, type: :model do it 'canonicalises aliases in the supplied language only' do prepare_ainu_reference! nipponese = create(:tag, primary_name: 'nipponese_canonical') - ainu_name = create(:tag_name, name: 'ainu_canonical', + ainu_name = build(:tag_name, tag: nil, name: 'ainu_canonical', language_code: 'ain', script_code: 'Kana') ainu = create(:tag, primary_tag_name: ainu_name) primary_tag_name_for(ainu, 'ja').update!( @@ -265,7 +265,7 @@ RSpec.describe Tag, type: :model do expect(tag.category).to eq('character') end - it 'uses a Japanese fallback for an Ainu-origin tag without reverse transcription' do + it 'copies an Ainu-origin name to Japanese without reverse transcription' do locale allow(TagName).to receive(:generate_name).and_call_original @@ -277,9 +277,9 @@ RSpec.describe Tag, type: :model do name: 'ainu_origin', auto_generated: false)) expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) .to contain_exactly(have_attributes( - name: "Tag_##{ tag.id }", auto_generated: true)) + name: 'ainu_origin', auto_generated: true)) expect(TagName).to have_received(:generate_name) - .with(Locale.nipponese, tag, nil) + .with(Locale.nipponese, tag, tag.tag_name('ain')) end it 'gives a new tag one primary name for every active language' do diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index c5ecfcc..51577d4 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -153,11 +153,14 @@ RSpec.describe 'Locale propagation on write paths', type: :request do let(:ainu_name) do create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana') end - let(:ainu_tag) { create(:tag, primary_tag_name: ainu_name, category: :material) } + let(:ainu_tag) do + create(:tag, primary_tag_name: ainu_name, category: :material).tap do |tag| + primary_tag_name_for(tag, 'ja').update!(name: 'ainu_in_japanese', auto_generated: false) + end + end let(:nipponese_tag) do create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag| - # Isolate lookup from the separately tested uniqueness validation. - primary_tag_name_for(tag, 'ja').update_columns(name: 'shared_name') + primary_tag_name_for(tag, 'ja').update!(name: 'shared_name') end end @@ -291,6 +294,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do it 'allows a rename to a name already used in Nipponese' do locale nipponese = create(:tag, primary_name: 'rename_target') + primary_tag_name_for(nipponese, 'ain').update!( + name: 'different_ainu_target', auto_generated: false) patch "/tags/#{ tag.id }", params: { locale: 'ain-Kana', name: 'rename_target' } diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 38bc115..06148f4 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -2323,8 +2323,10 @@ RSpec.describe 'Posts API', type: :request do 'prev' => nil ) expect(latest.fetch('tags')).to include( - { 'name' => 'spec_tag_2', 'type' => 'added' }, - { 'name' => 'spec_tag', 'type' => 'removed' } + { 'name' => 'spec_tag_2', 'restore_name' => 'spec_tag_2', + 'external' => false, 'type' => 'added' }, + { 'name' => 'spec_tag', 'restore_name' => 'spec_tag', + 'external' => false, 'type' => 'removed' } ) expect(latest.fetch('original_created_from')).to eq( 'current' => oc_from.iso8601, @@ -2351,7 +2353,8 @@ RSpec.describe 'Posts API', type: :request do 'prev' => nil ) expect(first.fetch('tags')).to include( - { 'name' => 'spec_tag', 'type' => 'added' } + { 'name' => 'spec_tag', 'restore_name' => 'spec_tag', + 'external' => false, 'type' => 'added' } ) expect(first.fetch('created_at')).to eq(t_v1.iso8601) end @@ -2435,7 +2438,9 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:ok) expect(json.fetch('count')).to eq(3) expect(json.fetch('versions').first.fetch('tags')).to include( - 'name' => primary_tag_name_for(tag2, 'ja').name, 'type' => 'context') + 'name' => primary_tag_name_for(tag2, 'ja').name, + 'restore_name' => primary_tag_name_for(tag2, 'ja').name, + 'external' => false, 'type' => 'context') end it 'filters versions by tag when the current snapshot includes the tag' do @@ -2449,7 +2454,8 @@ RSpec.describe 'Posts API', type: :request do expect(versions[0]['post_id']).to eq(post_record.id) expect(versions[0]['version_no']).to eq(2) expect(versions[0]['tags']).to include( - { 'name' => 'spec_tag_2', 'type' => 'added' } + { 'name' => 'spec_tag_2', 'restore_name' => 'spec_tag_2', + 'external' => false, 'type' => 'added' } ) end @@ -2467,10 +2473,12 @@ RSpec.describe 'Posts API', type: :request do first = versions[1] expect(latest['tags']).to include( - { 'name' => 'spec_tag', 'type' => 'removed' } + { 'name' => 'spec_tag', 'restore_name' => 'spec_tag', + 'external' => false, 'type' => 'removed' } ) expect(first['tags']).to include( - { 'name' => 'spec_tag', 'type' => 'added' } + { 'name' => 'spec_tag', 'restore_name' => 'spec_tag', + 'external' => false, 'type' => 'added' } ) end diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 511346c..0efbaee 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -18,9 +18,9 @@ RSpec.describe PostCreatePlan do ainu_name = create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana') ainu = create(:tag, primary_tag_name: ainu_name, category: :character) + primary_tag_name_for(ainu, 'ja').update!(name: 'ainu_in_japanese', auto_generated: false) nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general) - # The DB permits this identity; validation has its own contract spec. - primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name') + primary_tag_name_for(nipponese, 'ja').update!(name: 'shared_name') ainu_parent_name = create(:tag_name, name: 'ainu_parent', language_code: 'ain', script_code: 'Kana') ainu_parent = create(:tag, primary_tag_name: ainu_parent_name, diff --git a/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb index a6d908f..e6b2aa4 100644 --- a/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb +++ b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb @@ -141,6 +141,40 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do result(first) end + [false, true].each do |inner_rollback| + [false, true].each do |outer_rollback| + it "keeps savepoint=#{ inner_rollback } lock through outer rollback=#{ outer_rollback }" do + connection = ApplicationRecord.connection + database = connection.pool.db_config.database + lock_name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:tags" + quoted_lock = connection.quote(lock_name) + owner = connection.select_value('SELECT CONNECTION_ID()').to_i + + connection.transaction do + connection.transaction(requires_new: true) do + create_resource(:external) + raise ActiveRecord::Rollback if inner_rollback + end + + expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i) + .to eq(owner) + contender = worker do |other| + other.select_value("SELECT GET_LOCK(#{ quoted_lock }, 0)").to_i + end + expect(result(contender)).to eq(0) + + SharedResourceIdAllocator.lock_space!(ExternalTag, 'tags') + raise ActiveRecord::Rollback if outer_rollback + end + + expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i) + .to eq(1) + expect(ExternalTag.exists?(name: "#{ @prefix }_external")) + .to eq(!inner_rollback && !outer_rollback) + end + end + end + it 'raises on a real lock acquisition failure without inserting a record' do first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true) stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0) diff --git a/backend/spec/services/shared_resource_id_allocator_spec.rb b/backend/spec/services/shared_resource_id_allocator_spec.rb index 8ec2114..1fa3f18 100644 --- a/backend/spec/services/shared_resource_id_allocator_spec.rb +++ b/backend/spec/services/shared_resource_id_allocator_spec.rb @@ -101,7 +101,7 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do let(:record) { build(:external_tag) } let(:connection) { double('connection') } let(:transaction) { double('transaction', open?: true) } - let(:callbacks) { { } } + let(:callbacks) { [] } let(:sql) { [] } before do @@ -128,19 +128,24 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do end end allow(ExternalTag).to receive(:connection).and_return(connection) - allow(ExternalTag).to receive(:current_transaction).and_return(transaction) + allow(connection).to receive(:current_transaction).and_return(transaction) + allow(connection).to receive(:add_transaction_record) { callbacks << _1 } + allow(connection).to receive(:disconnect!) allow(ExternalTag).to receive(:uncached).and_yield - [:after_commit, :after_rollback].each do |event| - allow(transaction).to receive(event) { |&callback| callbacks[event] = callback } - end end - [:after_commit, :after_rollback].each do |event| + def finish_transaction event + allow(transaction).to receive(:open?).and_return(false) + callbacks.shift.public_send(event) + allow(transaction).to receive(:open?).and_return(true) + end + + [:committed!, :rolledback!].each do |event| it "holds the shared lock until #{ event }" do described_class.assign!(record) expect(record.id).to eq(9) expect(sql.grep(/RELEASE_LOCK/)).to be_empty - callbacks.fetch(event).call + finish_transaction(event) expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) end end @@ -155,7 +160,7 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do it 'releases a successfully acquired lock when allocation fails and rolls back' do allow(connection).to receive(:select_all).and_raise('query failed') expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed') - callbacks.fetch(:after_rollback).call + finish_transaction(:rolledback!) expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) end @@ -164,18 +169,17 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do described_class.assign!(record) expect(record.id).to eq(50) expect(sql.grep(/FOR UPDATE/)).to be_empty - callbacks.fetch(:after_commit).call + finish_transaction(:committed!) end it 'uses the same named lock within each space and different locks between spaces' do locks = [Tag, ExternalTag, TagDescription, TagGroup, TagDescriptionVersion, TagGroupVersion].map do |model| allow(model).to receive(:connection).and_return(connection) - allow(model).to receive(:current_transaction).and_return(transaction) allow(model).to receive(:uncached).and_yield record = model.new(id: 50) described_class.assign!(record) statement = sql.grep(/GET_LOCK/).last - callbacks.fetch(:after_commit).call + finish_transaction(:committed!) statement end expect(locks[0]).to eq(locks[1]) @@ -187,7 +191,21 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do it 'reports lock release failures' do described_class.assign!(record) allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil) - expect { callbacks.fetch(:after_commit).call } + expect { finish_transaction(:committed!) } .to raise_error(ActiveRecord::ActiveRecordError, /lock release failed/) + expect(connection).to have_received(:disconnect!) + end + + it 'keeps the lock and transfers cleanup when a savepoint rolls back' do + described_class.assign!(record) + callbacks.shift.rolledback! + + expect(callbacks.size).to eq(1) + expect(sql.grep(/RELEASE_LOCK/)).to be_empty + described_class.assign!(record) + expect(sql.grep(/GET_LOCK/).size).to eq(1) + + finish_transaction(:committed!) + expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) end end diff --git a/backend/spec/services/tag_name_transcription_spec.rb b/backend/spec/services/tag_name_transcription_spec.rb index 419d1cf..2036b96 100644 --- a/backend/spec/services/tag_name_transcription_spec.rb +++ b/backend/spec/services/tag_name_transcription_spec.rb @@ -17,7 +17,7 @@ RSpec.describe TagNameTranscription do 'ガギグゲゴダヂヅデド' => 'カキクケコタチチュテト', 'バビブベボパピプペポ' => 'パピプペポパピプペポ', 'ザジズゼゾ' => 'チャチチュチェチョ', - 'ジャジュジョ' => 'チヤチユチヨ', + 'ジャジュジョ' => 'チャチュチョ', 'アㇰアㇱアッアンアㇺアㇷ゚' => 'アㇰアㇱアッアンアㇺアㇷ゚', 'アㇻイㇼウㇽエㇾオㇿ' => 'アㇻイㇼウㇽエㇾオㇿ', '漢Aあ' => 'カンAア' }.each do |source, expected| @@ -26,9 +26,12 @@ RSpec.describe TagNameTranscription do end end - ['😀', 'ヴ', 'ティ', 'ㇵ', '魑', '𠮷', 'ー', '々'].each do |source| - it "rejects unsupported input #{ source }" do - expect { described_class.ainu(source) }.to raise_error(described_class::Unconvertible) + { '😀' => '', 'ヴ' => '', 'ティ' => 'テ', 'ㇵ' => '', + '魑' => '', '𠮷' => '', 'ー' => '', '々' => '', + 'ア😀😀イ' => 'ア_イ', '😀アイ😀' => 'アイ', + '_😀_' => '__', 'ア_😀_イ' => 'ア___イ', '漢魑字' => 'カン_チ' }.each do |source, expected| + it "preserves convertible portions of #{ source }" do + expect(described_class.ainu(source)).to eq(expected) end end end diff --git a/backend/spec/services/tag_version_recorder_spec.rb b/backend/spec/services/tag_version_recorder_spec.rb index 34b6c67..f2073c1 100644 --- a/backend/spec/services/tag_version_recorder_spec.rb +++ b/backend/spec/services/tag_version_recorder_spec.rb @@ -64,7 +64,7 @@ RSpec.describe TagVersionRecorder do it 'snapshots future languages together without a request locale argument' do prepare_ainu_names! - version = described_class.record!(tag:, event_type: :create, created_by_user: user) + version = described_class.record!(tag:, event_type: :update, created_by_user: user) expect(version.names).to eq( 'ja' => nipponese_snapshot, @@ -75,12 +75,12 @@ RSpec.describe TagVersionRecorder do it 'creates a version for an Ainu-only change and preserves prior names' do prepare_ainu_names! - original = described_class.record!(tag:, event_type: :create, created_by_user: user) + original = described_class.record!(tag:, event_type: :update, created_by_user: user) primary_tag_name_for(tag, 'ain').update!(name: 'Renamed') updated = described_class.record!(tag:, event_type: :update, created_by_user: user) - expect(updated.version_no).to eq(2) + expect(updated.version_no).to eq(original.version_no + 1) expect(original.reload.names.fetch('ain')).to include('name' => 'Ainu name') expect(updated.names.fetch('ain')).to include('name' => 'Renamed') expect(updated.names.fetch('ja')).to eq(nipponese_snapshot) @@ -88,11 +88,11 @@ RSpec.describe TagVersionRecorder do it 'does not create a version for an unchanged multilingual snapshot' do prepare_ainu_names! - described_class.record!(tag:, event_type: :create, created_by_user: user) + original = described_class.record!(tag:, event_type: :update, created_by_user: user) unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user) - expect(unchanged.version_no).to eq(1) - expect(tag.reload.tag_versions.count).to eq(1) + expect(unchanged.version_no).to eq(original.version_no) + expect(tag.reload.tag_versions.count).to eq(original.version_no) end end diff --git a/frontend/src/components/ErrorScreen.test.tsx b/frontend/src/components/ErrorScreen.test.tsx index 2f3bca3..b032bad 100644 --- a/frontend/src/components/ErrorScreen.test.tsx +++ b/frontend/src/components/ErrorScreen.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react' import { HelmetProvider } from 'react-helmet-async' +import { MemoryRouter } from 'react-router-dom' import { describe, expect, it } from 'vitest' import ErrorScreen from '@/components/ErrorScreen' @@ -12,9 +13,9 @@ describe ('ErrorScreen', () => { [503, '鯖死んでるよ(泣)'], ]) ('renders status %s', (status, message) => { render ( - + - , + , ) expect (screen.getByText (String (status))).toBeInTheDocument () @@ -24,9 +25,9 @@ describe ('ErrorScreen', () => { it ('throws for unsupported statuses', () => { expect (() => render ( - + - , + , )).toThrow () }) }) diff --git a/frontend/src/components/PostEditForm.test.tsx b/frontend/src/components/PostEditForm.test.tsx index 349c753..60c4170 100644 --- a/frontend/src/components/PostEditForm.test.tsx +++ b/frontend/src/components/PostEditForm.test.tsx @@ -1,8 +1,10 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { textContentOf } from '@/test/text' import PostEditForm from '@/components/PostEditForm' import { buildPost, buildTag } from '@/test/factories' +import { renderWithProviders as render } from '@/test/render' const postsApi = vi.hoisted (() => ({ updatePost: vi.fn (), @@ -19,7 +21,8 @@ const toastApi = vi.hoisted (() => ({ vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/api', () => api) vi.mock ('@/components/ui/use-toast', () => toastApi) -vi.mock ('@/lib/dialogues/useDialogue', () => ({ +vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({ + ...await importOriginal (), default: () => ({ choice: vi.fn (), }), @@ -60,11 +63,12 @@ describe ('PostEditForm', () => { parentPostIds: '3 4', tags: 'general-tag', }), - { baseVersionNo: 4 }, + { baseVersionNo: 4 }, 'ja', ) }) expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) - expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) + expect (toastApi.toast).toHaveBeenCalledTimes (1) + expect (textContentOf (toastApi.toast.mock.calls[0][0].description)).toBe ('更新しました.') }) it ('preserves duration while the video tag is temporarily removed', () => { @@ -78,7 +82,7 @@ describe ('PostEditForm', () => { render () - expect (screen.getByText ('動画時間').parentElement?.querySelector ('input')) + expect (screen.getByDisplayValue ('180.5')) .toHaveValue ('180.5') const tags = screen.getAllByRole ('textbox')[2] @@ -88,7 +92,7 @@ describe ('PostEditForm', () => { fireEvent.change (tags, { target: { value: '動画 general-tag' }, }) - expect (screen.getByText ('動画時間').parentElement?.querySelector ('input')) + expect (screen.getByDisplayValue ('180.5')) .toHaveValue ('180.5') }) diff --git a/frontend/src/components/PostEmbed.test.tsx b/frontend/src/components/PostEmbed.test.tsx index 15cd42c..d123068 100644 --- a/frontend/src/components/PostEmbed.test.tsx +++ b/frontend/src/components/PostEmbed.test.tsx @@ -1,8 +1,9 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import PostEmbed from '@/components/PostEmbed' import { buildPost } from '@/test/factories' +import { renderWithProviders as render } from '@/test/render' const dialogue = vi.hoisted (() => ({ confirm: vi.fn (), @@ -12,8 +13,9 @@ const nicoViewer = vi.hoisted (() => ({ props: vi.fn (), })) -vi.mock ('@/components/dialogues/DialogueProvider', () => ({ - useDialogue: () => dialogue, +vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({ + ...await importOriginal (), + default: () => dialogue, })) vi.mock ('@/components/NicoViewer', () => ({ diff --git a/frontend/src/components/PostFormTagsArea.test.tsx b/frontend/src/components/PostFormTagsArea.test.tsx index b9586db..5687cd2 100644 --- a/frontend/src/components/PostFormTagsArea.test.tsx +++ b/frontend/src/components/PostFormTagsArea.test.tsx @@ -26,7 +26,8 @@ describe ('PostFormTagsArea', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( '/tags/autocomplete', - { params: { q: '虹', nico: '0' } }, + { locale: 'ja', params: { q: '虹', nico: '0' }, + signal: expect.any (AbortSignal) }, ) }) expect (setTags).toHaveBeenCalledWith ('虹夏') diff --git a/frontend/src/components/PostList.test.tsx b/frontend/src/components/PostList.test.tsx index 8180a6a..78db6ac 100644 --- a/frontend/src/components/PostList.test.tsx +++ b/frontend/src/components/PostList.test.tsx @@ -26,11 +26,11 @@ describe ('PostList', () => { expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute ( 'href', - '/posts/1', + '/ja/posts/1', ) expect ( screen.getByRole ('link', { name: 'https://example.com/second' }), - ).toHaveAttribute ('href', '/posts/2') + ).toHaveAttribute ('href', '/ja/posts/2') }) it ('calls the optional click handler', () => { diff --git a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx index 37186e2..5c64f10 100644 --- a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx +++ b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx @@ -1,7 +1,8 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' +import { renderWithProviders as render } from '@/test/render' describe ('PostOriginalCreatedTimeField', () => { it ('updates from and before values', () => { diff --git a/frontend/src/components/SortHeader.test.tsx b/frontend/src/components/SortHeader.test.tsx index f11b93e..d797735 100644 --- a/frontend/src/components/SortHeader.test.tsx +++ b/frontend/src/components/SortHeader.test.tsx @@ -17,7 +17,7 @@ describe ('SortHeader', () => { expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute ( 'href', - '/posts?tags=x&page=1&order=title%3Adesc', + '/ja/posts?tags=x&page=1&order=title%3Adesc', ) }) @@ -33,7 +33,7 @@ describe ('SortHeader', () => { expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute ( 'href', - '/posts?page=1&order=updated_at%3Adesc', + '/ja/posts?page=1&order=updated_at%3Adesc', ) }) }) diff --git a/frontend/src/components/TagLink.test.tsx b/frontend/src/components/TagLink.test.tsx index 8922265..4aba0d6 100644 --- a/frontend/src/components/TagLink.test.tsx +++ b/frontend/src/components/TagLink.test.tsx @@ -13,7 +13,7 @@ describe ('TagLink', () => { expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute ( 'href', - '/posts?tags=%E8%99%B9+%E5%A4%8F', + '/ja/posts?tags=%E8%99%B9+%E5%A4%8F', ) expect (screen.getByText ('4')).toBeInTheDocument () }) @@ -40,7 +40,7 @@ describe ('TagLink', () => { expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute ( 'href', - '/wiki/a%2Fb', + '/ja/wiki/a%2Fb', ) }) diff --git a/frontend/src/components/TopNavUser.test.tsx b/frontend/src/components/TopNavUser.test.tsx index 49e1ee2..85ac99d 100644 --- a/frontend/src/components/TopNavUser.test.tsx +++ b/frontend/src/components/TopNavUser.test.tsx @@ -17,7 +17,7 @@ describe ('TopNavUser', () => { expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute ( 'href', - '/users/settings', + '/ja/users/settings', ) }) diff --git a/frontend/src/components/common/Pagination.test.tsx b/frontend/src/components/common/Pagination.test.tsx index d5a659b..162d1b0 100644 --- a/frontend/src/components/common/Pagination.test.tsx +++ b/frontend/src/components/common/Pagination.test.tsx @@ -13,11 +13,11 @@ describe ('Pagination', () => { expect (screen.getByLabelText ('前のページ')).toHaveAttribute ( 'href', - '/posts?tags=abc&page=2', + '/ja/posts?tags=abc&page=2', ) expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( 'href', - '/posts?tags=abc&page=4', + '/ja/posts?tags=abc&page=4', ) expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page') }) @@ -33,6 +33,6 @@ describe ('Pagination', () => { rerender () - expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2') + expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/ja/tags?page=2') }) }) diff --git a/frontend/src/components/common/TagInput.test.tsx b/frontend/src/components/common/TagInput.test.tsx index 96eabf8..af8cb9a 100644 --- a/frontend/src/components/common/TagInput.test.tsx +++ b/frontend/src/components/common/TagInput.test.tsx @@ -1,8 +1,9 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TagInput from '@/components/common/TagInput' import { buildTag } from '@/test/factories' +import { renderWithProviders as render } from '@/test/render' const api = vi.hoisted (() => ({ apiGet: vi.fn (), @@ -26,7 +27,8 @@ describe ('TagInput', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( '/tags/autocomplete', - { params: { q: '虹夏', nico: '0' } }, + { locale: 'ja', params: { q: '虹夏', nico: '0' }, + signal: expect.any (AbortSignal) }, ) }) expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') @@ -53,7 +55,8 @@ describe ('TagInput', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( '/tags/autocomplete', - { params: { q: '虹夏', nico: '1' } }, + { locale: 'ja', params: { q: '虹夏', nico: '1' }, + signal: expect.any (AbortSignal) }, ) }) }) diff --git a/frontend/src/components/dialogues/DialogueProvider.test.tsx b/frontend/src/components/dialogues/DialogueProvider.test.tsx index 171631f..3caf266 100644 --- a/frontend/src/components/dialogues/DialogueProvider.test.tsx +++ b/frontend/src/components/dialogues/DialogueProvider.test.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect } from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' import DialogueProvider from '@/components/dialogues/DialogueProvider' import useDialogue from '@/lib/dialogues/useDialogue' @@ -49,7 +50,7 @@ describe ('DialogueProvider', () => { ) } - render () + render () fireEvent.click (screen.getByRole ('button', { name: '開く' })) expect (screen.getByText ('一件目')).toBeInTheDocument () @@ -73,7 +74,7 @@ describe ('DialogueProvider', () => { ) } - render () + render () fireEvent.click (screen.getByRole ('button', { name: '開く' })) const dialogue = screen.getByRole ('dialog') @@ -101,7 +102,7 @@ describe ('DialogueProvider', () => { ) } - render () + render () fireEvent.click (screen.getByRole ('button', { name: '開く' })) const action = await screen.findByRole ('button', { name: '左操作' }) fireEvent.click (action) diff --git a/frontend/src/components/layout/SidebarComponent.test.tsx b/frontend/src/components/layout/SidebarComponent.test.tsx index c618446..8c7879a 100644 --- a/frontend/src/components/layout/SidebarComponent.test.tsx +++ b/frontend/src/components/layout/SidebarComponent.test.tsx @@ -1,8 +1,9 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { HelmetProvider } from 'react-helmet-async' import { beforeEach, describe, expect, it, vi } from 'vitest' import SidebarComponent from '@/components/layout/SidebarComponent' +import { renderWithProviders as render } from '@/test/render' const sidebarWidth = (element: Element | null): string => diff --git a/frontend/src/components/posts/PostCreationDataFieldsUsage.test.tsx b/frontend/src/components/posts/PostCreationDataFieldsUsage.test.tsx index d617eb9..2d2b2ea 100644 --- a/frontend/src/components/posts/PostCreationDataFieldsUsage.test.tsx +++ b/frontend/src/components/posts/PostCreationDataFieldsUsage.test.tsx @@ -1,7 +1,8 @@ -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { buildPostImportRow } from '@/test/postImportFactories' +import { renderWithProviders as render } from '@/test/render' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' diff --git a/frontend/src/components/posts/PostThumbnailPreview.test.tsx b/frontend/src/components/posts/PostThumbnailPreview.test.tsx index bb9a17e..656cc01 100644 --- a/frontend/src/components/posts/PostThumbnailPreview.test.tsx +++ b/frontend/src/components/posts/PostThumbnailPreview.test.tsx @@ -1,7 +1,8 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' +import { renderWithProviders as render } from '@/test/render' describe ('PostThumbnailPreview', () => { it ('keeps an existing blob preview URL unchanged for normal post forms', () => { diff --git a/frontend/src/components/posts/import/PostImportRowForm.test.tsx b/frontend/src/components/posts/import/PostImportRowForm.test.tsx index ad33957..bde56a9 100644 --- a/frontend/src/components/posts/import/PostImportRowForm.test.tsx +++ b/frontend/src/components/posts/import/PostImportRowForm.test.tsx @@ -1,8 +1,10 @@ -import { act, render, screen, waitFor } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import PostImportRowForm from '@/components/posts/import/PostImportRowForm' import { buildPostImportRow } from '@/test/postImportFactories' +import { renderWithProviders as render } from '@/test/render' +import { textContentOf } from '@/test/text' import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue' @@ -37,7 +39,7 @@ describe ('PostImportRowForm', () => { const titleInput = screen.getByDisplayValue ('manual title') await waitFor (() => expect (actions.at (-1)?.length).toBe (2)) - const reset = actions.at (-1)?.find (action => action.label === '変更をリセット') + const reset = actions.at (-1)?.find (action => textContentOf (action.label) === '変更をリセット') expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false }) await act (async () => { await reset?.onSelect () @@ -47,7 +49,7 @@ describe ('PostImportRowForm', () => { expect (titleInput).toHaveValue ('') expect (onSave).not.toHaveBeenCalled () - const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存') + const save = actions.at (-1)?.find (action => textContentOf (action.label) === '編輯内容を保存') await act (async () => { await save?.onSelect () }) @@ -76,7 +78,7 @@ describe ('PostImportRowForm', () => { await waitFor (() => expect (actions.length).toBe (2)) await act (async () => { - await actions.find (action => action.label === '変更をリセット')?.onSelect () + await actions.find (action => textContentOf (action.label) === '変更をリセット')?.onSelect () }) expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument () @@ -124,7 +126,7 @@ describe ('PostImportRowForm', () => { await waitFor (() => expect (actions.length).toBe (2)) await act (async () => { - void actions.find (action => action.label === '編輯内容を保存')?.onSelect () + void actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect () }) expect (onSave).toHaveBeenCalledWith ({ @@ -171,7 +173,7 @@ describe ('PostImportRowForm', () => { expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument () await act (async () => { - await actions.find (action => action.label === '編輯内容を保存')?.onSelect () + await actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect () }) expect (onSave).toHaveBeenCalledWith ({ @@ -218,7 +220,8 @@ describe ('PostImportRowForm', () => { onSave={vi.fn ()}/>) await waitFor (() => expect (actions.length).toBe (2)) - expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false) + const reset = actions.find (action => textContentOf (action.label) === '変更をリセット') + expect (reset?.disabled).toBe (false) }) it ( @@ -249,7 +252,8 @@ describe ('PostImportRowForm', () => { let savePromise: Promise | undefined await act (async () => { - savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect () + const save = actions.find (action => textContentOf (action.label) === '編輯内容を保存') + savePromise = save?.onSelect () }) await waitFor (() => { diff --git a/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx b/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx index 63879a7..41b9edd 100644 --- a/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx +++ b/frontend/src/components/posts/import/PostImportThumbnailPreview.test.tsx @@ -1,7 +1,8 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' +import { renderWithProviders as render } from '@/test/render' describe ('PostImportThumbnailPreview', () => { beforeEach (() => { diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts index bf8947f..a7ad271 100644 --- a/frontend/src/lib/gekanator.test.ts +++ b/frontend/src/lib/gekanator.test.ts @@ -52,7 +52,7 @@ describe('Gekanator API functions', () => { mockedApiGet.mockResolvedValueOnce({ posts }) await expect(fetchGekanatorPosts()).resolves.toEqual(posts) - expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts') + expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts', { locale: 'ja' }) }) it('returns questions from the Gekanator questions endpoint', async () => { @@ -60,7 +60,7 @@ describe('Gekanator API functions', () => { mockedApiGet.mockResolvedValueOnce({ questions }) await expect(fetchGekanatorQuestions()).resolves.toEqual(questions) - expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions') + expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions', { locale: 'ja' }) }) }) @@ -458,7 +458,7 @@ describe('Gekanator API writers', () => { original_answer: 'partial', }, ], - }) + }, { locale: 'ja' }) }) it('sends question suggestions using snake_case request keys', async () => { @@ -482,7 +482,7 @@ describe('Gekanator API writers', () => { gekanator_game_id: 100, question_text: '喜多ちゃんが泣いてる?', answer: 'yes', - }) + }, { locale: 'ja' }) }) it('sends extra question answers using snake_case request keys', async () => { @@ -517,7 +517,7 @@ describe('Gekanator API writers', () => { answer: 'probably_no', }, ], - }, + }, { locale: 'ja' }, ) }) }) diff --git a/frontend/src/lib/helpPrefetch.test.tsx b/frontend/src/lib/helpPrefetch.test.tsx index 788b8e3..60969be 100644 --- a/frontend/src/lib/helpPrefetch.test.tsx +++ b/frontend/src/lib/helpPrefetch.test.tsx @@ -43,7 +43,7 @@ describe ('Help prefetch integration', () => { Help - }/> + }/> ) const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx'] @@ -84,14 +84,14 @@ describe ('Help prefetch integration', () => { diff - }/> + }/> ) 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') + .toHaveAttribute ('href', '/ja/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 diff --git a/frontend/src/lib/prefetchers.test.ts b/frontend/src/lib/prefetchers.test.ts index b147c79..c79fce0 100644 --- a/frontend/src/lib/prefetchers.test.ts +++ b/frontend/src/lib/prefetchers.test.ts @@ -21,6 +21,8 @@ const wikiApi = vi.hoisted (() => ({ fetchWikiPage: vi.fn (), fetchWikiPageByTitle: vi.fn (), fetchWikiPages: vi.fn (), + fetchWikiPageByReference: vi.fn (), + fetchWikiAutolinkCandidates: vi.fn (), })) vi.mock ('@/lib/posts', () => postsApi) @@ -45,12 +47,14 @@ describe ('prefetchForURL', () => { wikiApi.fetchWikiPages.mockResolvedValue ([]) wikiApi.fetchWikiPage.mockResolvedValue ({ id: 1 }) wikiApi.fetchWikiPageByTitle.mockResolvedValue (null) + wikiApi.fetchWikiPageByReference.mockResolvedValue (null) + wikiApi.fetchWikiAutolinkCandidates.mockResolvedValue ([]) }) it ('prefetches post indexes from query parameters', async () => { await prefetchForURL ( qc (), - 'http://localhost/posts?tags=a+b&match=any&page=2&limit=5&order=title%3Aasc', + '/ja/posts?tags=a+b&match=any&page=2&limit=5&order=title%3Aasc', ) expect (postsApi.fetchPosts).toHaveBeenCalledWith ( @@ -60,20 +64,20 @@ describe ('prefetchForURL', () => { page: 2, limit: 5, order: 'title:asc', - }), + }), 'ja', ) }) it ('prefetches post detail pages', async () => { - await prefetchForURL (qc (), 'http://localhost/posts/12') + await prefetchForURL (qc (), '/ja/posts/12') - expect (postsApi.fetchPost).toHaveBeenCalledWith ('12') + expect (postsApi.fetchPost).toHaveBeenCalledWith ('12', 'ja') }) it ('prefetches tag indexes from query parameters', async () => { await prefetchForURL ( qc (), - 'http://localhost/tags?post=9&name=x&category=general&page=4&post_count_lte=10', + '/ja/tags?post=9&name=x&category=general&page=4&post_count_lte=10', ) expect (tagsApi.fetchTags).toHaveBeenCalledWith ( @@ -83,17 +87,17 @@ describe ('prefetchForURL', () => { category: 'general', page: 4, postCountLTE: 10, - }), + }), 'ja', ) }) it ('prefetches nico tag indexes and their alias from query parameters', async () => { await prefetchForURL ( qc (), - 'http://localhost/tags/nico?name=source&linked_tag=destination' + '/ja/tags/nico?name=source&linked_tag=destination' + '&link_status=linked&page=3&limit=10', ) - await prefetchForURL (qc (), 'http://localhost/nico/tags?page=2') + await prefetchForURL (qc (), '/ja/nico/tags?page=2') expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (1, { name: 'source', @@ -102,7 +106,7 @@ describe ('prefetchForURL', () => { page: 3, limit: 10, order: 'updated_at:desc', - }) + }, 'ja') expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (2, { name: '', linkedTag: '', @@ -110,28 +114,29 @@ describe ('prefetchForURL', () => { page: 2, limit: 20, order: 'updated_at:desc', - }) + }, 'ja') }) it ('prefetches wiki show pages and related tag/post data', async () => { - wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({ + wikiApi.fetchWikiPageByReference.mockResolvedValueOnce ({ id: 3, title: 'Actual', body: 'body', }) - await prefetchForURL (qc (), 'http://localhost/wiki/Alias') + await prefetchForURL (qc (), '/ja/wiki/Alias') - expect (wikiApi.fetchWikiPageByTitle).toHaveBeenCalledWith ('Alias', { version: undefined }) - expect (wikiApi.fetchWikiPage).toHaveBeenCalledWith ('3', {}) - expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual') + expect (wikiApi.fetchWikiPageByReference).toHaveBeenCalledWith ( + 'Alias', { version: undefined }, 'ja') + expect (wikiApi.fetchWikiPage).not.toHaveBeenCalled () + expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual', 'ja') expect (postsApi.fetchPosts).toHaveBeenCalledWith ( - expect.objectContaining ({ tags: 'Actual', limit: 8 }), + expect.objectContaining ({ tags: 'Actual', limit: 8 }), 'ja', ) }) it ('ignores routes without a prefetcher', async () => { - await prefetchForURL (qc (), 'http://localhost/unknown') + await prefetchForURL (qc (), '/ja/unknown') expect (postsApi.fetchPosts).not.toHaveBeenCalled () expect (tagsApi.fetchTags).not.toHaveBeenCalled () @@ -141,14 +146,14 @@ describe ('prefetchForURL', () => { it ('prefetches external tag post history without treating it as an internal tag', async () => { await prefetchForURL ( qc (), - 'http://localhost/posts/changes?external_tag=12&page=2&limit=50', + '/ja/posts/changes?external_tag=12&page=2&limit=50', ) expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({ externalTag: '12', page: 2, limit: 50, - }) + }, 'ja') expect (tagsApi.fetchTag).not.toHaveBeenCalled () }) diff --git a/frontend/src/mdx-components.test.tsx b/frontend/src/mdx-components.test.tsx index a4ff0b6..d630175 100644 --- a/frontend/src/mdx-components.test.tsx +++ b/frontend/src/mdx-components.test.tsx @@ -32,7 +32,7 @@ describe ('Help MDX with the application compiler', () => { expect (within (table).getAllByRole ('columnheader')).toHaveLength (2) expect (within (table).getAllByRole ('cell')).toHaveLength (4) expect (within (table).getByRole ('link', { name: 'ヘルプ' })) - .toHaveAttribute ('href', '/help/guide') + .toHaveAttribute ('href', '/ja/help/guide') expect (table.querySelector ('strong')).toHaveTextContent ('強調') }) @@ -72,6 +72,6 @@ describe ('Help MDX with the application compiler', () => { renderWithProviders () fireEvent.mouseEnter (screen.getByRole ('link', { name: '内部リンク' })) expect (prefetch).toHaveBeenCalledWith (expect.anything (), - new URL ('/help/guide', window.location.origin).href) + new URL ('/ja/help/guide', window.location.origin).href) }) }) diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.test.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.test.tsx index ae60500..3f7ac1c 100644 --- a/frontend/src/pages/deerjikists/DeerjikistDetailPage.test.tsx +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.test.tsx @@ -63,7 +63,7 @@ describe ('DeerjikistDetailPage', () => { await waitFor (() => { expect (api.apiPut).toHaveBeenCalledWith ( '/tags/7/deerjikists', - [{ platform: null, code: 'abc' }], + [{ platform: null, code: 'abc' }], { locale: 'ja' }, ) }) expect (await screen.findByText ('プラットフォームを入力してください.')).toBeInTheDocument () diff --git a/frontend/src/pages/help/HelpChangesPage.test.tsx b/frontend/src/pages/help/HelpChangesPage.test.tsx index a0f8177..30a08f9 100644 --- a/frontend/src/pages/help/HelpChangesPage.test.tsx +++ b/frontend/src/pages/help/HelpChangesPage.test.tsx @@ -8,7 +8,7 @@ import App from '@/App' import { helpFixture } from '@/test/helpFixtures' vi.hoisted (() => { - window.history.replaceState (null, '', '/help/changes') + window.history.replaceState (null, '', '/ja/help/changes') }) vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api) @@ -21,7 +21,10 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({ describe ('Help global edition history', () => { - beforeEach (() => helpFixture.reset ()) + beforeEach (() => { + helpFixture.reset () + vi.mocked (apiGet).mockResolvedValue ({ locales: [{ code: 'ja', name: '日本語' }] }) + }) it ('routes /help/changes to published editions in date and numeric order', async () => { const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } }) @@ -31,12 +34,12 @@ describe ('Help global edition history', () => { ) const links = await screen.findAllByRole ('link') const editions = links.map (link => link.getAttribute ('href')) - .filter (href => href?.startsWith ('/help/') && href.includes ('edition=')) + .filter (href => href?.startsWith ('/ja/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']) + `/ja/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`, + '/ja/help/guide?edition=2026-10-02.10', '/ja/help/guide?edition=2026-10-02.3', + '/ja/help/guide?edition=2026-10-02.2', '/ja/help/guide?edition=2026-10-02', + '/ja/help/guide?edition=2026-10-01']) expect (container).toHaveTextContent ('旧ホーム') expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent)) .toEqual (['差分', 'タイトル', '有効開始年月日']) @@ -45,11 +48,11 @@ describe ('Help global edition history', () => { 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', - '/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') + '/ja/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10', + '/ja/help/guide/diff?from=2026-10-02.2&to=2026-10-02.3', + '/ja/help/guide/diff?from=2026-10-02&to=2026-10-02.2', + '/ja/help/guide/diff?from=2026-10-01&to=2026-10-02']) + expect (apiGet).toHaveBeenCalledWith ('/locales') + expect (window.location.pathname).toBe ('/ja/help/changes') }) }) diff --git a/frontend/src/pages/help/HelpDetailPage.test.tsx b/frontend/src/pages/help/HelpDetailPage.test.tsx index 6bc3489..6f8b30a 100644 --- a/frontend/src/pages/help/HelpDetailPage.test.tsx +++ b/frontend/src/pages/help/HelpDetailPage.test.tsx @@ -17,19 +17,19 @@ const Navigation = () => { return ( <> {location.pathname}{location.search} - - + ) } -const renderDetail = (route = '/help/guide') => renderWithProviders ( +const renderDetail = (route = '/ja/help/guide') => renderWithProviders ( <> - }/> + }/> , { route }) @@ -39,7 +39,7 @@ describe ('Help details', () => { 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 }`) + const { container } = renderDetail (`/ja/help/guide?edition=${ edition }`) await screen.findByRole ('heading') expect (container).toHaveTextContent ('10月2日') expect (container).not.toHaveTextContent ('Invalid Date') @@ -61,31 +61,31 @@ describe ('Help details', () => { }) it ('uses the historical H1 in both page and document titles', async () => { - renderDetail ('/help/guide?edition=2026-10-01') + renderDetail ('/ja/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') + .toHaveAttribute ('href', '/ja/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') + renderDetail ('/ja/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') + .toHaveAttribute ('href', '/ja/help/guide?edition=2026-10-02.2') const next = screen.getByRole ('link', { name: '新 >' }) - expect (next).toHaveAttribute ('href', '/help/guide?edition=2026-10-02.10') + expect (next).toHaveAttribute ('href', '/ja/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', - '/help/guide?edition=2026-02-30', '/help/guide?edition=2026-10-02.2.extra', - '/help/guide?edition=2026-10-02.11']) ( + it.each (['/ja/help/missing', '/ja/help/guide?edition=missing', + '/ja/help/guide?edition=2026-02-30', '/ja/help/guide?edition=2026-10-02.2.extra', + '/ja/help/guide?edition=2026-10-02.11']) ( 'shows NotFound without redirecting %s', async route => { renderDetail (route) expect (await screen.findByText ('404')).toBeInTheDocument () diff --git a/frontend/src/pages/help/HelpListPage.test.tsx b/frontend/src/pages/help/HelpListPage.test.tsx index 218ccd1..022200e 100644 --- a/frontend/src/pages/help/HelpListPage.test.tsx +++ b/frontend/src/pages/help/HelpListPage.test.tsx @@ -14,9 +14,9 @@ describe ('Help search', () => { it ('initially lists each current article once with encoded detail links', async () => { renderWithProviders (, { route: '/help' }) expect (await screen.findByRole ('link', { name: 'ホーム第十版' })) - .toHaveAttribute ('href', '/help/guide') + .toHaveAttribute ('href', '/ja/help/guide') expect (screen.getByRole ('link', { name: '設定' })) - .toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`) + .toHaveAttribute ('href', `/ja/help/${encodeURIComponent ('設定 手順')}`) expect (screen.getByText ('2 件')).toBeInTheDocument () expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument () expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument () @@ -82,7 +82,7 @@ describe ('Help search', () => { renderWithProviders (, { route: `/help?${ params }` }) await screen.findByText (`${ found ? 1 : 0 } 件`) if (found) - expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/help/guide') + expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/ja/help/guide') else expect (screen.queryByRole ('link', { name: title })).not.toBeInTheDocument () expect (entry).toEqual ({ identifier: 'guide', edition: '2026-10-02.10', @@ -99,7 +99,7 @@ describe ('Help search', () => { route: `/help?order=title:${ direction }` }) await screen.findByText ('2 件') const expected = direction === 'asc' - ? ['/help/beta', '/help/alpha'] : ['/help/alpha', '/help/beta'] + ? ['/ja/help/beta', '/ja/help/alpha'] : ['/ja/help/alpha', '/ja/help/beta'] expect (Array.from (container.querySelectorAll ('tbody a'), link => link.getAttribute ('href'))).toEqual (expected) }) @@ -113,7 +113,7 @@ describe ('Help search', () => { 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']) + link.getAttribute ('href'))).toEqual (['/ja/help/alpha', '/ja/help/beta']) }) it.each (['asc', 'desc']) ('ignores title case before identifier ties in order %s', @@ -125,6 +125,6 @@ describe ('Help search', () => { 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']) + link.getAttribute ('href'))).toEqual (['/ja/help/alpha', '/ja/help/beta']) }) }) diff --git a/frontend/src/pages/materials/MaterialDetailPage.test.tsx b/frontend/src/pages/materials/MaterialDetailPage.test.tsx index 8a5819a..9a009fd 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.test.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { textContentOf } from '@/test/text' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import { buildMaterial, buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' @@ -59,7 +60,7 @@ describe ('MaterialDetailPage', () => { renderPage () await waitFor (() => { - expect (api.apiGet).toHaveBeenCalledWith ('/materials/8') + expect (api.apiGet).toHaveBeenCalledWith ('/materials/8', { locale: 'ja' }) }) expect (await screen.findByAltText ('素材タグ')).toHaveAttribute ('src', 'image.png') }) @@ -82,13 +83,15 @@ describe ('MaterialDetailPage', () => { fireEvent.click (screen.getByRole ('button', { name: '更新' })) await waitFor (() => { - expect (api.apiPut).toHaveBeenCalledWith ('/materials/8', expect.any (FormData)) + expect (api.apiPut).toHaveBeenCalledWith ( + '/materials/8', expect.any (FormData), { locale: 'ja' }) }) const formData = api.apiPut.mock.calls[0]?.[1] as FormData expect (formData.get ('tag')).toBe ('new') expect (formData.get ('url')).toBe ('https://example.com/ref') expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png') - expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' }) + expect (toastApi.toast).toHaveBeenCalledTimes (1) + expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('更新成功!') }) it ('shows not found when material API returns 404', async () => { @@ -97,7 +100,7 @@ describe ('MaterialDetailPage', () => { renderPage () - expect (await screen.findByText ('素材が見つかりませんでした.')).toBeInTheDocument () + expect (await screen.findByText ('404')).toBeInTheDocument () }) it ('shows an error for non-404 material API failures', async () => { diff --git a/frontend/src/pages/materials/MaterialListPage.test.tsx b/frontend/src/pages/materials/MaterialListPage.test.tsx index 663921d..319852b 100644 --- a/frontend/src/pages/materials/MaterialListPage.test.tsx +++ b/frontend/src/pages/materials/MaterialListPage.test.tsx @@ -23,7 +23,7 @@ describe ('MaterialListPage', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( '/materials', - { params: { + { locale: 'ja', params: { tag_state: 'all', media_kind: 'all', sort: 'created_at', @@ -53,8 +53,9 @@ describe ('MaterialListPage', () => { renderWithProviders (, { route: '/materials' }) expect ((await screen.findByText ('素材タグ')).closest ('a')) - .toHaveAttribute ('href', '/materials/8') - expect (screen.getByText (/画像 \/ 令和8年1月2日/)).toBeInTheDocument () + .toHaveAttribute ('href', '/ja/materials/8') + expect (screen.getByText ('令和8年1月2日(金) 12:04:05').parentElement) + .toHaveTextContent ('画像 / 令和8年1月2日') }) it ('submits list filters as material index params', async () => { @@ -93,7 +94,7 @@ describe ('MaterialListPage', () => { expect (await screen.findByRole ( 'link', { name: '素材検索トップへ戻る' }, - )).toHaveAttribute ('href', '/materials?material_filter=any') + )).toHaveAttribute ('href', '/ja/materials?material_filter=any') }) it ('uses list view for detailed material rows', async () => { @@ -113,7 +114,7 @@ describe ('MaterialListPage', () => { expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute ( 'href', - '/materials/9', + '/ja/materials/9', ) expect (screen.getByText ('サイズ:')).toBeInTheDocument () expect (screen.getByText ('2.0 KB')).toBeInTheDocument () @@ -153,11 +154,11 @@ describe ('MaterialListPage', () => { expect (screen.getByRole ('link', { name: '泣き' })).toHaveAttribute ( 'href', - '/materials?tag_id=30&include_descendants=1&group_by=parent_tag&material_filter=present', - ) + '/ja/materials?tag_id=30&include_descendants=1' + + '&group_by=parent_tag&material_filter=present') expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute ( 'href', - '/materials?material_filter=present', + '/ja/materials?material_filter=present', ) }) @@ -174,7 +175,7 @@ describe ('MaterialListPage', () => { expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument () expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute ( 'href', - '/materials?material_filter=present', + '/ja/materials?material_filter=present', ) }) }) diff --git a/frontend/src/pages/materials/MaterialNewPage.test.tsx b/frontend/src/pages/materials/MaterialNewPage.test.tsx index 1d3a1e8..d16263e 100644 --- a/frontend/src/pages/materials/MaterialNewPage.test.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Route, Routes, useLocation } from 'react-router-dom' +import { textContentOf } from '@/test/text' import MaterialNewPage from '@/pages/materials/MaterialNewPage' import { renderWithProviders } from '@/test/render' @@ -33,7 +34,7 @@ describe ('MaterialNewPage', () => { it ('initializes tag from query and submits form data', async () => { api.apiPost.mockResolvedValueOnce ({}) - renderWithProviders (, { route: '/materials/new?tag=%E8%99%B9%E5%A4%8F' }) + renderWithProviders (, { route: '/ja/materials/new?tag=%E8%99%B9%E5%A4%8F' }) expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('虹夏') fireEvent.change (screen.getAllByRole ('textbox')[1], { @@ -42,12 +43,14 @@ describe ('MaterialNewPage', () => { fireEvent.click (screen.getByRole ('button', { name: '追加' })) await waitFor (() => { - expect (api.apiPost).toHaveBeenCalledWith ('/materials', expect.any (FormData)) + expect (api.apiPost).toHaveBeenCalledWith ( + '/materials', expect.any (FormData), { locale: 'ja' }) }) const formData = api.apiPost.mock.calls[0]?.[1] as FormData expect (formData.get ('tag')).toBe ('虹夏') expect (formData.get ('url')).toBe ('https://example.com/ref') - expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' }) + expect (toastApi.toast).toHaveBeenCalledTimes (1) + expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('送信成功!') }) it ('shows validation errors for file and url fields', async () => { @@ -81,12 +84,12 @@ describe ('MaterialNewPage', () => { renderWithProviders ( - }/> - }/> + }/> + }/> , { route: - '/materials/new?tag=%E8%99%B9%E5%A4%8F' + '/ja/materials/new?tag=%E8%99%B9%E5%A4%8F' + '&return_to=%2Fmaterials%3Ftag_id%3D20', }, ) @@ -96,7 +99,7 @@ describe ('MaterialNewPage', () => { }) fireEvent.click (screen.getByRole ('button', { name: '追加' })) - expect (await screen.findByText ('/materials?tag_id=20')).toBeInTheDocument () + expect (await screen.findByText ('/ja/materials?tag_id=20')).toBeInTheDocument () }) it ('ignores unsafe return_to values after successful creation', async () => { @@ -104,12 +107,12 @@ describe ('MaterialNewPage', () => { renderWithProviders ( - }/> - }/> + }/> + }/> , { route: - '/materials/new?tag=%E8%99%B9%E5%A4%8F' + '/ja/materials/new?tag=%E8%99%B9%E5%A4%8F' + '&return_to=https%3A%2F%2Fexample.com%2Fevil', }, ) @@ -119,6 +122,6 @@ describe ('MaterialNewPage', () => { }) fireEvent.click (screen.getByRole ('button', { name: '追加' })) - expect (await screen.findByText ('/materials')).toBeInTheDocument () + expect (await screen.findByText ('/ja/materials')).toBeInTheDocument () }) }) diff --git a/frontend/src/pages/posts/PostDetailPage.test.tsx b/frontend/src/pages/posts/PostDetailPage.test.tsx index 1630f6d..a23d19a 100644 --- a/frontend/src/pages/posts/PostDetailPage.test.tsx +++ b/frontend/src/pages/posts/PostDetailPage.test.tsx @@ -63,7 +63,7 @@ describe ('PostDetailPage', () => { renderPage () await waitFor (() => { - expect (postsApi.fetchPost).toHaveBeenCalledWith ('9') + expect (postsApi.fetchPost).toHaveBeenCalledWith ('9', 'ja') }) expect (await screen.findByText ('Embed:https://example.com/9')).toBeInTheDocument () expect (screen.getByRole ('button', { name: '未閲覧' })).toBeInTheDocument () diff --git a/frontend/src/pages/posts/PostHistoryPage.test.tsx b/frontend/src/pages/posts/PostHistoryPage.test.tsx index b65a0b9..16cc1f8 100644 --- a/frontend/src/pages/posts/PostHistoryPage.test.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.test.tsx @@ -47,10 +47,10 @@ describe ('PostHistoryPage', () => { externalTag: '7', page: 1, limit: 20, - }) + }, 'ja') }) - expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7') + expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7', 'ja') expect (tagsApi.fetchTag).not.toHaveBeenCalled () expect ( diff --git a/frontend/src/pages/posts/PostImportReviewPage.test.tsx b/frontend/src/pages/posts/PostImportReviewPage.test.tsx index 9baec54..3a27996 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.test.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.test.tsx @@ -130,7 +130,9 @@ describe ('PostImportReviewPage', () => { renderReviewPage (['https://example.com/post']) - fireEvent.click (await screen.findByRole ('button', { name: '追加' })) + const submit = await screen.findByRole ('button', { name: '追加' }) + await waitFor (() => expect (submit).toBeEnabled ()) + fireEvent.click (submit) expect (await screen.findAllByText ('登録失敗')).toHaveLength (2) expect (screen.getAllByText ('invalid title')).toHaveLength (2) @@ -147,11 +149,14 @@ describe ('PostImportReviewPage', () => { renderReviewPage (['https://example.com/post']) - fireEvent.click (await screen.findByRole ('button', { name: '追加' })) + const submit = await screen.findByRole ('button', { name: '追加' }) + await waitFor (() => expect (submit).toBeEnabled ()) + fireEvent.click (submit) await waitFor (() => { - expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts') + expect (screen.getByLabelText ('current-location').textContent).toBe ('/ja/posts') }) - expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData)) + expect (api.apiPost).toHaveBeenCalledWith ( + '/posts/bulk', expect.any (FormData), { locale: 'ja' }) }) }) diff --git a/frontend/src/pages/posts/PostImportSourcePage.test.tsx b/frontend/src/pages/posts/PostImportSourcePage.test.tsx index 6e28d94..70432b1 100644 --- a/frontend/src/pages/posts/PostImportSourcePage.test.tsx +++ b/frontend/src/pages/posts/PostImportSourcePage.test.tsx @@ -36,7 +36,8 @@ describe ('PostImportSourcePage', () => { target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } }) fireEvent.click (screen.getByRole ('button', { name: '次へ' })) - expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument () + expect (screen.getByText ('ftp://example.com/file').closest ('li')) + .toHaveTextContent ('2 行目: HTTP または HTTPS') expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument () expect (input).toHaveAttribute ('aria-invalid', 'true') expect (router.navigate).not.toHaveBeenCalled () @@ -51,8 +52,8 @@ describe ('PostImportSourcePage', () => { fireEvent.click (screen.getByRole ('button', { name: '次へ' })) expect (router.navigate).toHaveBeenCalledWith ( - '/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba' - + '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc') + '/ja/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba' + + '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc', undefined) }) it ('disables Next when the encoded request target reaches 4096 bytes', () => { diff --git a/frontend/src/pages/posts/PostListPage.test.tsx b/frontend/src/pages/posts/PostListPage.test.tsx index 3a3bd0d..7aa8462 100644 --- a/frontend/src/pages/posts/PostListPage.test.tsx +++ b/frontend/src/pages/posts/PostListPage.test.tsx @@ -43,12 +43,12 @@ describe ('PostListPage', () => { page: 2, limit: 20, order: 'original_created_at:desc', - }), + }), 'ja', ) }) expect (await screen.findByRole ('link', { name: '投稿5' })).toHaveAttribute ( 'href', - '/posts/5', + '/ja/posts/5', ) expect (screen.getByText ('広場')).toBeInTheDocument () }) @@ -75,7 +75,7 @@ describe ('PostListPage', () => { expect (await screen.findByText ('Wiki body')).toBeInTheDocument () expect (screen.getByRole ('link', { name: 'Wiki を見る' })).toHaveAttribute ( 'href', - '/wiki/%E8%99%B9%E5%A4%8F', + '/ja/wiki/%E8%99%B9%E5%A4%8F', ) }) @@ -92,7 +92,7 @@ describe ('PostListPage', () => { expect.objectContaining ({ limit: 50, order: 'original_created_at:desc', - }), + }), 'ja', ) }) }) @@ -106,7 +106,7 @@ describe ('PostListPage', () => { await waitFor (() => { expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( - expect.objectContaining ({ page: 1, limit: 100 }), + expect.objectContaining ({ page: 1, limit: 100 }), 'ja', ) }) }) diff --git a/frontend/src/pages/posts/PostSearchPage.test.tsx b/frontend/src/pages/posts/PostSearchPage.test.tsx index 06e9127..855c27e 100644 --- a/frontend/src/pages/posts/PostSearchPage.test.tsx +++ b/frontend/src/pages/posts/PostSearchPage.test.tsx @@ -43,12 +43,12 @@ describe ('PostSearchPage', () => { match: 'any', page: 2, limit: 20, - }), + }), 'ja', ) }) expect ((await screen.findAllByRole ('link', { name: '検索対象' }))[0]).toHaveAttribute ( 'href', - '/posts/4', + '/ja/posts/4', ) }) @@ -72,7 +72,7 @@ describe ('PostSearchPage', () => { tags: 'tag', match: 'any', page: 1, - }), + }), 'ja', ) }) }) @@ -98,7 +98,7 @@ describe ('PostSearchPage', () => { await waitFor (() => { expect (postsApi.fetchPosts).toHaveBeenCalledWith ( - expect.objectContaining ({ limit: 50, order: 'created_at:desc' }), + expect.objectContaining ({ limit: 50, order: 'created_at:desc' }), 'ja', ) }) expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) @@ -117,7 +117,7 @@ describe ('PostSearchPage', () => { await waitFor (() => { expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( - expect.objectContaining ({ page: 1, limit: 100 }), + expect.objectContaining ({ page: 1, limit: 100 }), 'ja', ) }) }) diff --git a/frontend/src/pages/tags/NicoTagListPage.test.tsx b/frontend/src/pages/tags/NicoTagListPage.test.tsx index ae5eb17..75ceb2f 100644 --- a/frontend/src/pages/tags/NicoTagListPage.test.tsx +++ b/frontend/src/pages/tags/NicoTagListPage.test.tsx @@ -5,6 +5,7 @@ import NicoTagListPage from '@/pages/tags/NicoTagListPage' import { dateString } from '@/lib/utils' import { buildTag, buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import { textContentOf } from '@/test/text' import type { NicoTag } from '@/types' @@ -26,10 +27,10 @@ const scrollIntoView = vi.fn () vi.mock ('@/lib/api', () => api) vi.mock ('@/components/ui/use-toast', () => toastApi) -vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({ +vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({ ...await importOriginal< - typeof import('@/components/dialogues/DialogueProvider')> (), - useDialogue: () => dialogue })) + typeof import('@/lib/dialogues/useDialogue')> (), + default: () => dialogue })) const buildNicoTag = (values: Partial = {}): NicoTag => ({ ...buildTag (), @@ -74,7 +75,7 @@ describe ('NicoTagListPage', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( '/tags/nico', - { params: { + { locale: 'ja', params: { page: 2, limit: 20, name: 'linked', @@ -106,7 +107,7 @@ describe ('NicoTagListPage', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenLastCalledWith ( '/tags/nico', - { params: { + { locale: 'ja', params: { page: 1, limit: 20, name: 'linked', @@ -151,7 +152,7 @@ describe ('NicoTagListPage', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenLastCalledWith ( '/tags/nico', - { params: expect.objectContaining ({ + { locale: 'ja', params: expect.objectContaining ({ page: 1, name: 'source', linked_tag: 'destination', @@ -191,7 +192,7 @@ describe ('NicoTagListPage', () => { expect (api.apiPut).toHaveBeenCalledWith ( '/tags/nico/7', expect.any (FormData), - { headers: { 'Content-Type': 'multipart/form-data' } }, + { locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } }, ) }) expect (await screen.findByText ('1 件')).toBeInTheDocument () @@ -217,11 +218,11 @@ describe ('NicoTagListPage', () => { fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0]) await waitFor (() => { - expect (dialogue.confirm).toHaveBeenCalledWith ({ - title: '編集中の内容を破棄しますか?', - confirmText: '破棄', - variant: 'danger', - }) + expect (dialogue.confirm).toHaveBeenCalledTimes (1) + const options = dialogue.confirm.mock.calls[0][0] + expect (textContentOf (options.title)).toBe ('編集中の内容を破棄しますか?') + expect (textContentOf (options.confirmText)).toBe ('破棄') + expect (options.variant).toBe ('danger') }) expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1) expect (screen.getByLabelText ('連携する広場タグ')).toHaveValue ('入力中') diff --git a/frontend/src/pages/tags/TagDetailPage.test.tsx b/frontend/src/pages/tags/TagDetailPage.test.tsx index 9b9ca2c..567c554 100644 --- a/frontend/src/pages/tags/TagDetailPage.test.tsx +++ b/frontend/src/pages/tags/TagDetailPage.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { textContentOf } from '@/test/text' import TagDetailPage from '@/pages/tags/TagDetailPage' import { buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' @@ -60,11 +61,12 @@ describe ('TagDetailPage', () => { fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!) await waitFor (() => { - expect (api.apiPut).toHaveBeenCalledWith ('/tags/7', expect.any (FormData)) + expect (api.apiPut).toHaveBeenCalledWith ('/tags/7', expect.any (FormData), { locale: 'ja' }) }) const formData = api.apiPut.mock.calls[0]?.[1] as FormData expect (formData.get ('name')).toBe ('new') - expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) + expect (toastApi.toast).toHaveBeenCalledTimes (1) + expect (textContentOf (toastApi.toast.mock.calls[0][0].description)).toBe ('更新しました.') }) it ('keeps nico tags disabled', async () => { @@ -77,7 +79,7 @@ describe ('TagDetailPage', () => { it ('shows validation errors returned for tag fields', async () => { tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' })) - api.isApiError.mockReturnValue (true) + api.isApiError.mockImplementation (error => error?.response != null) api.apiPut.mockRejectedValueOnce ({ response: { status: 422, diff --git a/frontend/src/pages/tags/TagListPage.test.tsx b/frontend/src/pages/tags/TagListPage.test.tsx index a2d6763..fa003c5 100644 --- a/frontend/src/pages/tags/TagListPage.test.tsx +++ b/frontend/src/pages/tags/TagListPage.test.tsx @@ -48,12 +48,12 @@ describe ('TagListPage', () => { order: 'post_count:desc', postCountGTE: 5, deprecated: true, - }), + }), 'ja', ) }) expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute ( 'href', - '/tags/7', + '/ja/tags/7', ) expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0) expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1') @@ -77,7 +77,7 @@ describe ('TagListPage', () => { name: '虹夏', category: 'character', page: 1, - }), + }), 'ja', ) }) expect (screen.getByRole ('textbox')).toHaveValue ('虹夏') @@ -104,7 +104,7 @@ describe ('TagListPage', () => { await waitFor (() => { expect (tagsApi.fetchTags).toHaveBeenCalledWith ( - expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }), + expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }), 'ja', ) }) expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) @@ -123,7 +123,7 @@ describe ('TagListPage', () => { await waitFor (() => { expect (tagsApi.fetchTags).toHaveBeenLastCalledWith ( - expect.objectContaining ({ page: 1, limit: 100 }), + expect.objectContaining ({ page: 1, limit: 100 }), 'ja', ) }) }) @@ -152,10 +152,10 @@ describe ('TagListPage', () => { const externalLink = screen.getByRole ('link', { name: 'nico:external_collision' }) - expect (internalLink).toHaveAttribute ('href', '/tags/7') + expect (internalLink).toHaveAttribute ('href', '/ja/tags/7') expect (externalLink).toHaveAttribute ( 'href', - '/tags/nico?name=nico%3Aexternal_collision', + '/ja/tags/nico?name=nico%3Aexternal_collision', ) const internalRow = internalLink.closest ('tr') @@ -166,10 +166,10 @@ describe ('TagListPage', () => { expect ( within (internalRow!).getByRole ('link', { name: '耕作履歴' }), - ).toHaveAttribute ('href', '/posts/changes?tag=7') + ).toHaveAttribute ('href', '/ja/posts/changes?tag=7') expect ( within (externalRow!).getByRole ('link', { name: '耕作履歴' }), - ).toHaveAttribute ('href', '/posts/changes?external_tag=7') + ).toHaveAttribute ('href', '/ja/posts/changes?external_tag=7') }) }) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx index 0a78f51..3f5eae4 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx @@ -1,8 +1,10 @@ import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { QueryClient } from '@tanstack/react-query' import { Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' +import { theatresKeys } from '@/lib/queryKeys' import { buildPost, buildTag, buildTheatre, @@ -40,10 +42,10 @@ const postEmbed = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/posts', () => postsApi) -vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({ +vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({ ...await importOriginal< - typeof import('@/components/dialogues/DialogueProvider')> (), - useDialogue: () => dialogue })) + typeof import('@/lib/dialogues/useDialogue')> (), + default: () => dialogue })) vi.mock ('@/components/PostEmbed', () => ({ default: (props: { ref?: { current: unknown } @@ -94,19 +96,28 @@ const watchingUsers = [ { id: 2, name: 'another' }, ] -const renderPage = (user = buildUser ({ id: 1, role: 'member' })) => - renderWithProviders ( +const renderPage = ( + user = buildUser ({ id: 1, role: 'member' }), + prefetched = true, +) => { + const queryClient = new QueryClient ({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } } }) + if (prefetched) + { + queryClient.setQueryData (theatresKeys.show ('7', 'ja'), theatre) + queryClient.setQueryData (theatresKeys.programmes ('7', 'ja'), [programme]) + } + return renderWithProviders ( }/> , - { route: '/theatres/7' }, - ) + { route: '/theatres/7', queryClient }) +} const tagSection = (): HTMLElement => screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')! const mockDefaultApi = () => { - let commentsFetched = false let currentTheatreInfo = buildTheatreInfo ({ postId: currentPost.id, postStartedAt: '2026-01-02T03:04:05.000Z', @@ -120,17 +131,16 @@ const mockDefaultApi = () => { }, }) - api.apiGet.mockImplementation ((path: string) => { + api.apiGet.mockImplementation ((path: string, options?: { params?: { no_gt?: number } }) => { switch (path) { case '/theatres/7': return Promise.resolve (theatre) case '/theatres/7/comments': - if (commentsFetched) + if ((options?.params?.no_gt ?? 0) >= 2) return Promise.resolve ([]) - commentsFetched = true return Promise.resolve ([ buildTheatreComment ({ theatreId: 7, @@ -192,30 +202,32 @@ describe ('TheatreDetailPage', () => { }) it ('loads theatre state, comments, current post, programme history, and weights', async () => { - renderPage () + renderPage (undefined, false) expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument () - expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument () - expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument () + expect (await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })).toBeInTheDocument () + expect ((await screen.findAllByText ('視聴コメント', {}, { timeout: 4_000 }))[0]) + .toBeInTheDocument () expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument () expect (screen.getByText ('penalty 2')).toBeInTheDocument () await waitFor (() => { - expect (postsApi.fetchPost).toHaveBeenCalledWith ('10') + expect (postsApi.fetchPost).toHaveBeenCalledWith ('10', 'ja') }) }) it ('votes to skip the current post', async () => { renderPage () - await screen.findByText ('Embed:上映中の投稿') + await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 }) - fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' })) + fireEvent.click (await screen.findByRole ('button', + { name: 'スキップ 0 / 2' }, { timeout: 4_000 })) await waitFor (() => { expect (api.apiPut).toHaveBeenCalledWith ( '/theatres/7/skip_vote', - { post_id: 10 }, + { post_id: 10 }, { locale: 'ja' }, ) }) expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' })) @@ -247,7 +259,7 @@ describe ('TheatreDetailPage', () => { }) renderPage () - await screen.findByText ('Embed:上映中の投稿') + await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 }) const props = postEmbed.props.mock.calls.at (-1)![0] @@ -256,6 +268,7 @@ describe ('TheatreDetailPage', () => { props.onPlaybackChange (0) }) + await waitFor (() => expect (postEmbed.seek).toHaveBeenCalled ()) const seekMs = postEmbed.seek.mock.calls[0][0] expect (seekMs).toBeGreaterThanOrEqual (7_000) expect (seekMs).toBeLessThan (10_000) @@ -279,14 +292,14 @@ describe ('TheatreDetailPage', () => { children: [childTag], }) - postsApi.fetchPost.mockResolvedValueOnce (buildPost ({ + postsApi.fetchPost.mockResolvedValue (buildPost ({ ...currentPost, tags: [parentTag, duplicateParentTag], })) renderPage () - await screen.findByText ('Embed:上映中の投稿') + await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 }) expect (within (tagSection ()).getByRole ('link', { name: '親タグ' })) .toBeInTheDocument () @@ -337,9 +350,9 @@ describe ('TheatreDetailPage', () => { renderPage () - await screen.findByText ('Embed:上映中の投稿') + await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 }) await waitFor (() => { - expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching') + expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching', {}, { locale: 'ja' }) }) await waitFor (() => { @@ -351,13 +364,16 @@ describe ('TheatreDetailPage', () => { it ('deletes an owned comment after confirmation', async () => { renderPage () - fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0]) + await screen.findByRole ('button', { name: 'スキップ 0 / 2' }, { timeout: 4_000 }) + + fireEvent.click ((await screen.findAllByLabelText ( + 'コメントを削除', {}, { timeout: 4_000 }))[0]) await waitFor (() => { expect (dialogue.confirm).toHaveBeenCalled () }) await waitFor (() => { - expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2') + expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2', { locale: 'ja' }) }) expect (await screen.findAllByText ('削除されました.')).toHaveLength (2) }) diff --git a/frontend/src/pages/users/SettingPage.test.tsx b/frontend/src/pages/users/SettingPage.test.tsx index d88798a..52f7383 100644 --- a/frontend/src/pages/users/SettingPage.test.tsx +++ b/frontend/src/pages/users/SettingPage.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { textContentOf } from '@/test/text' import SettingPage from '@/pages/users/SettingPage' import { buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' @@ -68,7 +69,8 @@ describe ('SettingPage', () => { const formData = api.apiPut.mock.calls[0]?.[1] as FormData expect (formData.get ('name')).toBe ('new') expect (setUser).toHaveBeenCalled () - expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' }) + expect (toastApi.toast).toHaveBeenCalledTimes (1) + expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('表示名を更新しました.') }) it ('shows validation errors returned for the name field', async () => { diff --git a/frontend/src/pages/wiki/WikiDetailPage.test.tsx b/frontend/src/pages/wiki/WikiDetailPage.test.tsx index 84268c9..ce3bb7e 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.test.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.test.tsx @@ -24,7 +24,13 @@ const postsApi = vi.hoisted (() => ({ fetchPosts: vi.fn (), })) -vi.mock ('@/lib/wiki', () => wikiApi) +vi.mock ('@/lib/wiki', () => ({ + ...wikiApi, + fetchWikiAutolinkCandidates: async () => [], + fetchWikiPageByReference: (reference: string, ...args: unknown[]) => + /^\d+$/.test (reference) + ? wikiApi.fetchWikiPage (reference, ...args) + : wikiApi.fetchWikiPageByTitle (reference, ...args) })) vi.mock ('@/lib/tags', () => tagsApi) vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () })) @@ -63,15 +69,15 @@ describe ('WikiDetailPage', () => { vi.mocked (getHelpTitleIndex).mockReturnValueOnce (pending) tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ', hasWiki: false })) renderWithProviders ( - }/>, - { route: `/wiki/${ encodeURIComponent ('既存タグ') }` }) + }/>, + { route: `/ja/wiki/${ encodeURIComponent ('既存タグ') }` }) await waitFor (() => expect (getHelpTitleIndex).toHaveBeenCalled ()) expect (screen.queryByText ('404')).not.toBeInTheDocument () expect (screen.queryByRole ('link', { name: '新規作成してください' })) .not.toBeInTheDocument () await act (async () => finish ({ })) expect (await screen.findByRole ('link', { name: '新規作成してください' })) - .toHaveAttribute ('href', `/wiki/new?title=${ encodeURIComponent ('既存タグ') }`) + .toHaveAttribute ('href', `/ja/wiki/new?title=${ encodeURIComponent ('既存タグ') }`) }) it ('creates a tag description through the existing form and displays its content', async () => { @@ -79,13 +85,13 @@ describe ('WikiDetailPage', () => { resourceType: 'tag_description' }) tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false })) vi.mocked (apiPost).mockResolvedValueOnce (page) - const path = `/wiki/${ encodeURIComponent (page.title) }` + const path = `/ja/wiki/${ encodeURIComponent (page.title) }` const { container, queryClient } = renderWithProviders ( <> - }/> - }/> + }/> + }/> , { route: path }) fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' })) @@ -108,11 +114,10 @@ describe ('WikiDetailPage', () => { expect (form.get ('title')).toBe (page.title) expect (form.get ('body')).toBe (page.body) expect (apiPost).toHaveBeenCalledWith ('/wiki', form, - { headers: { 'Content-Type': 'multipart/form-data' } }) + { locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } }) expect (screen.queryByText ('404')).not.toBeInTheDocument () expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toEqual (page) - await waitFor (() => expect (queryClient.getQueryData (wikiKeys.index ({ }))) - .toEqual ([page])) + expect (queryClient.getQueryState (wikiKeys.index ({ }))?.isInvalidated).toBe (true) await waitFor (() => expect (queryClient.getQueryData (tagsKeys.show (page.title))) .toMatchObject ({ hasWiki: true })) expect (getHelpTitleIndex).toHaveBeenCalledTimes (1) @@ -121,7 +126,7 @@ describe ('WikiDetailPage', () => { it ('keeps failed saves on the form without changing cached Wiki or tag state', async () => { const title = '既存タグ' const tag = buildTag ({ name: title, hasWiki: false }) - const path = `/wiki/new?title=${ encodeURIComponent (title) }` + const path = `/ja/wiki/new?title=${ encodeURIComponent (title) }` vi.mocked (apiPost).mockRejectedValueOnce (new Error ('save failed')) const { queryClient } = renderWithProviders ( <>, { route: path }) @@ -141,8 +146,8 @@ describe ('WikiDetailPage', () => { it ('shows NotFound when neither a tag nor a Help title exists', async () => { renderWithProviders ( - }/>, - { route: '/wiki/missing' }) + }/>, + { route: '/ja/wiki/missing' }) expect (await screen.findByText ('404')).toBeInTheDocument () expect (screen.queryByRole ('link', { name: '新規作成してください' })) .not.toBeInTheDocument () @@ -153,8 +158,8 @@ describe ('WikiDetailPage', () => { wikiApi.fetchWikiPageByTitle.mockRejectedValue (new Error (message)) tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ' })) renderWithProviders ( - }/>, - { route: `/wiki/${ encodeURIComponent ('既存タグ') }` }) + }/>, + { route: `/ja/wiki/${ encodeURIComponent ('既存タグ') }` }) expect (await screen.findByRole ('alert')).toHaveTextContent (message) expect (getHelpTitleIndex).not.toHaveBeenCalled () expect (screen.queryByRole ('link', { name: '新規作成してください' })) @@ -174,9 +179,9 @@ describe ('WikiDetailPage', () => { renderWithProviders ( - }/> + }/> , - { route: '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' }, + { route: '/ja/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' }, ) const marker = await screen.findByText ('(廃止)') diff --git a/frontend/src/pages/wiki/WikiDiffPage.test.tsx b/frontend/src/pages/wiki/WikiDiffPage.test.tsx index 185b904..0ff0088 100644 --- a/frontend/src/pages/wiki/WikiDiffPage.test.tsx +++ b/frontend/src/pages/wiki/WikiDiffPage.test.tsx @@ -36,10 +36,10 @@ describe ('WikiDiffPage', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( '/wiki/3/diff', - { params: { from: '1', to: '2' } }, + { locale: 'ja', params: { from: '1', to: '2' } }, ) }) - expect (screen.getByText ('差分対象')).toBeInTheDocument () + expect (await screen.findByText ('差分対象')).toBeInTheDocument () expect (screen.getByText ('same')).toBeInTheDocument () expect (screen.getByText ('added line')).toBeInTheDocument () expect (screen.getByText ('removed line')).toBeInTheDocument () diff --git a/frontend/src/pages/wiki/WikiEditPage.test.tsx b/frontend/src/pages/wiki/WikiEditPage.test.tsx index 43675a3..bbc1eeb 100644 --- a/frontend/src/pages/wiki/WikiEditPage.test.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { Route, Routes } from 'react-router-dom' import { describe, expect, it, vi } from 'vitest' +import { textContentOf } from '@/test/text' import WikiEditPage from '@/pages/wiki/WikiEditPage' import { buildUser, buildWikiPage } from '@/test/factories' import { renderWithProviders } from '@/test/render' @@ -50,9 +51,9 @@ describe ('WikiEditPage', () => { renderPage () await waitFor (() => { - expect (api.apiGet).toHaveBeenCalledWith ('/wiki/9') + expect (api.apiGet).toHaveBeenCalledWith ('/wiki/9', { locale: 'ja' }) }) - expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('既存') + expect ((await screen.findAllByRole ('textbox'))[0]).toHaveValue ('既存') expect (screen.getByLabelText ('本文エディタ')).toHaveValue ('本文') }) @@ -71,12 +72,13 @@ describe ('WikiEditPage', () => { expect (api.apiPut).toHaveBeenCalledWith ( '/wiki/9', expect.any (FormData), - { headers: { 'Content-Type': 'multipart/form-data' } }, + { locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } }, ) }) const formData = api.apiPut.mock.calls[0]?.[1] as FormData expect (formData.get ('title')).toBe ('更新済み') expect (formData.get ('body')).toBe ('更新本文') - expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) + expect (toastApi.toast).toHaveBeenCalledTimes (1) + expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('投稿成功!') }) }) diff --git a/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx b/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx index 22f49cd..46d5742 100644 --- a/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx +++ b/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx @@ -36,7 +36,7 @@ const renderWiki = (title: string) => { }/> - }/> + }/> unexpected redirect}/> , { route }) diff --git a/frontend/src/pages/wiki/WikiHistoryPage.test.tsx b/frontend/src/pages/wiki/WikiHistoryPage.test.tsx index 35aa8f5..c6cb9a1 100644 --- a/frontend/src/pages/wiki/WikiHistoryPage.test.tsx +++ b/frontend/src/pages/wiki/WikiHistoryPage.test.tsx @@ -32,7 +32,7 @@ describe ('WikiHistoryPage', () => { const link = await screen.findByRole ('link', { name: '旧タグ' }) const marker = screen.getByText ('(廃止)') - expect (link).toHaveAttribute ('href', '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0?version=2') + expect (link).toHaveAttribute ('href', '/ja/wiki/3?version=2') expect (marker.closest ('a')).toBeNull () }) }) diff --git a/frontend/src/pages/wiki/WikiNewPage.test.tsx b/frontend/src/pages/wiki/WikiNewPage.test.tsx index 95626db..1c1bb3e 100644 --- a/frontend/src/pages/wiki/WikiNewPage.test.tsx +++ b/frontend/src/pages/wiki/WikiNewPage.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { textContentOf } from '@/test/text' import WikiNewPage from '@/pages/wiki/WikiNewPage' import { buildUser, buildWikiPage } from '@/test/factories' import { renderWithProviders } from '@/test/render' @@ -50,12 +51,13 @@ describe ('WikiNewPage', () => { expect (api.apiPost).toHaveBeenCalledWith ( '/wiki', expect.any (FormData), - { headers: { 'Content-Type': 'multipart/form-data' } }, + { locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } }, ) }) const formData = api.apiPost.mock.calls[0]?.[1] as FormData expect (formData.get ('title')).toBe ('下書き') expect (formData.get ('body')).toBe ('本文') - expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) + expect (toastApi.toast).toHaveBeenCalledTimes (1) + expect (textContentOf (toastApi.toast.mock.calls[0][0].title)).toBe ('投稿成功!') }) }) diff --git a/frontend/src/pages/wiki/WikiSearchPage.test.tsx b/frontend/src/pages/wiki/WikiSearchPage.test.tsx index 7619f66..e584eeb 100644 --- a/frontend/src/pages/wiki/WikiSearchPage.test.tsx +++ b/frontend/src/pages/wiki/WikiSearchPage.test.tsx @@ -16,8 +16,8 @@ describe ('Wiki resource identity', () => { vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })]) renderWithProviders () expect (await screen.findByRole ('link', { name: '虹夏' })) - .toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F') - expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } }) + .toHaveAttribute ('href', '/ja/wiki/%E8%99%B9%E5%A4%8F') + expect (apiGet).toHaveBeenCalledWith ('/wiki', { locale: 'ja', params: { title: '' } }) }) it ('searches by title on submit', async () => { @@ -28,7 +28,7 @@ describe ('Wiki resource identity', () => { fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } }) fireEvent.click (screen.getByRole ('button', { name: '検索' })) await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith ( - '/wiki', { params: { title: '検索' } })) + '/wiki', { locale: 'ja', params: { title: '検索' } })) expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument () }) @@ -49,7 +49,7 @@ describe ('Wiki resource identity', () => { await screen.findByRole ('link', { name: '説明' }) expect (screen.getByRole ('link', { name: 'タググループ:一覧' })) - .toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`) + .toHaveAttribute ('href', `/ja/wiki/${ encodeURIComponent (group.title) }`) await waitFor (() => { expect (errors.mock.calls.some (args => args.some (value => String (value).includes ('same key')))).toBe (false) diff --git a/frontend/src/test/compileMdx.ts b/frontend/src/test/compileMdx.ts index e910fc7..587b8f5 100644 --- a/frontend/src/test/compileMdx.ts +++ b/frontend/src/test/compileMdx.ts @@ -14,7 +14,7 @@ export const transformMdx = async (source: string, suffix = '.mdx') => { 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}`) + return await plugin.transform.call (plugin, source, `/test/help-table${suffix}`) ?? null } diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx index 4f6c0ac..ae2463a 100644 --- a/frontend/src/test/render.tsx +++ b/frontend/src/test/render.tsx @@ -11,8 +11,8 @@ import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import type { ReactElement, ReactNode } from 'react' type Options = { - route?: string -} + route?: string + queryClient?: QueryClient } const TestContentContext = createContext (null) @@ -34,10 +34,8 @@ export const renderWithProviders = ( ui: ReactElement, options: Options = {}, ) => { - const queryClient = new QueryClient ({ - defaultOptions: { - queries: { retry: false } }, - }) + const queryClient = options.queryClient ?? new QueryClient ({ + defaultOptions: { queries: { retry: false } } }) const Wrapper = ({ children }: { children: ReactNode }) => { const [router] = useState (() => createMemoryRouter ([{ diff --git a/frontend/src/test/text.tsx b/frontend/src/test/text.tsx new file mode 100644 index 0000000..88e0f49 --- /dev/null +++ b/frontend/src/test/text.tsx @@ -0,0 +1,10 @@ +import { renderToStaticMarkup } from 'react-dom/server' + +import type { ReactNode } from 'react' + + +export const textContentOf = (node: ReactNode): string => { + const container = document.createElement ('div') + container.innerHTML = renderToStaticMarkup (<>{node}) + return container.textContent ?? '' +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index b9b2947..593ad38 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -7,5 +7,6 @@ export default mergeConfig ( viteConfig, defineConfig ({ test: { environment: 'jsdom', + exclude: ['**/node_modules/**', '**/dist/**', 'server.ssr.test.mjs'], setupFiles: './src/test/setup.ts', globals: false } }))