diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index b362e23..d4ac59b 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -24,6 +24,18 @@ FactoryBot.define do after(:create) do |tag, evaluator| evaluator.primary_tag_name.update!(tag:, primary_flg: true) + + Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a + .uniq(&:language_code).each do |locale| + next if tag.tag_names.exists?(language_code: locale.language_code, + primary_flg: true) + + create(:tag_name, + tag:, language_code: locale.language_code, + script_code: locale.script_code, + name: TagName.generate_name(locale, tag, evaluator.primary_tag_name), + primary_flg: true, auto_generated: true) + end end end end diff --git a/backend/spec/models/language_spec.rb b/backend/spec/models/language_spec.rb new file mode 100644 index 0000000..7f76a80 --- /dev/null +++ b/backend/spec/models/language_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe Language, type: :model do + it 'accepts supported language codes' do + expect(described_class.new(code: 'en', name: 'English')).to be_valid + end + + it 'rejects an unsupported language code' do + expect { + described_class.create!(code: 'xx', name: 'Unsupported') + }.to raise_error { |error| + expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) + } + end + + it 'does not treat French as a supported language' do + expect { + described_class.create!(code: 'fr', name: 'French') + }.to raise_error { |error| + expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) + } + end +end diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb index 852f66f..a4d2794 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -1,15 +1,15 @@ require 'rails_helper' RSpec.describe Locale, type: :model do - def prepare_french_reference! - Language.find_or_create_by!(code: 'fr') { _1.name = 'French' } + def prepare_english_reference! + Language.find_or_create_by!(code: 'en') { _1.name = 'English' } Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } end - def create_french_locale! - prepare_french_reference! - described_class.create!(code: 'fr', language_code: 'fr', - script_code: 'Latn', name: 'French') + def create_english_locale! + prepare_english_reference! + described_class.create!(code: 'en', language_code: 'en', + script_code: 'Latn', name: 'English') end it 'generates a primary name for every existing tag using the name generator' do @@ -20,55 +20,80 @@ RSpec.describe Locale, type: :model do .with(kind_of(described_class), first, primary_tag_name_for(first, 'ja')) .and_return('name_from_generator') - locale = create_french_locale! + locale = create_english_locale! expect(TagName).to have_received(:generate_name) .with(locale, first, primary_tag_name_for(first, 'ja')) expect(TagName).to have_received(:generate_name) .with(locale, second, primary_tag_name_for(second, 'ja')) - expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true)) + expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true)) .to have_attributes(name: 'name_from_generator', tag_id: first.id, - language_code: 'fr', script_code: 'Latn', + language_code: 'en', script_code: 'Latn', primary_flg: true, auto_generated: true) - expect(second.tag_names.find_by!(language_code: 'fr', primary_flg: true)) - .to have_attributes(tag_id: second.id, language_code: 'fr', + expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true)) + .to have_attributes(tag_id: second.id, language_code: 'en', script_code: 'Latn', primary_flg: true, auto_generated: true) - expect(TagName.where(language_code: 'fr', primary_flg: true, + expect(TagName.where(language_code: 'en', primary_flg: true, tag_id: [first.id, second.id]).count).to eq(2) end it 'does not duplicate an existing primary name in the new language' do - prepare_french_reference! + prepare_english_reference! tag = create(:tag, primary_name: 'already_named') - existing = create(:tag_name, name: 'nom_existant', tag:, - language_code: 'fr', script_code: 'Latn') + existing = create(:tag_name, name: 'existing_english', tag:, + language_code: 'en', script_code: 'Latn') - create_french_locale! + create_english_locale! - expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true)) + expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true)) .to contain_exactly(existing) expect(existing.reload).to have_attributes( - tag_id: tag.id, language_code: 'fr', script_code: 'Latn', + tag_id: tag.id, language_code: 'en', script_code: 'Latn', primary_flg: true, auto_generated: false) end it 'creates a primary name when the language has only an alias' do - prepare_french_reference! + prepare_english_reference! tag = create(:tag, primary_name: 'alias_only_tag') - alias_name = create(:tag_name, :alias, name: 'alias_fr', + alias_name = create(:tag_name, :alias, name: 'alias_en', tag:, - language_code: 'fr', script_code: 'Latn') + language_code: 'en', script_code: 'Latn') - create_french_locale! + create_english_locale! expect(alias_name.reload).to have_attributes( tag_id: tag.id, - language_code: 'fr', script_code: 'Latn', primary_flg: false) - generated = TagName.find_by!(tag_id: tag.id, language_code: 'fr', primary_flg: true) - expect(generated).to have_attributes(tag_id: tag.id, language_code: 'fr', + language_code: 'en', script_code: 'Latn', primary_flg: false) + generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true) + expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en', script_code: 'Latn', primary_flg: true, auto_generated: true) - expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true).count).to eq(1) + expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1) + end + + it 'rolls back the locale and all generated names when backfill fails' do + prepare_english_reference! + first = create(:tag, primary_name: 'atomic_first') + second = create(:tag, primary_name: 'atomic_second') + allow(TagName).to receive(:generate_name).and_call_original + allow(TagName).to receive(:generate_name) + .with(kind_of(described_class), second, primary_tag_name_for(second, 'ja')) + .and_raise('backfill failed') + + expect { create_english_locale! }.to raise_error('backfill failed') + + expect(described_class.exists?(code: 'en')).to be(false) + expect(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty + end + + it 'accepts a stable locale code outside the historical enum values' do + prepare_english_reference! + + locale = described_class.create!( + code: 'en-x-rspec', language_code: 'en', + script_code: 'Latn', name: 'English') + + expect(locale.reload).to have_attributes(code: 'en-x-rspec', language_code: 'en') end end diff --git a/backend/spec/models/script_spec.rb b/backend/spec/models/script_spec.rb new file mode 100644 index 0000000..ccd91ff --- /dev/null +++ b/backend/spec/models/script_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe Script, type: :model do + include_context 'English locale' + + it 'selects locales by script code, not language code' do + expect(described_class.find('Latn').locales).to include(locale) + expect(described_class.find('Jpan').locales).to include(Locale.nipponese) + expect(described_class.find('Jpan').locales).not_to include(locale) + end + + it 'accepts supported script codes and rejects unsupported ones' do + expect(described_class.new(code: 'Latn', name: 'Latin')).to be_valid + expect { + described_class.create!(code: 'Zzzz', name: 'Unsupported') + }.to raise_error { |error| + expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) + } + end +end diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index 959778a..8d01412 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -39,6 +39,18 @@ RSpec.describe TagName, type: :model do expect(tag.reload.tag_name_id).to eq(name.id) end + it 'creates a complete ordinary tag factory under every active language' do + tag = create(:tag, primary_name: 'factory_multilingual') + + Locale.where(deprecated_at: nil).distinct.pluck(:language_code).each do |language_code| + expect(TagName.where(tag:, language_code:, primary_flg: true).count).to eq(1) + end + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) + .to contain_exactly(have_attributes(auto_generated: false)) + expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) + .to contain_exactly(have_attributes(auto_generated: true)) + end + it 'builds a primary name without persisting the tag or its name' do tag = nil @@ -74,10 +86,18 @@ RSpec.describe TagName, type: :model do .to eq(['detached_alias']) end + it 'does not resolve a detached alias through another detached primary' do + create(:tag_name, :alias, name: 'detached_alias') + create(:tag_name, name: 'unrelated_detached_primary') + + expect(described_class.canonicalise(Locale.nipponese, ['detached_alias'])) + .to eq(['detached_alias']) + end + it 'resolves the primary name of the alias language on the same tag' do tag = create(:tag, primary_name: '日本語正本') - english_primary = create(:tag_name, tag:, name: 'english_primary', - language_code: 'en', script_code: 'Latn') + english_primary = primary_tag_name_for(tag, 'en') + english_primary.update!(name: 'english_primary', auto_generated: false) english_alias = create(:tag_name, :alias, tag:, name: 'english_alias', language_code: 'en', script_code: 'Latn') representative_id = tag.tag_name_id @@ -115,4 +135,16 @@ RSpec.describe TagName, type: :model do .to eq(['nipponese_canonical']) end end + + it 'rejects the generated-name prefix in human input' do + human_name = build(:tag_name, name: 'Tag_#999', auto_generated: false) + + expect(human_name).to be_invalid + end + + it 'allows the generated-name prefix for system-created names' do + generated_name = build(:tag_name, name: 'Tag_#1000', auto_generated: true) + + expect { generated_name.save! }.to change(described_class, :count).by(1) + end end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index f9d1934..7493d2a 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -32,7 +32,8 @@ RSpec.describe Tag, type: :model do english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') english = create(:tag, primary_tag_name: english_name) - create(:tag_name, tag: english, name: 'english_in_nipponese') + primary_tag_name_for(english, 'ja').update!( + name: 'english_in_nipponese', auto_generated: false) english_locale = Locale.create!( code: 'en', language_code: 'en', script_code: 'Latn', name: 'English') nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', @@ -252,6 +253,63 @@ RSpec.describe Tag, type: :model do expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1) end + it 'creates one primary per language when multiple locales use English' do + described_class.find_or_create_by_tag_name!( + locale, 'before_second_english_locale', category: :general) + Locale.create!(code: 'en-x-rspec', language_code: 'en', + script_code: 'Latn', name: 'English') + + tag = described_class.find_or_create_by_tag_name!( + locale, 'one_english_primary', category: :general) + + expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1) + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) + end + + it 'rolls back a new tag and requested name if name generation fails' do + original_tag_count = described_class.count + allow(TagName).to receive(:generate_name).and_raise('generation failed') + + expect { + described_class.find_or_create_by_tag_name!( + locale, 'atomic_english_name', category: :general) + }.to raise_error('generation failed') + + expect(described_class.count).to eq(original_tag_count) + expect(TagName.where(language_code: 'en', name: 'atomic_english_name')).to be_empty + expect(described_class.joins(:tag_names) + .where(tag_names: { name: 'atomic_english_name' })).to be_empty + end + + it 'keeps a detached requested name detached if generation fails' do + detached = create(:tag_name, :alias, name: 'atomic_detached', + language_code: 'en', script_code: 'Latn') + original_tag_count = described_class.count + allow(TagName).to receive(:generate_name).and_raise('generation failed') + + expect { + described_class.find_or_create_by_tag_name!( + locale, detached.name, category: :general) + }.to raise_error('generation failed') + + expect(described_class.count).to eq(original_tag_count) + expect(detached.reload).to have_attributes(tag_id: nil, primary_flg: false) + end + + it 'promotes a detached alias into the requested primary name' do + detached = create(:tag_name, :alias, name: 'detached_english', + language_code: 'en', script_code: 'Latn') + + tag = described_class.find_or_create_by_tag_name!( + locale, detached.name, category: :general) + + expect(detached.reload).to have_attributes( + tag_id: tag.id, primary_flg: true, canonical_id: nil, + auto_generated: false) + expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) + .to contain_exactly(detached) + end + it 'creates separate tag identities for the same name in different languages' do nipponese = described_class.find_or_create_by_tag_name!( Locale.nipponese, 'same_name', category: :general) @@ -272,8 +330,8 @@ RSpec.describe Tag, type: :model do it 'preserves the V1 representative when looking up another primary language' do tag = create(:tag, primary_name: '日本語代表名') representative_id = tag.tag_name_id - english = create(:tag_name, name: 'english_primary', tag:, - language_code: 'en', script_code: 'Latn') + english = primary_tag_name_for(tag, 'en') + english.update!(name: 'english_primary', auto_generated: false) found = described_class.find_or_create_by_tag_name!( locale, english.name, category: :general) diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index 83a3a3b..b5eb012 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -182,6 +182,51 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end let!(:tag) { create(:tag, primary_tag_name: english_name) } + it 'marks a human-renamed generated primary as no longer auto-generated' do + generated_tag = create(:tag, primary_name: 'rename_generated_source') + generated_name = primary_tag_name_for(generated_tag, 'en') + expect(generated_name.auto_generated).to be(true) + + patch "/tags/#{ generated_tag.id }", params: { + locale: 'en', name: 'human_english_name' } + + expect(response).to have_http_status(:ok), response.body + expect(generated_name.reload).to have_attributes( + name: 'human_english_name', auto_generated: false) + end + + it 'does not steal another tag primary for an alias' do + other_tag = create(:tag, primary_name: 'other_nipponese') + other_primary = primary_tag_name_for(other_tag, 'en') + other_primary.update!(name: 'other_english_primary', auto_generated: false) + create(:tag_name, :alias, tag:, name: 'existing_english_alias', + language_code: 'en', script_code: 'Latn') + primary_name_before = primary_tag_name_for(tag, 'en').name + aliases_before = + tag.tag_names.where(language_code: 'en', primary_flg: false) + .order(:name).pluck(:name) + category_before = tag.category + + put "/tags/#{ tag.id }", params: { + locale: 'en', name: 'renamed_before_alias_collision', + category: 'character', deprecated: false, + aliases: 'other_english_primary', parent_tags: '' } + + aggregate_failures do + expect(response).to have_http_status(:unprocessable_entity) + expect(other_primary.reload).to have_attributes( + tag_id: other_tag.id, language_code: 'en', primary_flg: true) + expect(TagName.where(tag: other_tag, language_code: 'en', primary_flg: true)) + .to contain_exactly(other_primary) + expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before) + expect(tag.category).to eq(category_before) + aliases_after = + tag.tag_names.where(language_code: 'en', primary_flg: false) + .order(:name).pluck(:name) + expect(aliases_after).to eq(aliases_before) + end + end + it 'allows a rename to a name already used in Nipponese' do nipponese = create(:tag, primary_name: 'rename_target')