このコミットが含まれているのは:
2026-10-19 20:56:07 +09:00
コミット d365406019
60個のファイルの変更、434行の追加、285行の削除
+8 -10
ファイルの表示
@@ -67,20 +67,18 @@ RSpec.describe Locale, type: :model do
primary_flg: true, auto_generated: false) primary_flg: true, auto_generated: false)
end end
it 'creates a primary name when the language has only an alias' do it 'adds the new language primary while preserving existing names and aliases' do
prepare_ainu_reference! tag = create(:tag, primary_name: 'named_tag')
tag = create(:tag, primary_name: 'alias_only_tag') create(:tag_name, :alias, name: 'existing_alias', tag:,
alias_name = create(:tag_name, :alias, name: 'alias_en', language_code: 'ja', script_code: 'Jpan')
tag:, original_names = tag.tag_names.order(:id).map(&:attributes)
language_code: 'ain', script_code: 'Kana')
create_ainu_locale! create_ainu_locale!
expect(alias_name.reload).to have_attributes( expect(tag.tag_names.where(language_code: 'ja').order(:id).map(&:attributes))
tag_id: tag.id, .to eq(original_names)
language_code: 'ain', script_code: 'Kana', primary_flg: false)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true) 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, script_code: 'Kana', primary_flg: true,
auto_generated: true) auto_generated: true)
expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true).count).to eq(1) expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true).count).to eq(1)
+2
ファイルの表示
@@ -113,6 +113,8 @@ RSpec.describe TagNameSanitisationRule, type: :model do
it 'renames without merging tags or deleting either language identity' do it 'renames without merging tags or deleting either language identity' do
locale locale
nipponese = create(:tag, primary_name: 'foobar') 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', ainu_name = create(:tag_name, name: 'temporary',
language_code: 'ain', script_code: 'Kana') language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name) ainu = create(:tag, primary_tag_name: ainu_name)
+4 -4
ファイルの表示
@@ -66,7 +66,7 @@ RSpec.describe Tag, type: :model do
it 'canonicalises aliases in the supplied language only' do it 'canonicalises aliases in the supplied language only' do
prepare_ainu_reference! prepare_ainu_reference!
nipponese = create(:tag, primary_name: 'nipponese_canonical') 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') language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name) ainu = create(:tag, primary_tag_name: ainu_name)
primary_tag_name_for(ainu, 'ja').update!( primary_tag_name_for(ainu, 'ja').update!(
@@ -265,7 +265,7 @@ RSpec.describe Tag, type: :model do
expect(tag.category).to eq('character') expect(tag.category).to eq('character')
end 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 locale
allow(TagName).to receive(:generate_name).and_call_original 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)) name: 'ainu_origin', auto_generated: false))
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(have_attributes( .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) expect(TagName).to have_received(:generate_name)
.with(Locale.nipponese, tag, nil) .with(Locale.nipponese, tag, tag.tag_name('ain'))
end end
it 'gives a new tag one primary name for every active language' do it 'gives a new tag one primary name for every active language' do
+8 -3
ファイルの表示
@@ -153,11 +153,14 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
let(:ainu_name) do let(:ainu_name) do
create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana') create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana')
end 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 let(:nipponese_tag) do
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag| 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!(name: 'shared_name')
primary_tag_name_for(tag, 'ja').update_columns(name: 'shared_name')
end end
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 it 'allows a rename to a name already used in Nipponese' do
locale locale
nipponese = create(:tag, primary_name: 'rename_target') 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' } patch "/tags/#{ tag.id }", params: { locale: 'ain-Kana', name: 'rename_target' }
+15 -7
ファイルの表示
@@ -2323,8 +2323,10 @@ RSpec.describe 'Posts API', type: :request do
'prev' => nil 'prev' => nil
) )
expect(latest.fetch('tags')).to include( expect(latest.fetch('tags')).to include(
{ 'name' => 'spec_tag_2', 'type' => 'added' }, { 'name' => 'spec_tag_2', 'restore_name' => 'spec_tag_2',
{ 'name' => 'spec_tag', 'type' => 'removed' } 'external' => false, 'type' => 'added' },
{ 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
'external' => false, 'type' => 'removed' }
) )
expect(latest.fetch('original_created_from')).to eq( expect(latest.fetch('original_created_from')).to eq(
'current' => oc_from.iso8601, 'current' => oc_from.iso8601,
@@ -2351,7 +2353,8 @@ RSpec.describe 'Posts API', type: :request do
'prev' => nil 'prev' => nil
) )
expect(first.fetch('tags')).to include( 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) expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end end
@@ -2435,7 +2438,9 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3) expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').first.fetch('tags')).to include( 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 end
it 'filters versions by tag when the current snapshot includes the tag' do 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]['post_id']).to eq(post_record.id)
expect(versions[0]['version_no']).to eq(2) expect(versions[0]['version_no']).to eq(2)
expect(versions[0]['tags']).to include( 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 end
@@ -2467,10 +2473,12 @@ RSpec.describe 'Posts API', type: :request do
first = versions[1] first = versions[1]
expect(latest['tags']).to include( 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( expect(first['tags']).to include(
{ 'name' => 'spec_tag', 'type' => 'added' } { 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
'external' => false, 'type' => 'added' }
) )
end end
+2 -2
ファイルの表示
@@ -18,9 +18,9 @@ RSpec.describe PostCreatePlan do
ainu_name = create(:tag_name, name: 'shared_name', ainu_name = create(:tag_name, name: 'shared_name',
language_code: 'ain', script_code: 'Kana') language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name, category: :character) 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) 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!(name: 'shared_name')
primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name')
ainu_parent_name = create(:tag_name, name: 'ainu_parent', ainu_parent_name = create(:tag_name, name: 'ainu_parent',
language_code: 'ain', script_code: 'Kana') language_code: 'ain', script_code: 'Kana')
ainu_parent = create(:tag, primary_tag_name: ainu_parent_name, ainu_parent = create(:tag, primary_tag_name: ainu_parent_name,
+34
ファイルの表示
@@ -141,6 +141,40 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
result(first) result(first)
end 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 it 'raises on a real lock acquisition failure without inserting a record' do
first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true) first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true)
stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0) stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0)
+30 -12
ファイルの表示
@@ -101,7 +101,7 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
let(:record) { build(:external_tag) } let(:record) { build(:external_tag) }
let(:connection) { double('connection') } let(:connection) { double('connection') }
let(:transaction) { double('transaction', open?: true) } let(:transaction) { double('transaction', open?: true) }
let(:callbacks) { { } } let(:callbacks) { [] }
let(:sql) { [] } let(:sql) { [] }
before do before do
@@ -128,19 +128,24 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
end end
end end
allow(ExternalTag).to receive(:connection).and_return(connection) 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 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 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 it "holds the shared lock until #{ event }" do
described_class.assign!(record) described_class.assign!(record)
expect(record.id).to eq(9) expect(record.id).to eq(9)
expect(sql.grep(/RELEASE_LOCK/)).to be_empty expect(sql.grep(/RELEASE_LOCK/)).to be_empty
callbacks.fetch(event).call finish_transaction(event)
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
end end
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 it 'releases a successfully acquired lock when allocation fails and rolls back' do
allow(connection).to receive(:select_all).and_raise('query failed') allow(connection).to receive(:select_all).and_raise('query failed')
expect { described_class.assign!(record) }.to raise_error(RuntimeError, '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) expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
end end
@@ -164,18 +169,17 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
described_class.assign!(record) described_class.assign!(record)
expect(record.id).to eq(50) expect(record.id).to eq(50)
expect(sql.grep(/FOR UPDATE/)).to be_empty expect(sql.grep(/FOR UPDATE/)).to be_empty
callbacks.fetch(:after_commit).call finish_transaction(:committed!)
end end
it 'uses the same named lock within each space and different locks between spaces' do 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| locks = [Tag, ExternalTag, TagDescription, TagGroup, TagDescriptionVersion, TagGroupVersion].map do |model|
allow(model).to receive(:connection).and_return(connection) allow(model).to receive(:connection).and_return(connection)
allow(model).to receive(:current_transaction).and_return(transaction)
allow(model).to receive(:uncached).and_yield allow(model).to receive(:uncached).and_yield
record = model.new(id: 50) record = model.new(id: 50)
described_class.assign!(record) described_class.assign!(record)
statement = sql.grep(/GET_LOCK/).last statement = sql.grep(/GET_LOCK/).last
callbacks.fetch(:after_commit).call finish_transaction(:committed!)
statement statement
end end
expect(locks[0]).to eq(locks[1]) expect(locks[0]).to eq(locks[1])
@@ -187,7 +191,21 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
it 'reports lock release failures' do it 'reports lock release failures' do
described_class.assign!(record) described_class.assign!(record)
allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil) 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/) .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
end end
+7 -4
ファイルの表示
@@ -17,7 +17,7 @@ RSpec.describe TagNameTranscription do
'ガギグゲゴダヂヅデド' => 'カキクケコタチチュテト', 'ガギグゲゴダヂヅデド' => 'カキクケコタチチュテト',
'バビブベボパピプペポ' => 'パピプペポパピプペポ', 'バビブベボパピプペポ' => 'パピプペポパピプペポ',
'ザジズゼゾ' => 'チャチチュチェチョ', 'ザジズゼゾ' => 'チャチチュチェチョ',
'ジャジュジョ' => 'チヤチユチヨ', 'ジャジュジョ' => 'チャチュチョ',
'アㇰアㇱアッアンアㇺアㇷ゚' => 'アㇰアㇱアッアンアㇺアㇷ゚', 'アㇰアㇱアッアンアㇺアㇷ゚' => 'アㇰアㇱアッアンアㇺアㇷ゚',
'アㇻイㇼウㇽエㇾオㇿ' => 'アㇻイㇼウㇽエㇾオㇿ', 'アㇻイㇼウㇽエㇾオㇿ' => 'アㇻイㇼウㇽエㇾオㇿ',
'漢Aあ' => 'カンAア' }.each do |source, expected| '漢Aあ' => 'カンAア' }.each do |source, expected|
@@ -26,9 +26,12 @@ RSpec.describe TagNameTranscription do
end end
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 end
end end
+6 -6
ファイルの表示
@@ -64,7 +64,7 @@ RSpec.describe TagVersionRecorder do
it 'snapshots future languages together without a request locale argument' do it 'snapshots future languages together without a request locale argument' do
prepare_ainu_names! 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( expect(version.names).to eq(
'ja' => nipponese_snapshot, '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 it 'creates a version for an Ainu-only change and preserves prior names' do
prepare_ainu_names! 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') primary_tag_name_for(tag, 'ain').update!(name: 'Renamed')
updated = described_class.record!(tag:, event_type: :update, created_by_user: user) 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(original.reload.names.fetch('ain')).to include('name' => 'Ainu name')
expect(updated.names.fetch('ain')).to include('name' => 'Renamed') expect(updated.names.fetch('ain')).to include('name' => 'Renamed')
expect(updated.names.fetch('ja')).to eq(nipponese_snapshot) 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 it 'does not create a version for an unchanged multilingual snapshot' do
prepare_ainu_names! 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) unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user)
expect(unchanged.version_no).to eq(1) expect(unchanged.version_no).to eq(original.version_no)
expect(tag.reload.tag_versions.count).to eq(1) expect(tag.reload.tag_versions.count).to eq(original.version_no)
end end
end end
+5 -4
ファイルの表示
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async' import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import ErrorScreen from '@/components/ErrorScreen' import ErrorScreen from '@/components/ErrorScreen'
@@ -12,9 +13,9 @@ describe ('ErrorScreen', () => {
[503, '鯖死んでるよ(泣)'], [503, '鯖死んでるよ(泣)'],
]) ('renders status %s', (status, message) => { ]) ('renders status %s', (status, message) => {
render ( render (
<HelmetProvider> <MemoryRouter><HelmetProvider>
<ErrorScreen status={status}/> <ErrorScreen status={status}/>
</HelmetProvider>, </HelmetProvider></MemoryRouter>,
) )
expect (screen.getByText (String (status))).toBeInTheDocument () expect (screen.getByText (String (status))).toBeInTheDocument ()
@@ -24,9 +25,9 @@ describe ('ErrorScreen', () => {
it ('throws for unsupported statuses', () => { it ('throws for unsupported statuses', () => {
expect (() => render ( expect (() => render (
<HelmetProvider> <MemoryRouter><HelmetProvider>
<ErrorScreen status={418}/> <ErrorScreen status={418}/>
</HelmetProvider>, </HelmetProvider></MemoryRouter>,
)).toThrow () )).toThrow ()
}) })
}) })
+10 -6
ファイルの表示
@@ -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 { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import PostEditForm from '@/components/PostEditForm' import PostEditForm from '@/components/PostEditForm'
import { buildPost, buildTag } from '@/test/factories' import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const postsApi = vi.hoisted (() => ({ const postsApi = vi.hoisted (() => ({
updatePost: vi.fn (), updatePost: vi.fn (),
@@ -19,7 +21,8 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/dialogues/useDialogue', () => ({ vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/dialogues/useDialogue')> (),
default: () => ({ default: () => ({
choice: vi.fn (), choice: vi.fn (),
}), }),
@@ -60,11 +63,12 @@ describe ('PostEditForm', () => {
parentPostIds: '3 4', parentPostIds: '3 4',
tags: 'general-tag', tags: 'general-tag',
}), }),
{ baseVersionNo: 4 }, { baseVersionNo: 4 }, 'ja',
) )
}) })
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) 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', () => { it ('preserves duration while the video tag is temporarily removed', () => {
@@ -78,7 +82,7 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>) render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input')) expect (screen.getByDisplayValue ('180.5'))
.toHaveValue ('180.5') .toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2] const tags = screen.getAllByRole ('textbox')[2]
@@ -88,7 +92,7 @@ describe ('PostEditForm', () => {
fireEvent.change (tags, { fireEvent.change (tags, {
target: { value: '動画 general-tag' }, target: { value: '動画 general-tag' },
}) })
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input')) expect (screen.getByDisplayValue ('180.5'))
.toHaveValue ('180.5') .toHaveValue ('180.5')
}) })
+5 -3
ファイルの表示
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import PostEmbed from '@/components/PostEmbed' import PostEmbed from '@/components/PostEmbed'
import { buildPost } from '@/test/factories' import { buildPost } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const dialogue = vi.hoisted (() => ({ const dialogue = vi.hoisted (() => ({
confirm: vi.fn (), confirm: vi.fn (),
@@ -12,8 +13,9 @@ const nicoViewer = vi.hoisted (() => ({
props: vi.fn (), props: vi.fn (),
})) }))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
useDialogue: () => dialogue, ...await importOriginal<typeof import ('@/lib/dialogues/useDialogue')> (),
default: () => dialogue,
})) }))
vi.mock ('@/components/NicoViewer', () => ({ vi.mock ('@/components/NicoViewer', () => ({
+2 -1
ファイルの表示
@@ -26,7 +26,8 @@ describe ('PostFormTagsArea', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete', '/tags/autocomplete',
{ params: { q: '虹', nico: '0' } }, { locale: 'ja', params: { q: '虹', nico: '0' },
signal: expect.any (AbortSignal) },
) )
}) })
expect (setTags).toHaveBeenCalledWith ('虹夏') expect (setTags).toHaveBeenCalledWith ('虹夏')
+2 -2
ファイルの表示
@@ -26,11 +26,11 @@ describe ('PostList', () => {
expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute (
'href', 'href',
'/posts/1', '/ja/posts/1',
) )
expect ( expect (
screen.getByRole ('link', { name: 'https://example.com/second' }), screen.getByRole ('link', { name: 'https://example.com/second' }),
).toHaveAttribute ('href', '/posts/2') ).toHaveAttribute ('href', '/ja/posts/2')
}) })
it ('calls the optional click handler', () => { it ('calls the optional click handler', () => {
+2 -1
ファイルの表示
@@ -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 { describe, expect, it, vi } from 'vitest'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import { renderWithProviders as render } from '@/test/render'
describe ('PostOriginalCreatedTimeField', () => { describe ('PostOriginalCreatedTimeField', () => {
it ('updates from and before values', () => { it ('updates from and before values', () => {
+2 -2
ファイルの表示
@@ -17,7 +17,7 @@ describe ('SortHeader', () => {
expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute (
'href', '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 ( expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute (
'href', 'href',
'/posts?page=1&order=updated_at%3Adesc', '/ja/posts?page=1&order=updated_at%3Adesc',
) )
}) })
}) })
+2 -2
ファイルの表示
@@ -13,7 +13,7 @@ describe ('TagLink', () => {
expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute (
'href', 'href',
'/posts?tags=%E8%99%B9+%E5%A4%8F', '/ja/posts?tags=%E8%99%B9+%E5%A4%8F',
) )
expect (screen.getByText ('4')).toBeInTheDocument () expect (screen.getByText ('4')).toBeInTheDocument ()
}) })
@@ -40,7 +40,7 @@ describe ('TagLink', () => {
expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute (
'href', 'href',
'/wiki/a%2Fb', '/ja/wiki/a%2Fb',
) )
}) })
+1 -1
ファイルの表示
@@ -17,7 +17,7 @@ describe ('TopNavUser', () => {
expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute (
'href', 'href',
'/users/settings', '/ja/users/settings',
) )
}) })
+3 -3
ファイルの表示
@@ -13,11 +13,11 @@ describe ('Pagination', () => {
expect (screen.getByLabelText ('前のページ')).toHaveAttribute ( expect (screen.getByLabelText ('前のページ')).toHaveAttribute (
'href', 'href',
'/posts?tags=abc&page=2', '/ja/posts?tags=abc&page=2',
) )
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
'href', 'href',
'/posts?tags=abc&page=4', '/ja/posts?tags=abc&page=4',
) )
expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page') expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page')
}) })
@@ -33,6 +33,6 @@ describe ('Pagination', () => {
rerender (<Pagination page={1} totalPages={2}/>) rerender (<Pagination page={1} totalPages={2}/>)
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2') expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/ja/tags?page=2')
}) })
}) })
+6 -3
ファイルの表示
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
import { buildTag } from '@/test/factories' import { buildTag } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (), apiGet: vi.fn (),
@@ -26,7 +27,8 @@ describe ('TagInput', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete', '/tags/autocomplete',
{ params: { q: '虹夏', nico: '0' } }, { locale: 'ja', params: { q: '虹夏', nico: '0' },
signal: expect.any (AbortSignal) },
) )
}) })
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
@@ -53,7 +55,8 @@ describe ('TagInput', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete', '/tags/autocomplete',
{ params: { q: '虹夏', nico: '1' } }, { locale: 'ja', params: { q: '虹夏', nico: '1' },
signal: expect.any (AbortSignal) },
) )
}) })
}) })
+4 -3
ファイルの表示
@@ -1,6 +1,7 @@
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import DialogueProvider from '@/components/dialogues/DialogueProvider' import DialogueProvider from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
@@ -49,7 +50,7 @@ describe ('DialogueProvider', () => {
</button>) </button>)
} }
render (<DialogueProvider><Launcher/></DialogueProvider>) render (<MemoryRouter><DialogueProvider><Launcher/></DialogueProvider></MemoryRouter>)
fireEvent.click (screen.getByRole ('button', { name: '開く' })) fireEvent.click (screen.getByRole ('button', { name: '開く' }))
expect (screen.getByText ('一件目')).toBeInTheDocument () expect (screen.getByText ('一件目')).toBeInTheDocument ()
@@ -73,7 +74,7 @@ describe ('DialogueProvider', () => {
</button>) </button>)
} }
render (<DialogueProvider><Launcher/></DialogueProvider>) render (<MemoryRouter><DialogueProvider><Launcher/></DialogueProvider></MemoryRouter>)
fireEvent.click (screen.getByRole ('button', { name: '開く' })) fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const dialogue = screen.getByRole ('dialog') const dialogue = screen.getByRole ('dialog')
@@ -101,7 +102,7 @@ describe ('DialogueProvider', () => {
</button>) </button>)
} }
render (<DialogueProvider><Launcher/></DialogueProvider>) render (<MemoryRouter><DialogueProvider><Launcher/></DialogueProvider></MemoryRouter>)
fireEvent.click (screen.getByRole ('button', { name: '開く' })) fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const action = await screen.findByRole ('button', { name: '左操作' }) const action = await screen.findByRole ('button', { name: '左操作' })
fireEvent.click (action) fireEvent.click (action)
+2 -1
ファイルの表示
@@ -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 { HelmetProvider } from 'react-helmet-async'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { renderWithProviders as render } from '@/test/render'
const sidebarWidth = (element: Element | null): string => const sidebarWidth = (element: Element | null): string =>
+2 -1
ファイルの表示
@@ -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 { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories' import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders as render } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
+2 -1
ファイルの表示
@@ -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 { describe, expect, it } from 'vitest'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { renderWithProviders as render } from '@/test/render'
describe ('PostThumbnailPreview', () => { describe ('PostThumbnailPreview', () => {
it ('keeps an existing blob preview URL unchanged for normal post forms', () => { it ('keeps an existing blob preview URL unchanged for normal post forms', () => {
+12 -8
ファイルの表示
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm' import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import { buildPostImportRow } from '@/test/postImportFactories' 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' import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -37,7 +39,7 @@ describe ('PostImportRowForm', () => {
const titleInput = screen.getByDisplayValue ('manual title') const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2)) 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 }) expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => { await act (async () => {
await reset?.onSelect () await reset?.onSelect ()
@@ -47,7 +49,7 @@ describe ('PostImportRowForm', () => {
expect (titleInput).toHaveValue ('') expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled () 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 act (async () => {
await save?.onSelect () await save?.onSelect ()
}) })
@@ -76,7 +78,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2)) await waitFor (() => expect (actions.length).toBe (2))
await act (async () => { await act (async () => {
await actions.find (action => action.label === '変更をリセット')?.onSelect () await actions.find (action => textContentOf (action.label) === '変更をリセット')?.onSelect ()
}) })
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument () expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
@@ -124,7 +126,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2)) await waitFor (() => expect (actions.length).toBe (2))
await act (async () => { await act (async () => {
void actions.find (action => action.label === '編輯内容を保存')?.onSelect () void actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
}) })
expect (onSave).toHaveBeenCalledWith ({ expect (onSave).toHaveBeenCalledWith ({
@@ -171,7 +173,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument () expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => { await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect () await actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
}) })
expect (onSave).toHaveBeenCalledWith ({ expect (onSave).toHaveBeenCalledWith ({
@@ -218,7 +220,8 @@ describe ('PostImportRowForm', () => {
onSave={vi.fn ()}/>) onSave={vi.fn ()}/>)
await waitFor (() => expect (actions.length).toBe (2)) 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 ( it (
@@ -249,7 +252,8 @@ describe ('PostImportRowForm', () => {
let savePromise: Promise<boolean | void> | undefined let savePromise: Promise<boolean | void> | undefined
await act (async () => { await act (async () => {
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect () const save = actions.find (action => textContentOf (action.label) === '編輯内容を保存')
savePromise = save?.onSelect ()
}) })
await waitFor (() => { await waitFor (() => {
+2 -1
ファイルの表示
@@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { renderWithProviders as render } from '@/test/render'
describe ('PostImportThumbnailPreview', () => { describe ('PostImportThumbnailPreview', () => {
beforeEach (() => { beforeEach (() => {
+5 -5
ファイルの表示
@@ -52,7 +52,7 @@ describe('Gekanator API functions', () => {
mockedApiGet.mockResolvedValueOnce({ posts }) mockedApiGet.mockResolvedValueOnce({ posts })
await expect(fetchGekanatorPosts()).resolves.toEqual(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 () => { it('returns questions from the Gekanator questions endpoint', async () => {
@@ -60,7 +60,7 @@ describe('Gekanator API functions', () => {
mockedApiGet.mockResolvedValueOnce({ questions }) mockedApiGet.mockResolvedValueOnce({ questions })
await expect(fetchGekanatorQuestions()).resolves.toEqual(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', original_answer: 'partial',
}, },
], ],
}) }, { locale: 'ja' })
}) })
it('sends question suggestions using snake_case request keys', async () => { it('sends question suggestions using snake_case request keys', async () => {
@@ -482,7 +482,7 @@ describe('Gekanator API writers', () => {
gekanator_game_id: 100, gekanator_game_id: 100,
question_text: '喜多ちゃんが泣いてる?', question_text: '喜多ちゃんが泣いてる?',
answer: 'yes', answer: 'yes',
}) }, { locale: 'ja' })
}) })
it('sends extra question answers using snake_case request keys', async () => { it('sends extra question answers using snake_case request keys', async () => {
@@ -517,7 +517,7 @@ describe('Gekanator API writers', () => {
answer: 'probably_no', answer: 'probably_no',
}, },
], ],
}, }, { locale: 'ja' },
) )
}) })
}) })
+3 -3
ファイルの表示
@@ -43,7 +43,7 @@ describe ('Help prefetch integration', () => {
<PrefetchLink to="/help/guide">Help</PrefetchLink> <PrefetchLink to="/help/guide">Help</PrefetchLink>
<Routes> <Routes>
<Route path="/" element={null}/> <Route path="/" element={null}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/> <Route path="/ja/help/:identifier" element={<HelpDetailPage/>}/>
</Routes> </Routes>
</>) </>)
const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx'] const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx']
@@ -84,14 +84,14 @@ describe ('Help prefetch integration', () => {
<PrefetchLink to={path}>diff</PrefetchLink> <PrefetchLink to={path}>diff</PrefetchLink>
<Routes> <Routes>
<Route path="/" element={null}/> <Route path="/" element={null}/>
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/> <Route path="/ja/help/:identifier/diff" element={<HelpDiffPage/>}/>
</Routes> </Routes>
</>) </>)
await Promise.all ([prefetchers.prefetchForURL (queryClient, path), await Promise.all ([prefetchers.prefetchForURL (queryClient, path),
prefetchers.prefetchForURL (queryClient, path)]) prefetchers.prefetchForURL (queryClient, path)])
fireEvent.click (screen.getByRole ('link', { name: 'diff' })) fireEvent.click (screen.getByRole ('link', { name: 'diff' }))
expect (await screen.findByRole ('link', { name: '旧ホーム (2026-10-01)' })) 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 () expect (screen.getByText ('+ # ホーム第十版')).toBeInTheDocument ()
Object.entries (helpFixture.raw).forEach (([name, load]) => { Object.entries (helpFixture.raw).forEach (([name, load]) => {
const expected = /\/ja\/(2026-10-01|2026-10-02\.10)\.mdx$/.test (name) ? 1 : 0 const expected = /\/ja\/(2026-10-01|2026-10-02\.10)\.mdx$/.test (name) ? 1 : 0
+24 -19
ファイルの表示
@@ -21,6 +21,8 @@ const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (), fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (), fetchWikiPageByTitle: vi.fn (),
fetchWikiPages: vi.fn (), fetchWikiPages: vi.fn (),
fetchWikiPageByReference: vi.fn (),
fetchWikiAutolinkCandidates: vi.fn (),
})) }))
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
@@ -45,12 +47,14 @@ describe ('prefetchForURL', () => {
wikiApi.fetchWikiPages.mockResolvedValue ([]) wikiApi.fetchWikiPages.mockResolvedValue ([])
wikiApi.fetchWikiPage.mockResolvedValue ({ id: 1 }) wikiApi.fetchWikiPage.mockResolvedValue ({ id: 1 })
wikiApi.fetchWikiPageByTitle.mockResolvedValue (null) wikiApi.fetchWikiPageByTitle.mockResolvedValue (null)
wikiApi.fetchWikiPageByReference.mockResolvedValue (null)
wikiApi.fetchWikiAutolinkCandidates.mockResolvedValue ([])
}) })
it ('prefetches post indexes from query parameters', async () => { it ('prefetches post indexes from query parameters', async () => {
await prefetchForURL ( await prefetchForURL (
qc (), 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 ( expect (postsApi.fetchPosts).toHaveBeenCalledWith (
@@ -60,20 +64,20 @@ describe ('prefetchForURL', () => {
page: 2, page: 2,
limit: 5, limit: 5,
order: 'title:asc', order: 'title:asc',
}), }), 'ja',
) )
}) })
it ('prefetches post detail pages', async () => { 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 () => { it ('prefetches tag indexes from query parameters', async () => {
await prefetchForURL ( await prefetchForURL (
qc (), 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 ( expect (tagsApi.fetchTags).toHaveBeenCalledWith (
@@ -83,17 +87,17 @@ describe ('prefetchForURL', () => {
category: 'general', category: 'general',
page: 4, page: 4,
postCountLTE: 10, postCountLTE: 10,
}), }), 'ja',
) )
}) })
it ('prefetches nico tag indexes and their alias from query parameters', async () => { it ('prefetches nico tag indexes and their alias from query parameters', async () => {
await prefetchForURL ( await prefetchForURL (
qc (), 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', + '&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, { expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (1, {
name: 'source', name: 'source',
@@ -102,7 +106,7 @@ describe ('prefetchForURL', () => {
page: 3, page: 3,
limit: 10, limit: 10,
order: 'updated_at:desc', order: 'updated_at:desc',
}) }, 'ja')
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (2, { expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (2, {
name: '', name: '',
linkedTag: '', linkedTag: '',
@@ -110,28 +114,29 @@ describe ('prefetchForURL', () => {
page: 2, page: 2,
limit: 20, limit: 20,
order: 'updated_at:desc', order: 'updated_at:desc',
}) }, 'ja')
}) })
it ('prefetches wiki show pages and related tag/post data', async () => { it ('prefetches wiki show pages and related tag/post data', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({ wikiApi.fetchWikiPageByReference.mockResolvedValueOnce ({
id: 3, id: 3,
title: 'Actual', title: 'Actual',
body: 'body', body: 'body',
}) })
await prefetchForURL (qc (), 'http://localhost/wiki/Alias') await prefetchForURL (qc (), '/ja/wiki/Alias')
expect (wikiApi.fetchWikiPageByTitle).toHaveBeenCalledWith ('Alias', { version: undefined }) expect (wikiApi.fetchWikiPageByReference).toHaveBeenCalledWith (
expect (wikiApi.fetchWikiPage).toHaveBeenCalledWith ('3', {}) 'Alias', { version: undefined }, 'ja')
expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual') expect (wikiApi.fetchWikiPage).not.toHaveBeenCalled ()
expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual', 'ja')
expect (postsApi.fetchPosts).toHaveBeenCalledWith ( expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({ tags: 'Actual', limit: 8 }), expect.objectContaining ({ tags: 'Actual', limit: 8 }), 'ja',
) )
}) })
it ('ignores routes without a prefetcher', async () => { it ('ignores routes without a prefetcher', async () => {
await prefetchForURL (qc (), 'http://localhost/unknown') await prefetchForURL (qc (), '/ja/unknown')
expect (postsApi.fetchPosts).not.toHaveBeenCalled () expect (postsApi.fetchPosts).not.toHaveBeenCalled ()
expect (tagsApi.fetchTags).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 () => { it ('prefetches external tag post history without treating it as an internal tag', async () => {
await prefetchForURL ( await prefetchForURL (
qc (), 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 ({ expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
externalTag: '12', externalTag: '12',
page: 2, page: 2,
limit: 50, limit: 50,
}) }, 'ja')
expect (tagsApi.fetchTag).not.toHaveBeenCalled () expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
}) })
+2 -2
ファイルの表示
@@ -32,7 +32,7 @@ describe ('Help MDX with the application compiler', () => {
expect (within (table).getAllByRole ('columnheader')).toHaveLength (2) expect (within (table).getAllByRole ('columnheader')).toHaveLength (2)
expect (within (table).getAllByRole ('cell')).toHaveLength (4) expect (within (table).getAllByRole ('cell')).toHaveLength (4)
expect (within (table).getByRole ('link', { name: 'ヘルプ' })) expect (within (table).getByRole ('link', { name: 'ヘルプ' }))
.toHaveAttribute ('href', '/help/guide') .toHaveAttribute ('href', '/ja/help/guide')
expect (table.querySelector ('strong')).toHaveTextContent ('強調') expect (table.querySelector ('strong')).toHaveTextContent ('強調')
}) })
@@ -72,6 +72,6 @@ describe ('Help MDX with the application compiler', () => {
renderWithProviders (<Content/>) renderWithProviders (<Content/>)
fireEvent.mouseEnter (screen.getByRole ('link', { name: '内部リンク' })) fireEvent.mouseEnter (screen.getByRole ('link', { name: '内部リンク' }))
expect (prefetch).toHaveBeenCalledWith (expect.anything (), expect (prefetch).toHaveBeenCalledWith (expect.anything (),
new URL ('/help/guide', window.location.origin).href) new URL ('/ja/help/guide', window.location.origin).href)
}) })
}) })
+1 -1
ファイルの表示
@@ -63,7 +63,7 @@ describe ('DeerjikistDetailPage', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ( expect (api.apiPut).toHaveBeenCalledWith (
'/tags/7/deerjikists', '/tags/7/deerjikists',
[{ platform: null, code: 'abc' }], [{ platform: null, code: 'abc' }], { locale: 'ja' },
) )
}) })
expect (await screen.findByText ('プラットフォームを入力してください.')).toBeInTheDocument () expect (await screen.findByText ('プラットフォームを入力してください.')).toBeInTheDocument ()
+16 -13
ファイルの表示
@@ -8,7 +8,7 @@ import App from '@/App'
import { helpFixture } from '@/test/helpFixtures' import { helpFixture } from '@/test/helpFixtures'
vi.hoisted (() => { 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) vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
@@ -21,7 +21,10 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({
describe ('Help global edition history', () => { 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 () => { it ('routes /help/changes to published editions in date and numeric order', async () => {
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } }) const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
@@ -31,12 +34,12 @@ describe ('Help global edition history', () => {
</QueryClientProvider>) </QueryClientProvider>)
const links = await screen.findAllByRole ('link') const links = await screen.findAllByRole ('link')
const editions = links.map (link => link.getAttribute ('href')) 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 ([ expect (editions).toEqual ([
`/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`, `/ja/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`,
'/help/guide?edition=2026-10-02.10', '/help/guide?edition=2026-10-02.3', '/ja/help/guide?edition=2026-10-02.10', '/ja/help/guide?edition=2026-10-02.3',
'/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02', '/ja/help/guide?edition=2026-10-02.2', '/ja/help/guide?edition=2026-10-02',
'/help/guide?edition=2026-10-01']) '/ja/help/guide?edition=2026-10-01'])
expect (container).toHaveTextContent ('旧ホーム') expect (container).toHaveTextContent ('旧ホーム')
expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent)) expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent))
.toEqual (['差分', 'タイトル', '有効開始年月日']) .toEqual (['差分', 'タイトル', '有効開始年月日'])
@@ -45,11 +48,11 @@ describe ('Help global edition history', () => {
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/) expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
expect (screen.getAllByRole ('link', { name: '差分' }).map (link => expect (screen.getAllByRole ('link', { name: '差分' }).map (link =>
link.getAttribute ('href'))).toEqual ([ link.getAttribute ('href'))).toEqual ([
'/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10', '/ja/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', '/ja/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', '/ja/help/guide/diff?from=2026-10-02&to=2026-10-02.2',
'/help/guide/diff?from=2026-10-01&to=2026-10-02']) '/ja/help/guide/diff?from=2026-10-01&to=2026-10-02'])
expect (apiGet).not.toHaveBeenCalled () expect (apiGet).toHaveBeenCalledWith ('/locales')
expect (window.location.pathname).toBe ('/help/changes') expect (window.location.pathname).toBe ('/ja/help/changes')
}) })
}) })
+13 -13
ファイルの表示
@@ -17,19 +17,19 @@ const Navigation = () => {
return ( return (
<> <>
<output data-testid="location">{location.pathname}{location.search}</output> <output data-testid="location">{location.pathname}{location.search}</output>
<button onClick={() => navigate ('/help/設定%20手順')}>switch article</button> <button onClick={() => navigate ('/ja/help/設定%20手順')}>switch article</button>
<button onClick={() => navigate ('/help/guide?edition=2026-10-02.3')}> <button onClick={() => navigate ('/ja/help/guide?edition=2026-10-02.3')}>
switch edition switch edition
</button> </button>
</>) </>)
} }
const renderDetail = (route = '/help/guide') => renderWithProviders ( const renderDetail = (route = '/ja/help/guide') => renderWithProviders (
<> <>
<Navigation/> <Navigation/>
<Routes> <Routes>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/> <Route path="/ja/help/:identifier" element={<HelpDetailPage/>}/>
</Routes> </Routes>
</>, { route }) </>, { 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']) ( 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 => { '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') await screen.findByRole ('heading')
expect (container).toHaveTextContent ('10月2日') expect (container).toHaveTextContent ('10月2日')
expect (container).not.toHaveTextContent ('Invalid Date') 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 () => { 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 (await screen.findByRole ('heading', { name: '旧ホーム' })).toBeInTheDocument ()
expect (screen.getByText ('過去限定本文')).toBeInTheDocument () expect (screen.getByText ('過去限定本文')).toBeInTheDocument ()
await waitFor (() => expect (document.title).toContain ('旧ホーム ヘルプ')) await waitFor (() => expect (document.title).toContain ('旧ホーム ヘルプ'))
expect (screen.getByText ('(最古)')).toBeInTheDocument () expect (screen.getByText ('(最古)')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '新 >' })) 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 () => { 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: 'ホーム第三版' }) await screen.findByRole ('heading', { name: 'ホーム第三版' })
expect (screen.getByRole ('link', { 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: '新 >' }) 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) fireEvent.click (next)
await screen.findByRole ('heading', { name: 'ホーム第十版' }) await screen.findByRole ('heading', { name: 'ホーム第十版' })
expect (screen.getByText ('(最新)')).toBeInTheDocument () expect (screen.getByText ('(最新)')).toBeInTheDocument ()
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument () expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
}) })
it.each (['/help/missing', '/help/guide?edition=missing', it.each (['/ja/help/missing', '/ja/help/guide?edition=missing',
'/help/guide?edition=2026-02-30', '/help/guide?edition=2026-10-02.2.extra', '/ja/help/guide?edition=2026-02-30', '/ja/help/guide?edition=2026-10-02.2.extra',
'/help/guide?edition=2026-10-02.11']) ( '/ja/help/guide?edition=2026-10-02.11']) (
'shows NotFound without redirecting %s', async route => { 'shows NotFound without redirecting %s', async route => {
renderDetail (route) renderDetail (route)
expect (await screen.findByText ('404')).toBeInTheDocument () expect (await screen.findByText ('404')).toBeInTheDocument ()
+6 -6
ファイルの表示
@@ -14,9 +14,9 @@ describe ('Help search', () => {
it ('initially lists each current article once with encoded detail links', async () => { it ('initially lists each current article once with encoded detail links', async () => {
renderWithProviders (<HelpListPage/>, { route: '/help' }) renderWithProviders (<HelpListPage/>, { route: '/help' })
expect (await screen.findByRole ('link', { name: 'ホーム第十版' })) expect (await screen.findByRole ('link', { name: 'ホーム第十版' }))
.toHaveAttribute ('href', '/help/guide') .toHaveAttribute ('href', '/ja/help/guide')
expect (screen.getByRole ('link', { name: '設定' })) expect (screen.getByRole ('link', { name: '設定' }))
.toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`) .toHaveAttribute ('href', `/ja/help/${encodeURIComponent ('設定 手順')}`)
expect (screen.getByText ('2 件')).toBeInTheDocument () expect (screen.getByText ('2 件')).toBeInTheDocument ()
expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument () expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument ()
expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument () expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument ()
@@ -82,7 +82,7 @@ describe ('Help search', () => {
renderWithProviders (<HelpListPage/>, { route: `/help?${ params }` }) renderWithProviders (<HelpListPage/>, { route: `/help?${ params }` })
await screen.findByText (`${ found ? 1 : 0 } 件`) await screen.findByText (`${ found ? 1 : 0 } 件`)
if (found) if (found)
expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/help/guide') expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/ja/help/guide')
else else
expect (screen.queryByRole ('link', { name: title })).not.toBeInTheDocument () expect (screen.queryByRole ('link', { name: title })).not.toBeInTheDocument ()
expect (entry).toEqual ({ identifier: 'guide', edition: '2026-10-02.10', expect (entry).toEqual ({ identifier: 'guide', edition: '2026-10-02.10',
@@ -99,7 +99,7 @@ describe ('Help search', () => {
route: `/help?order=title:${ direction }` }) route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件') await screen.findByText ('2 件')
const expected = direction === 'asc' 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 => expect (Array.from (container.querySelectorAll ('tbody a'), link =>
link.getAttribute ('href'))).toEqual (expected) link.getAttribute ('href'))).toEqual (expected)
}) })
@@ -113,7 +113,7 @@ describe ('Help search', () => {
route: `/help?order=title:${ direction }` }) route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件') await screen.findByText ('2 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link => 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', 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 }` }) route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件') await screen.findByText ('2 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link => 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'])
}) })
}) })
+7 -4
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import { buildMaterial, buildTag } from '@/test/factories' import { buildMaterial, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -59,7 +60,7 @@ describe ('MaterialDetailPage', () => {
renderPage () renderPage ()
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ('/materials/8') expect (api.apiGet).toHaveBeenCalledWith ('/materials/8', { locale: 'ja' })
}) })
expect (await screen.findByAltText ('素材タグ')).toHaveAttribute ('src', 'image.png') expect (await screen.findByAltText ('素材タグ')).toHaveAttribute ('src', 'image.png')
}) })
@@ -82,13 +83,15 @@ describe ('MaterialDetailPage', () => {
fireEvent.click (screen.getByRole ('button', { name: '更新' })) fireEvent.click (screen.getByRole ('button', { name: '更新' }))
await waitFor (() => { 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 const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('new') expect (formData.get ('tag')).toBe ('new')
expect (formData.get ('url')).toBe ('https://example.com/ref') expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (formData.get ('export_paths[legacy_drive]')).toBe ('素材/new.png') 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 () => { it ('shows not found when material API returns 404', async () => {
@@ -97,7 +100,7 @@ describe ('MaterialDetailPage', () => {
renderPage () renderPage ()
expect (await screen.findByText ('素材が見つかりませんでした.')).toBeInTheDocument () expect (await screen.findByText ('404')).toBeInTheDocument ()
}) })
it ('shows an error for non-404 material API failures', async () => { it ('shows an error for non-404 material API failures', async () => {
+10 -9
ファイルの表示
@@ -23,7 +23,7 @@ describe ('MaterialListPage', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/materials', '/materials',
{ params: { { locale: 'ja', params: {
tag_state: 'all', tag_state: 'all',
media_kind: 'all', media_kind: 'all',
sort: 'created_at', sort: 'created_at',
@@ -53,8 +53,9 @@ describe ('MaterialListPage', () => {
renderWithProviders (<MaterialListPage/>, { route: '/materials' }) renderWithProviders (<MaterialListPage/>, { route: '/materials' })
expect ((await screen.findByText ('素材タグ')).closest ('a')) expect ((await screen.findByText ('素材タグ')).closest ('a'))
.toHaveAttribute ('href', '/materials/8') .toHaveAttribute ('href', '/ja/materials/8')
expect (screen.getByText (/画像 \/ 令和8年1月2日/)).toBeInTheDocument () expect (screen.getByText ('令和8年1月2日(金) 12:04:05').parentElement)
.toHaveTextContent ('画像 / 令和8年1月2日')
}) })
it ('submits list filters as material index params', async () => { it ('submits list filters as material index params', async () => {
@@ -93,7 +94,7 @@ describe ('MaterialListPage', () => {
expect (await screen.findByRole ( expect (await screen.findByRole (
'link', 'link',
{ name: '素材検索トップへ戻る' }, { name: '素材検索トップへ戻る' },
)).toHaveAttribute ('href', '/materials?material_filter=any') )).toHaveAttribute ('href', '/ja/materials?material_filter=any')
}) })
it ('uses list view for detailed material rows', async () => { it ('uses list view for detailed material rows', async () => {
@@ -113,7 +114,7 @@ describe ('MaterialListPage', () => {
expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute ( expect (await screen.findByRole ('link', { name: '詳細素材' })).toHaveAttribute (
'href', 'href',
'/materials/9', '/ja/materials/9',
) )
expect (screen.getByText ('サイズ:')).toBeInTheDocument () expect (screen.getByText ('サイズ:')).toBeInTheDocument ()
expect (screen.getByText ('2.0 KB')).toBeInTheDocument () expect (screen.getByText ('2.0 KB')).toBeInTheDocument ()
@@ -153,11 +154,11 @@ describe ('MaterialListPage', () => {
expect (screen.getByRole ('link', { name: '泣き' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: '泣き' })).toHaveAttribute (
'href', '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 ( expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href', 'href',
'/materials?material_filter=present', '/ja/materials?material_filter=present',
) )
}) })
@@ -174,7 +175,7 @@ describe ('MaterialListPage', () => {
expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument () expect (await screen.findByText ('選択中タグが見つかりません.')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: 'タグ選択を解除' })).toHaveAttribute (
'href', 'href',
'/materials?material_filter=present', '/ja/materials?material_filter=present',
) )
}) })
}) })
+14 -11
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Route, Routes, useLocation } from 'react-router-dom' import { Route, Routes, useLocation } from 'react-router-dom'
import { textContentOf } from '@/test/text'
import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -33,7 +34,7 @@ describe ('MaterialNewPage', () => {
it ('initializes tag from query and submits form data', async () => { it ('initializes tag from query and submits form data', async () => {
api.apiPost.mockResolvedValueOnce ({}) api.apiPost.mockResolvedValueOnce ({})
renderWithProviders (<MaterialNewPage/>, { route: '/materials/new?tag=%E8%99%B9%E5%A4%8F' }) renderWithProviders (<MaterialNewPage/>, { route: '/ja/materials/new?tag=%E8%99%B9%E5%A4%8F' })
expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('虹夏') expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('虹夏')
fireEvent.change (screen.getAllByRole ('textbox')[1], { fireEvent.change (screen.getAllByRole ('textbox')[1], {
@@ -42,12 +43,14 @@ describe ('MaterialNewPage', () => {
fireEvent.click (screen.getByRole ('button', { name: '追加' })) fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await waitFor (() => { 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 const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (formData.get ('tag')).toBe ('虹夏') expect (formData.get ('tag')).toBe ('虹夏')
expect (formData.get ('url')).toBe ('https://example.com/ref') 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 () => { it ('shows validation errors for file and url fields', async () => {
@@ -81,12 +84,12 @@ describe ('MaterialNewPage', () => {
renderWithProviders ( renderWithProviders (
<Routes> <Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/> <Route path="/ja/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/> <Route path="/ja/materials" element={<LocationView/>}/>
</Routes>, </Routes>,
{ {
route: 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', + '&return_to=%2Fmaterials%3Ftag_id%3D20',
}, },
) )
@@ -96,7 +99,7 @@ describe ('MaterialNewPage', () => {
}) })
fireEvent.click (screen.getByRole ('button', { name: '追加' })) 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 () => { it ('ignores unsafe return_to values after successful creation', async () => {
@@ -104,12 +107,12 @@ describe ('MaterialNewPage', () => {
renderWithProviders ( renderWithProviders (
<Routes> <Routes>
<Route path="/materials/new" element={<MaterialNewPage/>}/> <Route path="/ja/materials/new" element={<MaterialNewPage/>}/>
<Route path="/materials" element={<LocationView/>}/> <Route path="/ja/materials" element={<LocationView/>}/>
</Routes>, </Routes>,
{ {
route: 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', + '&return_to=https%3A%2F%2Fexample.com%2Fevil',
}, },
) )
@@ -119,6 +122,6 @@ describe ('MaterialNewPage', () => {
}) })
fireEvent.click (screen.getByRole ('button', { name: '追加' })) fireEvent.click (screen.getByRole ('button', { name: '追加' }))
expect (await screen.findByText ('/materials')).toBeInTheDocument () expect (await screen.findByText ('/ja/materials')).toBeInTheDocument ()
}) })
}) })
+1 -1
ファイルの表示
@@ -63,7 +63,7 @@ describe ('PostDetailPage', () => {
renderPage () renderPage ()
await waitFor (() => { await waitFor (() => {
expect (postsApi.fetchPost).toHaveBeenCalledWith ('9') expect (postsApi.fetchPost).toHaveBeenCalledWith ('9', 'ja')
}) })
expect (await screen.findByText ('Embed:https://example.com/9')).toBeInTheDocument () expect (await screen.findByText ('Embed:https://example.com/9')).toBeInTheDocument ()
expect (screen.getByRole ('button', { name: '未閲覧' })).toBeInTheDocument () expect (screen.getByRole ('button', { name: '未閲覧' })).toBeInTheDocument ()
+2 -2
ファイルの表示
@@ -47,10 +47,10 @@ describe ('PostHistoryPage', () => {
externalTag: '7', externalTag: '7',
page: 1, page: 1,
limit: 20, limit: 20,
}) }, 'ja')
}) })
expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7') expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7', 'ja')
expect (tagsApi.fetchTag).not.toHaveBeenCalled () expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
expect ( expect (
+9 -4
ファイルの表示
@@ -130,7 +130,9 @@ describe ('PostImportReviewPage', () => {
renderReviewPage (['https://example.com/post']) 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 (await screen.findAllByText ('登録失敗')).toHaveLength (2)
expect (screen.getAllByText ('invalid title')).toHaveLength (2) expect (screen.getAllByText ('invalid title')).toHaveLength (2)
@@ -147,11 +149,14 @@ describe ('PostImportReviewPage', () => {
renderReviewPage (['https://example.com/post']) 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 (() => { 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' })
}) })
}) })
+4 -3
ファイルの表示
@@ -36,7 +36,8 @@ describe ('PostImportSourcePage', () => {
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } }) target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' })) 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 (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
expect (input).toHaveAttribute ('aria-invalid', 'true') expect (input).toHaveAttribute ('aria-invalid', 'true')
expect (router.navigate).not.toHaveBeenCalled () expect (router.navigate).not.toHaveBeenCalled ()
@@ -51,8 +52,8 @@ describe ('PostImportSourcePage', () => {
fireEvent.click (screen.getByRole ('button', { name: '次へ' })) fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (router.navigate).toHaveBeenCalledWith ( expect (router.navigate).toHaveBeenCalledWith (
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba' '/ja/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc') + '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc', undefined)
}) })
it ('disables Next when the encoded request target reaches 4096 bytes', () => { it ('disables Next when the encoded request target reaches 4096 bytes', () => {
+5 -5
ファイルの表示
@@ -43,12 +43,12 @@ describe ('PostListPage', () => {
page: 2, page: 2,
limit: 20, limit: 20,
order: 'original_created_at:desc', order: 'original_created_at:desc',
}), }), 'ja',
) )
}) })
expect (await screen.findByRole ('link', { name: '投稿5' })).toHaveAttribute ( expect (await screen.findByRole ('link', { name: '投稿5' })).toHaveAttribute (
'href', 'href',
'/posts/5', '/ja/posts/5',
) )
expect (screen.getByText ('広場')).toBeInTheDocument () expect (screen.getByText ('広場')).toBeInTheDocument ()
}) })
@@ -75,7 +75,7 @@ describe ('PostListPage', () => {
expect (await screen.findByText ('Wiki body')).toBeInTheDocument () expect (await screen.findByText ('Wiki body')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: 'Wiki を見る' })).toHaveAttribute ( expect (screen.getByRole ('link', { name: 'Wiki を見る' })).toHaveAttribute (
'href', 'href',
'/wiki/%E8%99%B9%E5%A4%8F', '/ja/wiki/%E8%99%B9%E5%A4%8F',
) )
}) })
@@ -92,7 +92,7 @@ describe ('PostListPage', () => {
expect.objectContaining ({ expect.objectContaining ({
limit: 50, limit: 50,
order: 'original_created_at:desc', order: 'original_created_at:desc',
}), }), 'ja',
) )
}) })
}) })
@@ -106,7 +106,7 @@ describe ('PostListPage', () => {
await waitFor (() => { await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }), expect.objectContaining ({ page: 1, limit: 100 }), 'ja',
) )
}) })
}) })
+5 -5
ファイルの表示
@@ -43,12 +43,12 @@ describe ('PostSearchPage', () => {
match: 'any', match: 'any',
page: 2, page: 2,
limit: 20, limit: 20,
}), }), 'ja',
) )
}) })
expect ((await screen.findAllByRole ('link', { name: '検索対象' }))[0]).toHaveAttribute ( expect ((await screen.findAllByRole ('link', { name: '検索対象' }))[0]).toHaveAttribute (
'href', 'href',
'/posts/4', '/ja/posts/4',
) )
}) })
@@ -72,7 +72,7 @@ describe ('PostSearchPage', () => {
tags: 'tag', tags: 'tag',
match: 'any', match: 'any',
page: 1, page: 1,
}), }), 'ja',
) )
}) })
}) })
@@ -98,7 +98,7 @@ describe ('PostSearchPage', () => {
await waitFor (() => { await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenCalledWith ( 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) ?? '{}')) expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
@@ -117,7 +117,7 @@ describe ('PostSearchPage', () => {
await waitFor (() => { await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }), expect.objectContaining ({ page: 1, limit: 100 }), 'ja',
) )
}) })
}) })
+13 -12
ファイルの表示
@@ -5,6 +5,7 @@ import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import { dateString } from '@/lib/utils' import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories' import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import { textContentOf } from '@/test/text'
import type { NicoTag } from '@/types' import type { NicoTag } from '@/types'
@@ -26,10 +27,10 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({ vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal< ...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (), typeof import('@/lib/dialogues/useDialogue')> (),
useDialogue: () => dialogue })) default: () => dialogue }))
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({ const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
...buildTag (), ...buildTag (),
@@ -74,7 +75,7 @@ describe ('NicoTagListPage', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/nico', '/tags/nico',
{ params: { { locale: 'ja', params: {
page: 2, page: 2,
limit: 20, limit: 20,
name: 'linked', name: 'linked',
@@ -106,7 +107,7 @@ describe ('NicoTagListPage', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenLastCalledWith ( expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/nico', '/tags/nico',
{ params: { { locale: 'ja', params: {
page: 1, page: 1,
limit: 20, limit: 20,
name: 'linked', name: 'linked',
@@ -151,7 +152,7 @@ describe ('NicoTagListPage', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenLastCalledWith ( expect (api.apiGet).toHaveBeenLastCalledWith (
'/tags/nico', '/tags/nico',
{ params: expect.objectContaining ({ { locale: 'ja', params: expect.objectContaining ({
page: 1, page: 1,
name: 'source', name: 'source',
linked_tag: 'destination', linked_tag: 'destination',
@@ -191,7 +192,7 @@ describe ('NicoTagListPage', () => {
expect (api.apiPut).toHaveBeenCalledWith ( expect (api.apiPut).toHaveBeenCalledWith (
'/tags/nico/7', '/tags/nico/7',
expect.any (FormData), expect.any (FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }, { locale: 'ja', headers: { 'Content-Type': 'multipart/form-data' } },
) )
}) })
expect (await screen.findByText ('1 件')).toBeInTheDocument () expect (await screen.findByText ('1 件')).toBeInTheDocument ()
@@ -217,11 +218,11 @@ describe ('NicoTagListPage', () => {
fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0]) fireEvent.click (screen.getAllByRole ('button', { name: '編集' })[0])
await waitFor (() => { await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalledWith ({ expect (dialogue.confirm).toHaveBeenCalledTimes (1)
title: '編集中の内容を破棄しますか?', const options = dialogue.confirm.mock.calls[0][0]
confirmText: '破棄', expect (textContentOf (options.title)).toBe ('編集中の内容を破棄しますか?')
variant: 'danger', expect (textContentOf (options.confirmText)).toBe ('破棄')
}) expect (options.variant).toBe ('danger')
}) })
expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1) expect (screen.getAllByLabelText ('連携する広場タグ')).toHaveLength (1)
expect (screen.getByLabelText ('連携する広場タグ')).toHaveValue ('入力中') expect (screen.getByLabelText ('連携する広場タグ')).toHaveValue ('入力中')
+5 -3
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import TagDetailPage from '@/pages/tags/TagDetailPage' import TagDetailPage from '@/pages/tags/TagDetailPage'
import { buildTag } from '@/test/factories' import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -60,11 +61,12 @@ describe ('TagDetailPage', () => {
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!) fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
await waitFor (() => { 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 const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('name')).toBe ('new') 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 () => { it ('keeps nico tags disabled', async () => {
@@ -77,7 +79,7 @@ describe ('TagDetailPage', () => {
it ('shows validation errors returned for tag fields', async () => { it ('shows validation errors returned for tag fields', async () => {
tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' })) tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
api.isApiError.mockReturnValue (true) api.isApiError.mockImplementation (error => error?.response != null)
api.apiPut.mockRejectedValueOnce ({ api.apiPut.mockRejectedValueOnce ({
response: { response: {
status: 422, status: 422,
+9 -9
ファイルの表示
@@ -48,12 +48,12 @@ describe ('TagListPage', () => {
order: 'post_count:desc', order: 'post_count:desc',
postCountGTE: 5, postCountGTE: 5,
deprecated: true, deprecated: true,
}), }), 'ja',
) )
}) })
expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute ( expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute (
'href', 'href',
'/tags/7', '/ja/tags/7',
) )
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0) expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1') expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1')
@@ -77,7 +77,7 @@ describe ('TagListPage', () => {
name: '虹夏', name: '虹夏',
category: 'character', category: 'character',
page: 1, page: 1,
}), }), 'ja',
) )
}) })
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏') expect (screen.getByRole ('textbox')).toHaveValue ('虹夏')
@@ -104,7 +104,7 @@ describe ('TagListPage', () => {
await waitFor (() => { await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenCalledWith ( 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) ?? '{}')) expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
@@ -123,7 +123,7 @@ describe ('TagListPage', () => {
await waitFor (() => { await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenLastCalledWith ( 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 = const externalLink =
screen.getByRole ('link', { name: 'nico:external_collision' }) screen.getByRole ('link', { name: 'nico:external_collision' })
expect (internalLink).toHaveAttribute ('href', '/tags/7') expect (internalLink).toHaveAttribute ('href', '/ja/tags/7')
expect (externalLink).toHaveAttribute ( expect (externalLink).toHaveAttribute (
'href', 'href',
'/tags/nico?name=nico%3Aexternal_collision', '/ja/tags/nico?name=nico%3Aexternal_collision',
) )
const internalRow = internalLink.closest ('tr') const internalRow = internalLink.closest ('tr')
@@ -166,10 +166,10 @@ describe ('TagListPage', () => {
expect ( expect (
within (internalRow!).getByRole ('link', { name: '耕作履歴' }), within (internalRow!).getByRole ('link', { name: '耕作履歴' }),
).toHaveAttribute ('href', '/posts/changes?tag=7') ).toHaveAttribute ('href', '/ja/posts/changes?tag=7')
expect ( expect (
within (externalRow!).getByRole ('link', { name: '耕作履歴' }), within (externalRow!).getByRole ('link', { name: '耕作履歴' }),
).toHaveAttribute ('href', '/posts/changes?external_tag=7') ).toHaveAttribute ('href', '/ja/posts/changes?external_tag=7')
}) })
}) })
+41 -25
ファイルの表示
@@ -1,8 +1,10 @@
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { theatresKeys } from '@/lib/queryKeys'
import { buildPost, import { buildPost,
buildTag, buildTag,
buildTheatre, buildTheatre,
@@ -40,10 +42,10 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({ vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal< ...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (), typeof import('@/lib/dialogues/useDialogue')> (),
useDialogue: () => dialogue })) default: () => dialogue }))
vi.mock ('@/components/PostEmbed', () => ({ vi.mock ('@/components/PostEmbed', () => ({
default: (props: { default: (props: {
ref?: { current: unknown } ref?: { current: unknown }
@@ -94,19 +96,28 @@ const watchingUsers = [
{ id: 2, name: 'another' }, { id: 2, name: 'another' },
] ]
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) => const renderPage = (
renderWithProviders ( 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 (
<Routes> <Routes>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
</Routes>, </Routes>,
{ route: '/theatres/7' }, { route: '/theatres/7', queryClient })
) }
const tagSection = (): HTMLElement => const tagSection = (): HTMLElement =>
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')! screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
const mockDefaultApi = () => { const mockDefaultApi = () => {
let commentsFetched = false
let currentTheatreInfo = buildTheatreInfo ({ let currentTheatreInfo = buildTheatreInfo ({
postId: currentPost.id, postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z', 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) switch (path)
{ {
case '/theatres/7': case '/theatres/7':
return Promise.resolve (theatre) return Promise.resolve (theatre)
case '/theatres/7/comments': case '/theatres/7/comments':
if (commentsFetched) if ((options?.params?.no_gt ?? 0) >= 2)
return Promise.resolve ([]) return Promise.resolve ([])
commentsFetched = true
return Promise.resolve ([ return Promise.resolve ([
buildTheatreComment ({ buildTheatreComment ({
theatreId: 7, theatreId: 7,
@@ -192,30 +202,32 @@ describe ('TheatreDetailPage', () => {
}) })
it ('loads theatre state, comments, current post, programme history, and weights', async () => { 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 ('上映会場『上映室』')).toBeInTheDocument ()
expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument () expect (await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })).toBeInTheDocument ()
expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument () expect ((await screen.findAllByText ('視聴コメント', {}, { timeout: 4_000 }))[0])
.toBeInTheDocument ()
expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument () expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument ()
expect (screen.getByText ('penalty 2')).toBeInTheDocument () expect (screen.getByText ('penalty 2')).toBeInTheDocument ()
await waitFor (() => { await waitFor (() => {
expect (postsApi.fetchPost).toHaveBeenCalledWith ('10') expect (postsApi.fetchPost).toHaveBeenCalledWith ('10', 'ja')
}) })
}) })
it ('votes to skip the current post', async () => { it ('votes to skip the current post', async () => {
renderPage () 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 (() => { await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ( expect (api.apiPut).toHaveBeenCalledWith (
'/theatres/7/skip_vote', '/theatres/7/skip_vote',
{ post_id: 10 }, { post_id: 10 }, { locale: 'ja' },
) )
}) })
expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' })) expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' }))
@@ -247,7 +259,7 @@ describe ('TheatreDetailPage', () => {
}) })
renderPage () renderPage ()
await screen.findByText ('Embed:上映中の投稿') await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })
const props = postEmbed.props.mock.calls.at (-1)![0] const props = postEmbed.props.mock.calls.at (-1)![0]
@@ -256,6 +268,7 @@ describe ('TheatreDetailPage', () => {
props.onPlaybackChange (0) props.onPlaybackChange (0)
}) })
await waitFor (() => expect (postEmbed.seek).toHaveBeenCalled ())
const seekMs = postEmbed.seek.mock.calls[0][0] const seekMs = postEmbed.seek.mock.calls[0][0]
expect (seekMs).toBeGreaterThanOrEqual (7_000) expect (seekMs).toBeGreaterThanOrEqual (7_000)
expect (seekMs).toBeLessThan (10_000) expect (seekMs).toBeLessThan (10_000)
@@ -279,14 +292,14 @@ describe ('TheatreDetailPage', () => {
children: [childTag], children: [childTag],
}) })
postsApi.fetchPost.mockResolvedValueOnce (buildPost ({ postsApi.fetchPost.mockResolvedValue (buildPost ({
...currentPost, ...currentPost,
tags: [parentTag, duplicateParentTag], tags: [parentTag, duplicateParentTag],
})) }))
renderPage () renderPage ()
await screen.findByText ('Embed:上映中の投稿') await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' })) expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument () .toBeInTheDocument ()
@@ -337,9 +350,9 @@ describe ('TheatreDetailPage', () => {
renderPage () renderPage ()
await screen.findByText ('Embed:上映中の投稿') await screen.findByText ('Embed:上映中の投稿', {}, { timeout: 4_000 })
await waitFor (() => { await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching') expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching', {}, { locale: 'ja' })
}) })
await waitFor (() => { await waitFor (() => {
@@ -351,13 +364,16 @@ describe ('TheatreDetailPage', () => {
it ('deletes an owned comment after confirmation', async () => { it ('deletes an owned comment after confirmation', async () => {
renderPage () 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 (() => { await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalled () expect (dialogue.confirm).toHaveBeenCalled ()
}) })
await waitFor (() => { 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) expect (await screen.findAllByText ('削除されました.')).toHaveLength (2)
}) })
+3 -1
ファイルの表示
@@ -1,6 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import SettingPage from '@/pages/users/SettingPage' import SettingPage from '@/pages/users/SettingPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -68,7 +69,8 @@ describe ('SettingPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('name')).toBe ('new') expect (formData.get ('name')).toBe ('new')
expect (setUser).toHaveBeenCalled () 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 () => { it ('shows validation errors returned for the name field', async () => {
+22 -17
ファイルの表示
@@ -24,7 +24,13 @@ const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (), 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/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () })) vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () }))
@@ -63,15 +69,15 @@ describe ('WikiDetailPage', () => {
vi.mocked (getHelpTitleIndex).mockReturnValueOnce (pending) vi.mocked (getHelpTitleIndex).mockReturnValueOnce (pending)
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ', hasWiki: false })) tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ', hasWiki: false }))
renderWithProviders ( renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>, <Routes><Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` }) { route: `/ja/wiki/${ encodeURIComponent ('既存タグ') }` })
await waitFor (() => expect (getHelpTitleIndex).toHaveBeenCalled ()) await waitFor (() => expect (getHelpTitleIndex).toHaveBeenCalled ())
expect (screen.queryByText ('404')).not.toBeInTheDocument () expect (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' })) expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument () .not.toBeInTheDocument ()
await act (async () => finish ({ })) await act (async () => finish ({ }))
expect (await screen.findByRole ('link', { name: '新規作成してください' })) 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 () => { it ('creates a tag description through the existing form and displays its content', async () => {
@@ -79,13 +85,13 @@ describe ('WikiDetailPage', () => {
resourceType: 'tag_description' }) resourceType: 'tag_description' })
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false })) tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false }))
vi.mocked (apiPost).mockResolvedValueOnce (page) vi.mocked (apiPost).mockResolvedValueOnce (page)
const path = `/wiki/${ encodeURIComponent (page.title) }` const path = `/ja/wiki/${ encodeURIComponent (page.title) }`
const { container, queryClient } = renderWithProviders ( const { container, queryClient } = renderWithProviders (
<> <>
<Location/> <Location/>
<Routes> <Routes>
<Route path="/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/> <Route path="/ja/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> <Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/>
</Routes> </Routes>
</>, { route: path }) </>, { route: path })
fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' })) fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' }))
@@ -108,11 +114,10 @@ describe ('WikiDetailPage', () => {
expect (form.get ('title')).toBe (page.title) expect (form.get ('title')).toBe (page.title)
expect (form.get ('body')).toBe (page.body) expect (form.get ('body')).toBe (page.body)
expect (apiPost).toHaveBeenCalledWith ('/wiki', form, 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 (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toEqual (page) expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toEqual (page)
await waitFor (() => expect (queryClient.getQueryData (wikiKeys.index ({ }))) expect (queryClient.getQueryState (wikiKeys.index ({ }))?.isInvalidated).toBe (true)
.toEqual ([page]))
await waitFor (() => expect (queryClient.getQueryData (tagsKeys.show (page.title))) await waitFor (() => expect (queryClient.getQueryData (tagsKeys.show (page.title)))
.toMatchObject ({ hasWiki: true })) .toMatchObject ({ hasWiki: true }))
expect (getHelpTitleIndex).toHaveBeenCalledTimes (1) 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 () => { it ('keeps failed saves on the form without changing cached Wiki or tag state', async () => {
const title = '既存タグ' const title = '既存タグ'
const tag = buildTag ({ name: title, hasWiki: false }) 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')) vi.mocked (apiPost).mockRejectedValueOnce (new Error ('save failed'))
const { queryClient } = renderWithProviders ( const { queryClient } = renderWithProviders (
<><Location/><WikiNewPage user={buildUser ({ role: 'member' })}/></>, { route: path }) <><Location/><WikiNewPage user={buildUser ({ role: 'member' })}/></>, { route: path })
@@ -141,8 +146,8 @@ describe ('WikiDetailPage', () => {
it ('shows NotFound when neither a tag nor a Help title exists', async () => { it ('shows NotFound when neither a tag nor a Help title exists', async () => {
renderWithProviders ( renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>, <Routes><Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: '/wiki/missing' }) { route: '/ja/wiki/missing' })
expect (await screen.findByText ('404')).toBeInTheDocument () expect (await screen.findByText ('404')).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' })) expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument () .not.toBeInTheDocument ()
@@ -153,8 +158,8 @@ describe ('WikiDetailPage', () => {
wikiApi.fetchWikiPageByTitle.mockRejectedValue (new Error (message)) wikiApi.fetchWikiPageByTitle.mockRejectedValue (new Error (message))
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ' })) tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ' }))
renderWithProviders ( renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>, <Routes><Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` }) { route: `/ja/wiki/${ encodeURIComponent ('既存タグ') }` })
expect (await screen.findByRole ('alert')).toHaveTextContent (message) expect (await screen.findByRole ('alert')).toHaveTextContent (message)
expect (getHelpTitleIndex).not.toHaveBeenCalled () expect (getHelpTitleIndex).not.toHaveBeenCalled ()
expect (screen.queryByRole ('link', { name: '新規作成してください' })) expect (screen.queryByRole ('link', { name: '新規作成してください' }))
@@ -174,9 +179,9 @@ describe ('WikiDetailPage', () => {
renderWithProviders ( renderWithProviders (
<Routes> <Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> <Route path="/ja/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>, </Routes>,
{ 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 ('(廃止)') const marker = await screen.findByText ('(廃止)')
+2 -2
ファイルの表示
@@ -36,10 +36,10 @@ describe ('WikiDiffPage', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/wiki/3/diff', '/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 ('same')).toBeInTheDocument ()
expect (screen.getByText ('added line')).toBeInTheDocument () expect (screen.getByText ('added line')).toBeInTheDocument ()
expect (screen.getByText ('removed line')).toBeInTheDocument () expect (screen.getByText ('removed line')).toBeInTheDocument ()
+6 -4
ファイルの表示
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import WikiEditPage from '@/pages/wiki/WikiEditPage' import WikiEditPage from '@/pages/wiki/WikiEditPage'
import { buildUser, buildWikiPage } from '@/test/factories' import { buildUser, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -50,9 +51,9 @@ describe ('WikiEditPage', () => {
renderPage () renderPage ()
await waitFor (() => { 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 ('本文') expect (screen.getByLabelText ('本文エディタ')).toHaveValue ('本文')
}) })
@@ -71,12 +72,13 @@ describe ('WikiEditPage', () => {
expect (api.apiPut).toHaveBeenCalledWith ( expect (api.apiPut).toHaveBeenCalledWith (
'/wiki/9', '/wiki/9',
expect.any (FormData), 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 const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('title')).toBe ('更新済み') expect (formData.get ('title')).toBe ('更新済み')
expect (formData.get ('body')).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 ('投稿成功!')
}) })
}) })
+1 -1
ファイルの表示
@@ -36,7 +36,7 @@ const renderWiki = (title: string) => {
<Location/> <Location/>
<Routes> <Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> <Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/> <Route path="/ja/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/404" element={<div>unexpected redirect</div>}/> <Route path="/404" element={<div>unexpected redirect</div>}/>
</Routes> </Routes>
</>, { route }) </>, { route })
+1 -1
ファイルの表示
@@ -32,7 +32,7 @@ describe ('WikiHistoryPage', () => {
const link = await screen.findByRole ('link', { name: '旧タグ' }) const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)') 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 () expect (marker.closest ('a')).toBeNull ()
}) })
}) })
+4 -2
ファイルの表示
@@ -1,6 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiNewPage from '@/pages/wiki/WikiNewPage'
import { buildUser, buildWikiPage } from '@/test/factories' import { buildUser, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
@@ -50,12 +51,13 @@ describe ('WikiNewPage', () => {
expect (api.apiPost).toHaveBeenCalledWith ( expect (api.apiPost).toHaveBeenCalledWith (
'/wiki', '/wiki',
expect.any (FormData), 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 const formData = api.apiPost.mock.calls[0]?.[1] as FormData
expect (formData.get ('title')).toBe ('下書き') expect (formData.get ('title')).toBe ('下書き')
expect (formData.get ('body')).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 ('投稿成功!')
}) })
}) })
+4 -4
ファイルの表示
@@ -16,8 +16,8 @@ describe ('Wiki resource identity', () => {
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })]) vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
renderWithProviders (<WikiSearchPage/>) renderWithProviders (<WikiSearchPage/>)
expect (await screen.findByRole ('link', { name: '虹夏' })) expect (await screen.findByRole ('link', { name: '虹夏' }))
.toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F') .toHaveAttribute ('href', '/ja/wiki/%E8%99%B9%E5%A4%8F')
expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } }) expect (apiGet).toHaveBeenCalledWith ('/wiki', { locale: 'ja', params: { title: '' } })
}) })
it ('searches by title on submit', async () => { it ('searches by title on submit', async () => {
@@ -28,7 +28,7 @@ describe ('Wiki resource identity', () => {
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } }) fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } })
fireEvent.click (screen.getByRole ('button', { name: '検索' })) fireEvent.click (screen.getByRole ('button', { name: '検索' }))
await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith ( await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith (
'/wiki', { params: { title: '検索' } })) '/wiki', { locale: 'ja', params: { title: '検索' } }))
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument () expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
}) })
@@ -49,7 +49,7 @@ describe ('Wiki resource identity', () => {
await screen.findByRole ('link', { name: '説明' }) await screen.findByRole ('link', { name: '説明' })
expect (screen.getByRole ('link', { name: 'タググループ:一覧' })) expect (screen.getByRole ('link', { name: 'タググループ:一覧' }))
.toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`) .toHaveAttribute ('href', `/ja/wiki/${ encodeURIComponent (group.title) }`)
await waitFor (() => { await waitFor (() => {
expect (errors.mock.calls.some (args => args.some (value => expect (errors.mock.calls.some (args => args.some (value =>
String (value).includes ('same key')))).toBe (false) String (value).includes ('same key')))).toBe (false)
+1 -1
ファイルの表示
@@ -14,7 +14,7 @@ export const transformMdx = async (source: string, suffix = '.mdx') => {
const plugin = plugins.find (entry => entry.name === '@mdx-js/rollup') const plugin = plugins.find (entry => entry.name === '@mdx-js/rollup')
if (plugin == null) if (plugin == null)
throw new Error ('The application MDX compiler is not registered') 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
} }
+4 -6
ファイルの表示
@@ -11,8 +11,8 @@ import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import type { ReactElement, ReactNode } from 'react' import type { ReactElement, ReactNode } from 'react'
type Options = { type Options = {
route?: string route?: string
} queryClient?: QueryClient }
const TestContentContext = createContext<ReactNode> (null) const TestContentContext = createContext<ReactNode> (null)
@@ -34,10 +34,8 @@ export const renderWithProviders = (
ui: ReactElement, ui: ReactElement,
options: Options = {}, options: Options = {},
) => { ) => {
const queryClient = new QueryClient ({ const queryClient = options.queryClient ?? new QueryClient ({
defaultOptions: { defaultOptions: { queries: { retry: false } } })
queries: { retry: false } },
})
const Wrapper = ({ children }: { children: ReactNode }) => { const Wrapper = ({ children }: { children: ReactNode }) => {
const [router] = useState (() => createMemoryRouter ([{ const [router] = useState (() => createMemoryRouter ([{
+10
ファイルの表示
@@ -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 ?? ''
}
+1
ファイルの表示
@@ -7,5 +7,6 @@ export default mergeConfig (
viteConfig, viteConfig,
defineConfig ({ defineConfig ({
test: { environment: 'jsdom', test: { environment: 'jsdom',
exclude: ['**/node_modules/**', '**/dist/**', 'server.ssr.test.mjs'],
setupFiles: './src/test/setup.ts', setupFiles: './src/test/setup.ts',
globals: false } })) globals: false } }))