From 1e398bfb97c0b8681460b952adbeb90c39e61970 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 30 Sep 2026 00:30:05 +0900 Subject: [PATCH] #55 --- backend/app/models/language.rb | 2 + backend/app/models/locale.rb | 23 +- backend/app/models/script.rb | 4 +- backend/app/models/tag.rb | 485 +++++++++--------- backend/app/models/tag_name.rb | 2 +- backend/spec/models/locale_spec.rb | 17 +- .../models/tag_name_sanitisation_rule_spec.rb | 12 +- backend/spec/models/tag_name_spec.rb | 23 +- backend/spec/models/tag_spec.rb | 84 +-- .../spec/requests/locale_write_paths_spec.rb | 40 +- .../spec/services/post_create_plan_spec.rb | 20 +- .../services/tag_version_recorder_spec.rb | 9 +- backend/spec/support/locale_context.rb | 10 +- 13 files changed, 385 insertions(+), 346 deletions(-) diff --git a/backend/app/models/language.rb b/backend/app/models/language.rb index 1572f28..87f86d8 100644 --- a/backend/app/models/language.rb +++ b/backend/app/models/language.rb @@ -1,3 +1,5 @@ class Language < ApplicationRecord has_many :locales, foreign_key: :language_code, primary_key: :code + + enum :code, ja: 'ja', en: 'en', fr: 'fr' end diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index a2081f6..1d39f44 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -4,21 +4,24 @@ class Locale < ApplicationRecord belongs_to :language, foreign_key: :language_code, primary_key: :code belongs_to :script, foreign_key: :script_code, primary_key: :code + enum :code, ja: 'ja', en: 'en', fr: 'fr' + def self.nipponese = Locale.find('ja') private def generate_tag_names! - tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id) - Tag.where.not(id: tag_ids).find_each do - TagName.create!(tag_id: _1.id, - language_code:, - name: TagName.generate_name(self, _1, _1.name('ja')), - script_code:, - primary_flg: true, - # TODO: 公証実装したら書く. - # auto_generated: true, - canonical_id: nil) + ApplicationRecord.transaction do + tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id) + Tag.where.not(id: tag_ids).find_each do + TagName.create!(tag_id: _1.id, + language_code:, + name: TagName.generate_name(self, _1, _1.tag_name('ja')), + script_code:, + primary_flg: true, + auto_generated: true, + canonical_id: nil) + end end end end diff --git a/backend/app/models/script.rb b/backend/app/models/script.rb index 43abe1f..d87f262 100644 --- a/backend/app/models/script.rb +++ b/backend/app/models/script.rb @@ -1,3 +1,5 @@ class Script < ApplicationRecord - ; + has_many :locales, foreign_key: :language_code, primary_key: :code + + enum :code, Jpan: 'Jpan', Latn: 'Latn' end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index fa659ee..ff758ff 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -102,209 +102,6 @@ class Tag < ApplicationRecord def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? - def self.tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta) - def self.bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta) - def self.no_deerjikist = - find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta) - def self.video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta) - def self.niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta) - def self.youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta) - - def self.normalise_tags! locale, tag_names, - with_tagme: true, - with_no_deerjikist: true, - deny_deprecated: false, - with_sections: false - if tag_names.any? { |n| n.downcase.start_with?('nico:') } - raise NicoTagNormalisationError - end - - sections = { } - tags = tag_names.map do |name| - raw_name = name - pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] - - name = name.sub(/\A#{ pf }/i, '') - - sections_by_tag = [] - while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)) - name = "#{ match[1] }#{ match[4] }" - next if match[2].empty? && match[3].empty? - - sections_by_tag << normalise_section_range!( - begin_raw: match[2], - end_raw: match[3], - tag_name: name) - end - - if name.include?('[') || name.include?(']') - raise SectionLiteralParseError.new(raw_name, raw_name) - end - - name = TagName.canonicalise(locale, name).first - - find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag| - if deny_deprecated && tag.deprecated? - raise DeprecatedTagNormalisationError, [tag.name(locale.language_code)] - end - - tag.update!(category: cat) if cat && tag.category != cat - next if sections_by_tag.empty? - - sections[tag.id] ||= [] - sections[tag.id].concat(sections_by_tag) - sections[tag.id] = merge_section_ranges(sections[tag.id]) - sections.delete(tag.id) if sections[tag.id] == [[0, nil]] - end - end - - tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) - tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) } - tags.uniq!(&:id) - - if with_sections - { tags:, sections: } - else - tags - end - end - - def self.expand_parent_tags tags - return [] if tags.blank? - - seen = Set.new - result = [] - stack = tags.compact.dup - - until stack.empty? - tag = stack.pop - next unless tag - - tag.parents.each do |parent| - next if seen.include?(parent.id) - - seen << parent.id - result << parent - stack << parent - end - end - - (result + tags).uniq { |t| t.id } - end - - def self.normalise_section_range! begin_raw:, end_raw:, tag_name: - begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:) - end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:) - - if end_ms - begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms - end_ms = begin_ms + 1 if begin_ms == end_ms - end - - [begin_ms, end_ms] - end - - def self.merge_section_ranges ranges - sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] } - merged = [] - - sorted_ranges.each do |begin_ms, end_ms| - if merged.empty? - merged << [begin_ms, end_ms] - next - end - - last_begin_ms, last_end_ms = merged[-1] - if last_end_ms.nil? || begin_ms <= last_end_ms - merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)] - else - merged << [begin_ms, end_ms] - end - end - - merged - end - - def self.merge_section_end left_end_ms, right_end_ms - return nil if left_end_ms.nil? || right_end_ms.nil? - - [left_end_ms, right_end_ms].max - end - - def self.find_or_create_by_tag_name! locale, name, category: - language_code = locale.language_code - name = name.to_s.strip - - tn = TagName.find_or_create_by!(language_code:, name:) do - _1.script_code = locale.script_code - _1.primary_flg = true - end - - tag = tn.tag - return tag if tag - - tag = Tag.create!(tag_name_id: tn.id, category:) - tn.update!(tag:) - tag.association(:tag_names).reset - - tag - rescue ActiveRecord::RecordNotUnique - retry - end - - def self.merge_tags! target_tag, source_tags, created_by_user: nil - target_tag => Tag - - affected_post_ids = Set.new - - Tag.transaction do - TagVersioning.ensure_snapshot!(target_tag, created_by_user:) - - Array(source_tags).compact.uniq.each do |source_tag| - source_tag => Tag - - next if source_tag == target_tag - - TagVersioning.ensure_snapshot!(source_tag, created_by_user:) - - source_tag.post_tags.find_each do |source_pt| - post_id = source_pt.post_id - affected_post_ids << post_id - source_pt.destroy! - unless PostTag.exists?(post_id:, tag: target_tag) - PostTag.create!(post_id:, tag: target_tag) - end - end - - source_tag_name = source_tag.tag_name('ja') - - if source_tag_name.wiki_page.present? - raise ActiveRecord::RecordInvalid.new(source_tag_name) - end - - TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) - source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false, - updated_at: Time.current) - source_tag.destroy! - - source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, - updated_at: Time.current) - - TagVersioning.record!(target_tag, event_type: :update, created_by_user:) - end - - Post.where(id: affected_post_ids.to_a).find_each do |post| - PostVersionRecorder.ensure_snapshot!(post, created_by_user:) - PostVersionRecorder.record!(post:, event_type: :update, created_by_user:) - end - - # 投稿件数を再集計 - target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count) - end - - target_tag.reload - end - def snapshot_aliases(language_code) tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name) end @@ -319,6 +116,262 @@ class Tag < ApplicationRecord .pluck('tag_names.name') end + class << self + def tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta) + def bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta) + def no_deerjikist = + find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta) + def video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta) + def niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta) + def youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta) + + def normalise_tags! locale, tag_names, + with_tagme: true, + with_no_deerjikist: true, + deny_deprecated: false, + with_sections: false + if tag_names.any? { |n| n.downcase.start_with?('nico:') } + raise NicoTagNormalisationError + end + + sections = { } + tags = tag_names.map do |name| + raw_name = name + pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] + + name = name.sub(/\A#{ pf }/i, '') + + sections_by_tag = [] + while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)) + name = "#{ match[1] }#{ match[4] }" + next if match[2].empty? && match[3].empty? + + sections_by_tag << normalise_section_range!( + begin_raw: match[2], + end_raw: match[3], + tag_name: name) + end + + if name.include?('[') || name.include?(']') + raise SectionLiteralParseError.new(raw_name, raw_name) + end + + name = TagName.canonicalise(locale, name).first + + find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag| + if deny_deprecated && tag.deprecated? + raise DeprecatedTagNormalisationError, [tag.name(locale.language_code)] + end + + tag.update!(category: cat) if cat && tag.category != cat + next if sections_by_tag.empty? + + sections[tag.id] ||= [] + sections[tag.id].concat(sections_by_tag) + sections[tag.id] = merge_section_ranges(sections[tag.id]) + sections.delete(tag.id) if sections[tag.id] == [[0, nil]] + end + end + + tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) + tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) } + tags.uniq!(&:id) + + if with_sections + { tags:, sections: } + else + tags + end + end + + def expand_parent_tags tags + return [] if tags.blank? + + seen = Set.new + result = [] + stack = tags.compact.dup + + until stack.empty? + tag = stack.pop + next unless tag + + tag.parents.each do |parent| + next if seen.include?(parent.id) + + seen << parent.id + result << parent + stack << parent + end + end + + (result + tags).uniq { |t| t.id } + end + + def normalise_section_range! begin_raw:, end_raw:, tag_name: + begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:) + end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:) + + if end_ms + begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms + end_ms = begin_ms + 1 if begin_ms == end_ms + end + + [begin_ms, end_ms] + end + + def merge_section_ranges ranges + sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] } + merged = [] + + sorted_ranges.each do |begin_ms, end_ms| + if merged.empty? + merged << [begin_ms, end_ms] + next + end + + last_begin_ms, last_end_ms = merged[-1] + if last_end_ms.nil? || begin_ms <= last_end_ms + merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)] + else + merged << [begin_ms, end_ms] + end + end + + merged + end + + def merge_section_end left_end_ms, right_end_ms + return nil if left_end_ms.nil? || right_end_ms.nil? + + [left_end_ms, right_end_ms].max + end + + def find_or_create_by_tag_name! locale, name, category: + language_code = locale.language_code + name = name.to_s.strip + + tn = TagName.find_or_create_by!(language_code:, name:) do + _1.script_code = locale.script_code + _1.primary_flg = true + end + + tag = tn.tag + return tag if tag + + tag = Tag.create!(tag_name_id: tn.id, category:) + tn.update!(tag:) + generate_tag_names!(locale, tag, tn) + tag.association(:tag_names).reset + + tag + rescue ActiveRecord::RecordNotUnique + retry + end + + def merge_tags! target_tag, source_tags, created_by_user: nil + target_tag => Tag + + affected_post_ids = Set.new + + Tag.transaction do + TagVersioning.ensure_snapshot!(target_tag, created_by_user:) + + Array(source_tags).compact.uniq.each do |source_tag| + source_tag => Tag + + next if source_tag == target_tag + + TagVersioning.ensure_snapshot!(source_tag, created_by_user:) + + source_tag.post_tags.find_each do |source_pt| + post_id = source_pt.post_id + affected_post_ids << post_id + source_pt.destroy! + unless PostTag.exists?(post_id:, tag: target_tag) + PostTag.create!(post_id:, tag: target_tag) + end + end + + source_tag_name = source_tag.tag_name('ja') + + if source_tag_name.wiki_page.present? + raise ActiveRecord::RecordInvalid.new(source_tag_name) + end + + TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) + source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false, + updated_at: Time.current) + source_tag.destroy! + + source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, + updated_at: Time.current) + + TagVersioning.record!(target_tag, event_type: :update, created_by_user:) + end + + Post.where(id: affected_post_ids.to_a).find_each do |post| + PostVersionRecorder.ensure_snapshot!(post, created_by_user:) + PostVersionRecorder.record!(post:, event_type: :update, created_by_user:) + end + + # 投稿件数を再集計 + target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count) + end + + target_tag.reload + end + + def time_to_ms! str, tag_name: + match = + case str + when /\A(?\d+)(?:\.(?\d{1,3}))?\z/ + { hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds], + ms: Regexp.last_match[:ms] } + when /\A(?\d+):(?[0-5]?\d)(?:\.(?\d{1,3}))?\z/ + { hours: nil, minutes: Regexp.last_match[:minutes], + seconds: Regexp.last_match[:seconds], + ms: Regexp.last_match[:ms] } + when /\A(?\d+):(?[0-5]?\d):(?[0-5]?\d)(?:\.(?\d{1,3}))?\z/ + { hours: Regexp.last_match[:hours], + minutes: Regexp.last_match[:minutes], + seconds: Regexp.last_match[:seconds], + ms: Regexp.last_match[:ms] } + end + + raise SectionLiteralParseError.new(tag_name, str) unless match + + total_s = match[:seconds].to_i + total_s += match[:minutes].to_i * 60 if match[:minutes] + total_s += match[:hours].to_i * 3_600 if match[:hours] + + total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i + end + + private + + def generate_tag_names! locale, tag, base_tag_name + ApplicationRecord.transaction do + locales = + Locale + .where(deprecated_at: nil) + .where.not(language_code: locale.language_code) + .order(:language_code, :created_at, :script_code) + .uniq { _1.language_code } + locales.each do |locale| + language_code = locale.language_code + script_code = locale.script_code + TagName.create!(tag_id: tag.id, + language_code:, + name: TagName.generate_name(locale, tag, base_tag_name), + script_code:, + primary_flg: true, + auto_generated: true, + canonical_id: nil) + end + end + end + end + private def tag_names_must_be_valid @@ -334,30 +387,4 @@ class Tag < ApplicationRecord errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' end end - - def self.time_to_ms! str, tag_name: - match = - case str - when /\A(?\d+)(?:\.(?\d{1,3}))?\z/ - { hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds], - ms: Regexp.last_match[:ms] } - when /\A(?\d+):(?[0-5]?\d)(?:\.(?\d{1,3}))?\z/ - { hours: nil, minutes: Regexp.last_match[:minutes], - seconds: Regexp.last_match[:seconds], - ms: Regexp.last_match[:ms] } - when /\A(?\d+):(?[0-5]?\d):(?[0-5]?\d)(?:\.(?\d{1,3}))?\z/ - { hours: Regexp.last_match[:hours], - minutes: Regexp.last_match[:minutes], - seconds: Regexp.last_match[:seconds], - ms: Regexp.last_match[:ms] } - end - - raise SectionLiteralParseError.new(tag_name, str) unless match - - total_s = match[:seconds].to_i - total_s += match[:minutes].to_i * 60 if match[:minutes] - total_s += match[:hours].to_i * 3_600 if match[:hours] - - total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i - end end diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 5d47460..0ce8223 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -32,7 +32,7 @@ class TagName < ApplicationRecord }.uniq end - def self.generate_name locale, tag, name + def self.generate_name locale, tag, base_tag_name # TODO: 言語ごとの自動命名ロジック完成したら書く. "Tag_##{ tag.id }" diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb index 54304bf..852f66f 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -17,22 +17,23 @@ RSpec.describe Locale, type: :model do second = create(:tag, primary_name: 'second_existing_tag') allow(TagName).to receive(:generate_name).and_call_original allow(TagName).to receive(:generate_name) - .with(kind_of(described_class), first, primary_tag_name_for(first, 'ja').name) + .with(kind_of(described_class), first, primary_tag_name_for(first, 'ja')) .and_return('name_from_generator') locale = create_french_locale! expect(TagName).to have_received(:generate_name) - .with(locale, first, primary_tag_name_for(first, 'ja').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').name) + .with(locale, second, primary_tag_name_for(second, 'ja')) expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true)) .to have_attributes(name: 'name_from_generator', tag_id: first.id, language_code: 'fr', script_code: 'Latn', - primary_flg: true) + 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', - script_code: 'Latn', primary_flg: true) + script_code: 'Latn', primary_flg: true, + auto_generated: true) expect(TagName.where(language_code: 'fr', primary_flg: true, tag_id: [first.id, second.id]).count).to eq(2) end @@ -48,7 +49,8 @@ RSpec.describe Locale, type: :model do expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true)) .to contain_exactly(existing) expect(existing.reload).to have_attributes( - tag_id: tag.id, language_code: 'fr', script_code: 'Latn', primary_flg: true) + tag_id: tag.id, language_code: 'fr', script_code: 'Latn', + primary_flg: true, auto_generated: false) end it 'creates a primary name when the language has only an alias' do @@ -65,7 +67,8 @@ RSpec.describe Locale, type: :model do 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', - script_code: 'Latn', primary_flg: true) + 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) end end diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index dcecd06..0790421 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -31,14 +31,14 @@ RSpec.describe TagNameSanitisationRule, type: :model do describe '.apply!' do before do - described_class.create!(priority: 10, source_pattern: '_', replacement: '') + described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo') end context 'when only another language has the sanitised name' do include_context 'English locale' it 'keeps both names when sanitisation produces a cross-language match' do - japanese = create(:tag_name, name: 'foobar') + nipponese = create(:tag_name, name: 'foobar') english = create(:tag_name, name: 'temporary', language_code: 'en', script_code: 'Latn') english.update_columns(name: 'foo_bar') @@ -46,11 +46,11 @@ RSpec.describe TagNameSanitisationRule, type: :model do expect { described_class.apply! }.not_to change(TagName, :count) expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en') - expect(japanese.reload).to have_attributes(name: 'foobar', language_code: 'ja') + expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja') end it 'renames without merging tags or deleting either language identity' do - japanese = create(:tag, primary_name: 'foobar') + nipponese = create(:tag, primary_name: 'foobar') english_name = create(:tag_name, name: 'temporary', language_code: 'en', script_code: 'Latn') english = create(:tag, primary_tag_name: english_name) @@ -60,8 +60,8 @@ RSpec.describe TagNameSanitisationRule, type: :model do expect(english_name.reload).to have_attributes( name: 'foobar', language_code: 'en', tag_id: english.id) - expect(primary_tag_name_for(japanese.reload, 'ja')).to have_attributes( - name: 'foobar', language_code: 'ja', tag_id: japanese.id) + expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes( + name: 'foobar', language_code: 'ja', tag_id: nipponese.id) expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name) end end diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index aabcf0a..959778a 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -20,7 +20,7 @@ RSpec.describe TagName, type: :model do expect(duplicate.errors.of_kind?(:name, :taken)).to be(true) end - it 'creates Japanese factory names without requiring a tag' do + it 'creates Nipponese factory names without requiring a tag' do name = create(:tag_name) expect(name.reload).to have_attributes( @@ -74,15 +74,6 @@ RSpec.describe TagName, type: :model do .to eq(['detached_alias']) end - it 'leaves an alias unchanged when its language has no primary' do - tag = create(:tag, primary_name: '日本語のみ') - create(:tag_name, :alias, tag:, name: 'orphan_english_alias', - language_code: 'en', script_code: 'Latn') - - expect(described_class.canonicalise(locale, ['orphan_english_alias'])) - .to eq(['orphan_english_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', @@ -102,18 +93,18 @@ RSpec.describe TagName, type: :model do end it 'resolves only aliases in the requested language' do - japanese = create(:tag, primary_name: 'japanese_canonical') + nipponese = create(:tag, primary_name: 'nipponese_canonical') english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') english = create(:tag, primary_tag_name: english_name) - japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: japanese) + nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: nipponese) english_alias = create(:tag_name, :alias, name: 'shared_alias', tag: english, language_code: 'en', script_code: 'Latn') - expect(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + expect(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.id, primary_flg: false, language_code: 'ja') expect(english_alias.reload).to have_attributes( tag_id: english.id, @@ -121,7 +112,7 @@ RSpec.describe TagName, type: :model do expect(described_class.canonicalise(locale, ['shared_alias'])) .to eq(['english_canonical']) expect(described_class.canonicalise(Locale.nipponese, ['shared_alias'])) - .to eq(['japanese_canonical']) + .to eq(['nipponese_canonical']) end end end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 27c8565..f9d1934 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -28,22 +28,21 @@ RSpec.describe Tag, type: :model do it 'canonicalises aliases in the supplied language only' do Language.find_or_create_by!(code: 'en') { _1.name = 'English' } Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } - Locale.insert_all!([ - { code: 'en', language_code: 'en', script_code: 'Latn', - name: 'English', created_at: Time.current }]) unless Locale.exists?(code: 'en') - english_locale = Locale.find('en') - japanese = create(:tag, primary_name: 'japanese_canonical') + nipponese = create(:tag, primary_name: 'nipponese_canonical') english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') english = create(:tag, primary_tag_name: english_name) - japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: japanese) + create(:tag_name, tag: english, name: 'english_in_nipponese') + english_locale = Locale.create!( + code: 'en', language_code: 'en', script_code: 'Latn', name: 'English') + nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: nipponese) english_alias = create(:tag_name, :alias, name: 'shared_alias', tag: english, language_code: 'en', script_code: 'Latn') - expect(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + expect(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.id, primary_flg: false, language_code: 'ja') expect(english_alias.reload).to have_attributes( tag_id: english.id, @@ -53,7 +52,7 @@ RSpec.describe Tag, type: :model do with_tagme: false, with_no_deerjikist: false)).to eq([english]) expect(described_class.normalise_tags!( Locale.nipponese, ['shared_alias'], - with_tagme: false, with_no_deerjikist: false)).to eq([japanese]) + with_tagme: false, with_no_deerjikist: false)).to eq([nipponese]) end it 'rejects deprecated tags when deny_deprecated is enabled' do @@ -228,21 +227,46 @@ RSpec.describe Tag, type: :model do expect(tag.category).to eq('character') end + it 'uses the English primary as the source for a generated Nipponese name' do + allow(TagName).to receive(:generate_name).and_return('generated_nipponese') + + tag = described_class.find_or_create_by_tag_name!( + locale, 'english_origin', category: :general) + + expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) + .to contain_exactly(have_attributes( + name: 'english_origin', auto_generated: false)) + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) + .to contain_exactly(have_attributes( + name: 'generated_nipponese', auto_generated: true)) + source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true) + expect(TagName).to have_received(:generate_name) + .with(Locale.nipponese, tag, source_tag_name) + end + + it 'gives a new tag one primary name for every active language' do + tag = described_class.find_or_create_by_tag_name!( + Locale.nipponese, 'new_multilingual_tag', category: :general) + + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) + expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1) + end + it 'creates separate tag identities for the same name in different languages' do - japanese = described_class.find_or_create_by_tag_name!( + nipponese = described_class.find_or_create_by_tag_name!( Locale.nipponese, 'same_name', category: :general) english = described_class.find_or_create_by_tag_name!( locale, 'same_name', category: :character) - expect(english).not_to eq(japanese) + expect(english).not_to eq(nipponese) expect(primary_tag_name_for(english, 'en')).to have_attributes( name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true) - expect(primary_tag_name_for(japanese, 'ja')).to have_attributes( - name: 'same_name', tag_id: japanese.id, language_code: 'ja', primary_flg: true) + expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes( + name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true) expect(described_class.find_or_create_by_tag_name!( locale, 'same_name', category: :general)).to eq(english) expect(described_class.find_or_create_by_tag_name!( - Locale.nipponese, 'same_name', category: :general)).to eq(japanese) + Locale.nipponese, 'same_name', category: :general)).to eq(nipponese) end it 'preserves the V1 representative when looking up another primary language' do @@ -335,33 +359,25 @@ RSpec.describe Tag, type: :model do include_context 'English locale' it 'keeps the target primary and moves source names as aliases' do - english_primary = create(:tag_name, tag: target_tag, - name: 'target_english', language_code: 'en', script_code: 'Latn') - source_primary = create(:tag_name, tag: source_tag, - name: 'source_english', language_code: 'en', script_code: 'Latn') + english_primary = primary_tag_name_for(target_tag, 'en') + source_primary = primary_tag_name_for(source_tag, 'en') source_alias = create(:tag_name, :alias, tag: source_tag, name: 'source_english_alias', language_code: 'en', script_code: 'Latn') + expect([english_primary, source_primary]).to all(have_attributes( + primary_flg: true, auto_generated: true)) + described_class.merge_tags!(target_tag, [source_tag]) expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary) + expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true)) + .to contain_exactly(english_primary) expect([source_primary.reload, source_alias.reload]).to all(have_attributes( tag_id: target_tag.id, language_code: 'en', primary_flg: false)) - end - - it 'inherits a source primary when the target has no name in that language' do - source_primary = create(:tag_name, tag: source_tag, - name: 'inherited_english', language_code: 'en', script_code: 'Latn') - source_alias = create(:tag_name, :alias, tag: source_tag, - name: 'inherited_alias', language_code: 'en', script_code: 'Latn') - - described_class.merge_tags!(target_tag, [source_tag]) - - expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(source_primary) - expect(source_primary.reload).to have_attributes( - tag_id: target_tag.id, language_code: 'en', primary_flg: true) - expect(source_alias.reload).to have_attributes( - tag_id: target_tag.id, language_code: 'en', primary_flg: false) + expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true)) + .to contain_exactly(primary_tag_name_for(target_tag, 'ja')) + expect(source_tag_name.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) end end diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index fa3b032..83a3a3b 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -22,7 +22,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do let(:locale_params) { requested_locale ? { locale: requested_locale } : { } } let(:expected_locale) { requested_locale ? locale : Locale.nipponese } - it 'creates post tags in the requested language or Japanese fallback' do + it 'creates post tags in the requested language or Nipponese fallback' do post '/posts', params: locale_params.merge( title: 'Locale post', url: 'https://example.com/locale-post', tags: 'locale_post_tag', parent_post_ids: '') @@ -32,7 +32,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(Post.find(json.fetch('id')).tags).to include(tag) end - it 'updates post tags in the requested language or Japanese fallback' do + it 'updates post tags in the requested language or Nipponese fallback' do record = create(:post) PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member) @@ -110,8 +110,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn') end let!(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) } - let!(:japanese_tag) do - create(:tag, primary_name: 'temporary_japanese', category: :general).tap do |tag| + let!(:nipponese_tag) do + create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag| # Isolate lookup from the separately tested uniqueness validation. primary_tag_name_for(tag, 'ja').update_columns(name: 'shared_name') end @@ -125,9 +125,9 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(response).to have_http_status(:created), response.body record = Post.find(json.fetch('id')) expect(record.tags).to include(english_tag) - expect(record.tags).not_to include(japanese_tag) + expect(record.tags).not_to include(nipponese_tag) expect(english_tag.reload.category).to eq('material') - expect(japanese_tag.reload.category).to eq('general') + expect(nipponese_tag.reload.category).to eq('general') end it 'updates the post using the English identity' do @@ -140,7 +140,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(response).to have_http_status(:ok), response.body expect(record.reload.tags).to include(english_tag) - expect(record.tags).not_to include(japanese_tag) + expect(record.tags).not_to include(nipponese_tag) end it 'resolves the English material tag' do @@ -149,7 +149,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(response).to have_http_status(:created), response.body expect(Material.find(json.fetch('id')).tag).to eq(english_tag) - expect(Material.where(tag: japanese_tag)).to be_empty + expect(Material.where(tag: nipponese_tag)).to be_empty end it 'resolves the English parent tag' do @@ -182,16 +182,16 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end let!(:tag) { create(:tag, primary_tag_name: english_name) } - it 'allows a rename to a name already used in Japanese' do - japanese = create(:tag, primary_name: 'rename_target') + it 'allows a rename to a name already used in Nipponese' do + nipponese = create(:tag, primary_name: 'rename_target') patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' } expect(response).to have_http_status(:ok), response.body expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes( name: 'rename_target', language_code: 'en', tag_id: tag.id) - expect(primary_tag_name_for(japanese.reload, 'ja')).to have_attributes( - name: 'rename_target', language_code: 'ja', tag_id: japanese.id) + expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes( + name: 'rename_target', language_code: 'ja', tag_id: nipponese.id) end it 'creates aliases in the requested language' do @@ -210,11 +210,11 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end it 'does not take an alias from another language' do - japanese = create(:tag, primary_name: 'japanese_owner') - japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: japanese) - expect(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + nipponese = create(:tag, primary_name: 'nipponese_owner') + nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: nipponese) + expect(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.id, primary_flg: false, language_code: 'ja') put "/tags/#{ tag.id }", params: { @@ -223,15 +223,15 @@ RSpec.describe 'Locale propagation on write paths', type: :request do aliases: 'shared_alias', parent_tags: '' } expect(response).to have_http_status(:ok), response.body - expect(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + expect(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.id, primary_flg: false, language_code: 'ja') expect(TagName.find_by!(language_code: 'en', name: 'shared_alias')) .to have_attributes( tag_id: tag.id, script_code: 'Latn', primary_flg: false, language_code: 'en') expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id)) - .to contain_exactly(['ja', japanese.id], ['en', tag.id]) + .to contain_exactly(['ja', nipponese.id], ['en', tag.id]) end end end diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 5ff805a..c5521b3 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -17,16 +17,16 @@ RSpec.describe PostCreatePlan do english_name = create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn') english = create(:tag, primary_tag_name: english_name, category: :character) - japanese = create(:tag, primary_name: 'temporary_japanese', 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(japanese, 'ja').update_columns(name: 'shared_name') + primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name') english_parent_name = create(:tag_name, name: 'english_parent', language_code: 'en', script_code: 'Latn') english_parent = create(:tag, primary_tag_name: english_parent_name, category: :material) - japanese_parent = create(:tag, primary_name: 'japanese_parent', category: :general) + nipponese_parent = create(:tag, primary_name: 'nipponese_parent', category: :general) TagImplication.create!(tag: english, parent_tag: english_parent) - TagImplication.create!(tag: japanese, parent_tag: japanese_parent) + TagImplication.create!(tag: nipponese, parent_tag: nipponese_parent) plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale) @@ -35,22 +35,22 @@ RSpec.describe PostCreatePlan do expect(plan[:post_tag_specs]).to include( { name: primary_tag_name_for(english_parent, 'en').name, category: :material }) expect(plan[:post_tag_specs].pluck(:name)) - .not_to include(primary_tag_name_for(japanese_parent, 'ja').name) + .not_to include(primary_tag_name_for(nipponese_parent, 'ja').name) end it 'resolves aliases within the requested language' do - japanese = create(:tag, primary_name: 'japanese_canonical') + nipponese = create(:tag, primary_name: 'nipponese_canonical') english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') english = create(:tag, primary_tag_name: english_name, category: :character) - japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: japanese) + nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: nipponese) english_alias = create(:tag_name, :alias, name: 'shared_alias', tag: english, language_code: 'en', script_code: 'Latn') - expect(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + expect(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.id, primary_flg: false, language_code: 'ja') expect(english_alias.reload).to have_attributes( tag_id: english.id, diff --git a/backend/spec/services/tag_version_recorder_spec.rb b/backend/spec/services/tag_version_recorder_spec.rb index c556d87..9e62183 100644 --- a/backend/spec/services/tag_version_recorder_spec.rb +++ b/backend/spec/services/tag_version_recorder_spec.rb @@ -2,15 +2,12 @@ require 'rails_helper' RSpec.describe TagVersionRecorder do let(:user) { create_member_user! } - let(:tag) { create(:tag, primary_name: '日本語名') } + let!(:tag) { create(:tag, primary_name: '日本語名') } + include_context 'English locale' before do - Language.insert_all!([{ code: 'en', name: 'English', created_at: Time.current }]) - Script.insert_all!([{ code: 'Latn', name: 'Latin', created_at: Time.current }]) - Locale.insert_all!([{ code: 'en', language_code: 'en', script_code: 'Latn', - name: 'English', created_at: Time.current }]) create(:tag_name, :alias, tag:, name: '別名') - create(:tag_name, tag:, language_code: 'en', script_code: 'Latn', name: 'English name') + primary_tag_name_for(tag, 'en').update!(name: 'English name', auto_generated: false) create(:tag_name, :alias, tag:, language_code: 'en', script_code: 'Latn', name: 'English alias') end diff --git a/backend/spec/support/locale_context.rb b/backend/spec/support/locale_context.rb index fa873ef..92ef3db 100644 --- a/backend/spec/support/locale_context.rb +++ b/backend/spec/support/locale_context.rb @@ -2,12 +2,10 @@ RSpec.shared_context 'English locale' do let!(:locale) do Language.find_or_create_by!(code: 'en') { _1.name = 'English' } Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } - unless Locale.exists?(code: 'en') - # Reference data only: do not generate names for unrelated existing tags. - Locale.insert_all!([ - { code: 'en', language_code: 'en', script_code: 'Latn', - name: 'English', created_at: Time.current }]) + Locale.find_or_create_by!(code: 'en') do |record| + record.language_code = 'en' + record.script_code = 'Latn' + record.name = 'English' end - Locale.find('en') end end