From 2e98ed02e38a895bdab0f3cc753b24eec96c7f01 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 12 Oct 2026 20:10:04 +0900 Subject: [PATCH] #424 --- .../app/controllers/wiki_pages_controller.rb | 57 ++++----- backend/app/models/external_tag.rb | 2 + backend/app/models/tag.rb | 2 + backend/app/models/tag_description.rb | 2 + backend/app/models/tag_group.rb | 2 + backend/app/representations/wiki_page_repr.rb | 3 +- .../services/shared_resource_id_allocator.rb | 56 +++++++++ .../app/services/tag_description_operation.rb | 26 +++- backend/app/services/tag_group_operation.rb | 2 +- backend/app/services/wiki/commit.rb | 14 ++- .../app/services/wiki/compatibility_writer.rb | 16 ++- .../spec/db/tag_description_migration_spec.rb | 4 +- backend/spec/models/shared_tag_ids_spec.rb | 7 ++ .../requests/tag_description_wiki_spec.rb | 4 +- .../tag_description_wiki_write_spec.rb | 54 +++++++-- .../requests/tag_group_wiki_write_spec.rb | 14 ++- .../tag_group_write_integrity_spec.rb | 31 ++--- backend/spec/requests/tag_groups_spec.rb | 10 +- backend/spec/requests/wiki_conflict_spec.rb | 12 +- .../requests/wiki_creation_freeze_spec.rb | 12 +- .../requests/wiki_history_integrity_spec.rb | 2 +- .../spec/requests/wiki_index_order_spec.rb | 42 +++++++ backend/spec/requests/wiki_spec.rb | 2 +- .../requests/wiki_unmigrated_contract_spec.rb | 18 +-- .../shared_resource_id_allocator_spec.rb | 65 ++++++++++ .../services/wiki/commit_integrity_spec.rb | 47 +++++--- backend/spec/services/wiki/commit_spec.rb | 2 +- frontend/scripts/help-assets.ts | 4 +- frontend/src/components/TopNav.tsx | 4 +- frontend/src/lib/help.test.ts | 43 ++++--- frontend/src/lib/helpPrefetch.test.tsx | 15 ++- frontend/src/mdx-components.test.tsx | 16 ++- .../src/pages/help/HelpChangesPage.test.tsx | 14 ++- .../src/pages/help/HelpDetailPage.test.tsx | 25 ++-- frontend/src/pages/help/HelpListPage.test.tsx | 23 ++-- .../pages/wiki/WikiHelpCompatibility.test.tsx | 30 +++-- .../src/pages/wiki/WikiSearchPage.test.tsx | 70 ++++++----- frontend/src/pages/wiki/WikiSearchPage.tsx | 3 +- frontend/src/test/factories.ts | 1 + frontend/src/test/helpFixtures.tsx | 111 ++++++++++++++---- frontend/src/types.ts | 1 + 41 files changed, 624 insertions(+), 244 deletions(-) create mode 100644 backend/app/services/shared_resource_id_allocator.rb create mode 100644 backend/spec/requests/wiki_index_order_spec.rb create mode 100644 backend/spec/services/shared_resource_id_allocator_spec.rb diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 840312b..abf2748 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -9,7 +9,8 @@ class WikiPagesController < ApplicationController TagDescription.where(language_code: 'ja') .includes(tag: :tag_names) - tag_groups = TagGroup.all + descriptions = descriptions.order(updated_at: :desc, id: :asc) + tag_groups = TagGroup.order(updated_at: :desc, id: :asc) if title.present? pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" descriptions = @@ -22,7 +23,9 @@ class WikiPagesController < ApplicationController json = descriptions.map { |description| WikiPageRepr.tag_description(description) } json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) }) - json.sort! { |a, b| b['updated_at'] <=> a['updated_at'] } + json.sort_by! do |row| + [-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')] + end json = json.first(20) if title.present? render json: end @@ -99,7 +102,7 @@ class WikiPagesController < ApplicationController 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", 'deprecated_at' => nil } else - raise 'お前やりませんねぇスギ!' + return head :not_found end end @@ -141,7 +144,7 @@ class WikiPagesController < ApplicationController page = if title.include?(':') - raise 'お前やりませんねぇスギ!' + return render_unprocessable_entity field: :title else tag_name = TagName.find_by(language_code: 'ja', name: title) return render_unprocessable_entity field: :title unless tag_name&.tag @@ -150,7 +153,8 @@ class WikiPagesController < ApplicationController tag: tag_name.tag, body:, created_by_user: current_user, message:) end - render json: WikiPageRepr.base(page), status: :created + description = TagDescription.find_by!(tag_id: page.tag_name.tag_id, language_code: 'ja') + render json: WikiPageRepr.tag_description(description), status: :created rescue ActiveRecord::RecordInvalid => e render_validation_error e.record rescue ActiveRecord::RecordNotUnique @@ -180,44 +184,34 @@ class WikiPagesController < ApplicationController return head :ok end - page = WikiPage.find_by(id: params[:id]) - if legacy_group_page?(page) + return render_unprocessable_entity field: :title if title.include?(':') + description = TagDescription.find_by(id: params[:id]) + unless description + return render_unprocessable_entity field: :title if group || WikiPage.exists?(id: params[:id]) + return head :not_found + end + tag = description.tag + tag_name = TagDescriptionOperation.compatibility_tag_name(tag) + if tag_name.name.include?(':') || (group && title != tag_name.name) return render_unprocessable_entity field: :title end - if group && !legacy_update_identified?(page, title, group) - return render_unprocessable_entity field: :title - end - return head :not_found unless page base_revision_id = params[:base_revision_id].presence ApplicationRecord.transaction do - page.lock! - - old_title = page.title - - tag = page.tag_name.tag - - if tag && title != old_title + tag.lock! + old_title = tag_name.name + if title != old_title TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) end - page.tag_name.update!(name: title) if title != old_title + tag_name.update!(name: title) if title != old_title message = params[:message].presence - description = TagDescription.find_by(id: page.id) - description_page = description && ( - tag&.id == description.tag_id || - (tag.nil? && !old_title.include?(':'))) - if description && !description_page - Wiki::Commit.legacy_content!(page:, body:, created_user: current_user, - message:, base_revision_id:) - else - Wiki::Commit.content!(page:, body:, created_user: current_user, - message:, base_revision_id:) - end + TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user, + message:, base_revision_id:) - if tag && title != old_title + if title != old_title tag.reload TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) end @@ -306,6 +300,7 @@ class WikiPagesController < ApplicationController updated_at = version.created_at render json: { 'id' => version.tag_group_id, + 'resource_type' => 'tag_group', 'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }", 'deprecated_at' => nil, 'body' => body, diff --git a/backend/app/models/external_tag.rb b/backend/app/models/external_tag.rb index dc81bef..a1f03a2 100644 --- a/backend/app/models/external_tag.rb +++ b/backend/app/models/external_tag.rb @@ -1,4 +1,6 @@ class ExternalTag < ApplicationRecord + before_create { SharedResourceIdAllocator.assign!(self) } + enum :platform, nico: 'nico' validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys } diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 7891029..8c8ab33 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -2,6 +2,8 @@ require 'set' class Tag < ApplicationRecord + before_create { SharedResourceIdAllocator.assign!(self) } + class NicoTagNormalisationError < ArgumentError ; end diff --git a/backend/app/models/tag_description.rb b/backend/app/models/tag_description.rb index 2c5e226..f2f212d 100644 --- a/backend/app/models/tag_description.rb +++ b/backend/app/models/tag_description.rb @@ -1,3 +1,5 @@ class TagDescription < ApplicationRecord + before_create { SharedResourceIdAllocator.assign!(self) } + belongs_to :tag end diff --git a/backend/app/models/tag_group.rb b/backend/app/models/tag_group.rb index 3533641..19cf4ed 100644 --- a/backend/app/models/tag_group.rb +++ b/backend/app/models/tag_group.rb @@ -1,4 +1,6 @@ class TagGroup < ApplicationRecord + before_create { SharedResourceIdAllocator.assign!(self) } + belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index 12fb3b6..e1b780e 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -13,6 +13,7 @@ module WikiPageRepr def tag_description description tag = description.tag { 'id' => description.id, + 'resource_type' => 'tag_description', 'tag_name_id' => tag.tag_name('ja')&.id, 'body' => description.body, 'created_user_id' => description.created_by_user_id, @@ -26,7 +27,7 @@ module WikiPageRepr end def tag_group tag_group - TagGroupRepr.tag_group(tag_group) + TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group') end def many wiki_pages diff --git a/backend/app/services/shared_resource_id_allocator.rb b/backend/app/services/shared_resource_id_allocator.rb new file mode 100644 index 0000000..03ad394 --- /dev/null +++ b/backend/app/services/shared_resource_id_allocator.rb @@ -0,0 +1,56 @@ +require 'digest' + + +class SharedResourceIdAllocator + SPACES = { + 'tags' => ['tags', 'external_tags'], + 'wiki' => ['tag_descriptions', 'tag_groups'] }.freeze + LOCK_TIMEOUT = 10 + + def self.assign! record + space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) } + raise ArgumentError, 'Unknown shared ID resource' unless tables + + connection = record.class.connection + transaction = record.class.current_transaction + raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' unless transaction.open? + + database = connection.pool.db_config.database + name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }" + quoted_name = connection.quote(name) + acquired = record.class.uncached do + connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })") + end + unless acquired.to_i == 1 + raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }" + end + + release = lambda do + result = record.class.uncached do + connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })") + end + unless result.to_i == 1 + raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }" + end + end + begin + transaction.after_commit(&release) + transaction.after_rollback(&release) + rescue StandardError + release.call + raise + end + + return if record.id.present? + + # Locking reads see committed maxima even after earlier repeatable reads. + maxima = record.class.uncached do + tables.map do |table| + quoted_table = connection.quote_table_name(table) + connection.select_value( + "SELECT id FROM #{ quoted_table } ORDER BY id DESC LIMIT 1 FOR UPDATE").to_i + end + end + record.id = maxima.max + 1 + end +end diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb index f3d9ebc..c0d0d11 100644 --- a/backend/app/services/tag_description_operation.rb +++ b/backend/app/services/tag_description_operation.rb @@ -8,9 +8,9 @@ class TagDescriptionOperation tag_id: tag.id, language_code: LANGUAGE_CODE) page = Wiki::CompatibilityWriter.create!( - tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:) + tag_name: compatibility_tag_name(tag), body:, created_by_user:, message:) revision = page.wiki_revisions.order(id: :desc).first - record_version!(tag:, revision:, body:, user: created_by_user, + record_version!(tag:, revision:, body: revision.body, user: created_by_user, description: nil) page end @@ -22,7 +22,20 @@ class TagDescriptionOperation tag.lock! description = TagDescription.lock.find_by!( tag_id: tag.id, language_code: LANGUAGE_CODE) - page = WikiPage.lock.find(description.id) + tag_name = compatibility_tag_name(tag) + page = WikiPage.lock.find_by(tag_name_id: tag_name.id) + unless page + versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: LANGUAGE_CODE) + if base_revision_id.present? && versions.maximum(:id) != base_revision_id.to_i + raise Wiki::Commit::Conflict, '競合が発生してゐます.' + end + page = Wiki::CompatibilityWriter.create!( + tag_name:, body:, created_by_user: updated_by_user, message:) + revision = page.current_revision + record_version!(tag:, revision:, body: revision.body, + user: updated_by_user, description:) + next revision + end revision = Wiki::CompatibilityWriter.update!( page:, body:, updated_by_user:, message:, base_revision_id:) @@ -34,6 +47,11 @@ class TagDescriptionOperation end end + def self.compatibility_tag_name tag + tag.tag_name(LANGUAGE_CODE) || TagName.find_by!(id: tag.tag_name_id, + language_code: LANGUAGE_CODE) + end + def self.record_version! tag:, revision:, body:, user:, description: version_no = description ? description.version_no + 1 : 1 TagDescriptionVersion.create!( @@ -44,7 +62,7 @@ class TagDescriptionOperation description.update!(body:, version_no:, updated_by_user_id: user.id) else TagDescription.create!( - id: revision.wiki_page_id, tag_id: tag.id, + tag_id: tag.id, language_code: LANGUAGE_CODE, version_no:, body:, created_by_user_id: user.id, updated_by_user_id: user.id) end diff --git a/backend/app/services/tag_group_operation.rb b/backend/app/services/tag_group_operation.rb index 905773b..985276e 100644 --- a/backend/app/services/tag_group_operation.rb +++ b/backend/app/services/tag_group_operation.rb @@ -349,7 +349,7 @@ class TagGroupOperation tag_name.primary_flg = true tag_name.auto_generated = false tag_name.save! - Wiki::Commit.create_legacy_content!(tag_name:, body:, created_by_user: actor) + Wiki::CompatibilityWriter.create!(tag_name:, body:, created_by_user: actor) end nil end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index 1414307..d18bd63 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -8,12 +8,13 @@ module Wiki end def self.create_content! tag_name:, body:, created_by_user:, message: nil - if tag_name.tag && tag_name.language_code == 'ja' + if tag_name.tag && tag_name.language_code == 'ja' && !tag_name.name.include?(':') return TagDescriptionOperation.create!( tag: tag_name.tag, body:, created_by_user:, message:) end - create_legacy_content!(tag_name:, body:, created_by_user:, message:) + tag_name.errors.add(:base, :invalid) + raise ActiveRecord::RecordInvalid, tag_name end def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil @@ -42,14 +43,17 @@ module Wiki end def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil - description = TagDescription.find_by(id: page.id) - if description + description = TagDescription.find_by(tag_id: page.tag_name.tag_id, language_code: 'ja') + description ||= TagDescription.joins(:tag).find_by( + tags: { tag_name_id: page.tag_name_id }, language_code: 'ja') + if description && !page.title.include?(':') return TagDescriptionOperation.update!( tag: description.tag, body:, updated_by_user: created_user, message:, base_revision_id:) end - legacy_content!(page:, body:, created_user:, message:, base_revision_id:) + page.errors.add(:base, :invalid) + raise ActiveRecord::RecordInvalid, page end def self.legacy_content! page:, body:, created_user:, message: nil, diff --git a/backend/app/services/wiki/compatibility_writer.rb b/backend/app/services/wiki/compatibility_writer.rb index 2bb003c..c7bcaa1 100644 --- a/backend/app/services/wiki/compatibility_writer.rb +++ b/backend/app/services/wiki/compatibility_writer.rb @@ -1,8 +1,16 @@ module Wiki class CompatibilityWriter def self.create! tag_name:, body:, created_by_user:, message: nil - Commit.create_legacy_content!( - tag_name:, body:, created_by_user:, message:) + ApplicationRecord.transaction do + tag_name.lock! + page = WikiPage.lock.find_by(tag_name_id: tag_name.id) + if page + update!(page:, body:, updated_by_user: created_by_user, message:) + page + else + Commit.create_legacy_content!(tag_name:, body:, created_by_user:, message:) + end + end end def self.update! page:, body:, updated_by_user:, message: nil, @@ -23,7 +31,9 @@ module Wiki end end - return if Commit.normalise_body(page.body) == normalised + if Commit.normalise_body(page.body) == normalised && page.wiki_revisions.exists? + return + end Commit.legacy_content!( page:, body: normalised, created_user: updated_by_user, message:, diff --git a/backend/spec/db/tag_description_migration_spec.rb b/backend/spec/db/tag_description_migration_spec.rb index 012bd29..c34c2a1 100644 --- a/backend/spec/db/tag_description_migration_spec.rb +++ b/backend/spec/db/tag_description_migration_spec.rb @@ -5,11 +5,11 @@ RSpec.describe 'Tag description migration identity' do it 'preserves every revision ID and repeated body in an independent sequence' do user = create_member_user! tag_name = create(:tag_name, name: 'description_migration') - page = Wiki::Commit.create_content!( + page = Wiki::Commit.create_legacy_content!( tag_name:, body: 'A', created_by_user: user) revisions = [page.current_revision] ['A', 'B', 'B', 'A'].each do |body| - revisions << Wiki::Commit.content!( + revisions << Wiki::Commit.legacy_content!( page:, body:, created_user: user, base_revision_id: revisions.last.id) end tag = create(:tag, primary_tag_name: tag_name) diff --git a/backend/spec/models/shared_tag_ids_spec.rb b/backend/spec/models/shared_tag_ids_spec.rb index e4ddd77..039de17 100644 --- a/backend/spec/models/shared_tag_ids_spec.rb +++ b/backend/spec/models/shared_tag_ids_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do include TagGroupRecords + let(:user) { create_member_user! } def create_resource resource if resource == :tag @@ -25,6 +26,12 @@ RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do expect(create_resource(resource).id).to eq(21) end + it "uses the current maximum after deleting earlier #{ resource } records" do + create(:tag, id: 100).destroy! + create(:external_tag, id: 200).destroy! + expect(create_resource(resource).id).to eq(1) + end + it "preserves legacy overlaps and gaps when creating #{ resource }" do [1, 2, 5].each { create(:tag, id: _1) } [1, 3, 8].each { create(:external_tag, id: _1) } diff --git a/backend/spec/requests/tag_description_wiki_spec.rb b/backend/spec/requests/tag_description_wiki_spec.rb index a7af93d..02b1184 100644 --- a/backend/spec/requests/tag_description_wiki_spec.rb +++ b/backend/spec/requests/tag_description_wiki_spec.rb @@ -10,8 +10,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do end let!(:revisions) do first = page.current_revision - second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user) - third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user) + second = Wiki::Commit.legacy_content!(page:, body: 'old-second', created_user: user) + third = Wiki::Commit.legacy_content!(page:, body: 'old-third', created_user: user) [first, second, third] end let!(:tag) { create(:tag, primary_tag_name: tag_name) } diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb index ab8d035..3b3af9d 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -32,20 +32,38 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do headers: headers end - it 'creates a description without implicitly creating Wiki records' do + it 'creates a description with matching Wiki content and histories' do expect { create_description } - .to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) .and change(TagDescription, :count).by(1) .and change(TagDescriptionVersion, :count).by(1) expect(response).to have_http_status(:created) description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') expect(description).to have_attributes(body: 'initial', version_no: 1) + page = WikiPage.find_by!(tag_name:) + expect(page.body).to eq(description.body) + expect(page.current_revision.body).to eq(description.body) + expect(page.wiki_versions.sole.body).to eq(description.body) + expect(json.fetch('id')).to eq(description.id) expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')) .to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id) end + it 'reuses an existing compatibility page and synchronises its histories' do + page = Wiki::Commit.create_legacy_content!( + tag_name:, body: 'legacy', created_by_user: user) + expect { create_description } + .to change(WikiPage, :count).by(0) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(response).to have_http_status(:created) + description = TagDescription.find_by!(tag_id: tag.id) + expect(page.reload.body).to eq(description.body) + expect(TagDescriptionVersion.find(page.current_revision.id).body).to eq(page.body) + end + ['missing_description', 'orphan_description'].each do |title| it "rejects the bare title #{ title } without writing any resource" do create(:tag_name, name: title) if title == 'orphan_description' @@ -70,7 +88,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do end end - [TagDescription, TagDescriptionVersion].each do |model| + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| it "rolls back new description creation when #{ model.name } persistence fails" do before_state = stored_state aggregate_failures do @@ -81,6 +99,28 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do end end + it 'creates missing compatibility data without writing an ID-colliding Help page' do + description = TagDescription.create!( + id: 500, tag:, language_code: 'ja', body: 'before', version_no: 1) + help = WikiPage.create!(id: description.id, + tag_name: create(:tag_name, name: 'Help:unrelated'), + body: 'unrelated', created_user: user, updated_user: user) + Wiki::Commit.legacy_content!(page: help, body: help.body, created_user: user) + before_help = help.attributes + expect do + put "/wiki/#{ description.id }", params: { title: tag_name.name, body: 'after' }, + headers: headers + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(response).to have_http_status(:ok) + expect(description.reload).to have_attributes(id: 500, body: 'after') + expect(help.reload.attributes).to eq(before_help) + compatibility = WikiPage.find_by!(tag_name:) + expect(compatibility.body).to eq(description.body) + expect(compatibility.current_revision.body).to eq(description.body) + end + describe 'PUT /wiki/:id' do let!(:page) do tag @@ -144,10 +184,10 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do page_state = page.attributes description_state = description.attributes - update_description(body: 'before', title: 'Help:description_renamed') + update_description(body: 'before', title: 'description_renamed') expect(response).to have_http_status(:ok) - expect(tag_name.reload.name).to eq('Help:description_renamed') + expect(tag_name.reload.name).to eq('description_renamed') expect(WikiRevision.count).to eq(revision_count) expect(WikiVersion.count).to eq(wiki_version_count) expect(TagDescriptionVersion.count).to eq(description_version_count) diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb index bf48402..7498e9f 100644 --- a/backend/spec/requests/tag_group_wiki_write_spec.rb +++ b/backend/spec/requests/tag_group_wiki_write_spec.rb @@ -33,11 +33,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do end end - it 'creates the group and first snapshot without new Wiki records' do + it 'creates the group, snapshot and matching compatibility histories' do expect { create_group } - .to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:created) group = group_rows('tag_groups', name: '新規').sole expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規') @@ -45,6 +45,10 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do version = group_version(group.fetch('id'), 1) expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id) expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id) + page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' }) + expect(page.body).to eq('- 洗澡鹿') + expect(page.current_revision.body).to eq(page.body) + expect(page.wiki_versions.sole.body).to eq(page.body) end it 'keeps repeated placements but only one membership and one snapshot member' do @@ -292,7 +296,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do end tables = TagGroupRecords::GROUP_TABLES - tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] if operation == :update + tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] tables.each do |table| it "rolls back all writes after a real #{ table } write fails" do expect_atomic_group_write(table) { write_group(operation, headers) } diff --git a/backend/spec/requests/tag_group_write_integrity_spec.rb b/backend/spec/requests/tag_group_write_integrity_spec.rb index b2f75b9..98c2e5d 100644 --- a/backend/spec/requests/tag_group_write_integrity_spec.rb +++ b/backend/spec/requests/tag_group_write_integrity_spec.rb @@ -71,17 +71,16 @@ RSpec.describe 'Tag group write integrity', type: :request do expect(page.body).to eq('- 伊地知ニジカ') end - it 'updates Help with a colliding group ID without altering the group' do + it 'rejects a standalone Help update despite a colliding group ID' do page = create_help('Help body') id = seed_group(name: '別グループ', id: page.id, members:, placements:) group_before = TagGroup.find(id).attributes version_before = group_version(id, 1) - put_wiki(id, title: 'Help:改名', body: 'Help after') + expect_no_writes { put_wiki(id, title: 'Help:改名', body: 'Help after') } - expect(response).to have_http_status(:ok) - expect(page.reload.body).to eq('Help after') - expect(page.title).to eq('Help:改名') + expect(page.reload.body).to eq('Help body') + expect(page.title).to eq('Help:既存') expect(TagGroup.find(id).attributes).to eq(group_before) expect(group_version(id, 1)).to eq(version_before) end @@ -99,7 +98,8 @@ RSpec.describe 'Tag group write integrity', type: :request do it 'keeps a description update separate from a colliding group' do page = Wiki::Commit.create_content!( tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user) - id = seed_group(name: '更新', id: page.id, members:, placements:) + description = TagDescription.find_by!(tag_id: tag.id) + id = seed_group(name: '更新', id: description.id, members:, placements:) group_before = TagGroup.find(id).attributes put_wiki(id, title: '洗澡鹿', body: 'description after') @@ -116,18 +116,19 @@ RSpec.describe 'Tag group write integrity', type: :request do description = TagDescription.create!( id:, tag:, language_code: 'ja', version_no: 1, body: 'description') - put_wiki(id, title: 'Help:既存', body: 'Help after') + expect_no_writes { put_wiki(id, title: 'Help:既存', body: 'Help after') } - expect(response).to have_http_status(:ok) - expect(page.reload.body).to eq('Help after') + expect(page.reload.body).to eq('Help before') expect(description.reload.body).to eq('description') expect(TagGroup.find(id).version_no).to eq(1) - expect_no_writes { put_wiki(id, title: '洗澡鹿', body: 'description after') } + put_wiki(id, title: '洗澡鹿', body: 'description after') + expect(response).to have_http_status(:ok) + expect(description.reload.body).to eq('description after') put_wiki(id, title: 'タググループ:更新') expect(response).to have_http_status(:ok) expect(TagGroup.find(id).version_no).to eq(2) - expect(page.reload.body).to eq('Help after') - expect(description.reload.body).to eq('description') + expect(page.reload.body).to eq('Help before') + expect(description.reload.body).to eq('description after') end end @@ -139,9 +140,9 @@ RSpec.describe 'Tag group write integrity', type: :request do expect do put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:ok) expect(TagGroup.find(id).name).to eq('改名') diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb index d51eb16..d910dfb 100644 --- a/backend/spec/requests/tag_groups_spec.rb +++ b/backend/spec/requests/tag_groups_spec.rb @@ -391,18 +391,18 @@ RSpec.describe 'Tag group native API', type: :request do end end - it 'creates native state without adding compatibility Wiki records' do + it 'creates native state and its compatibility histories' do expect { native_write(:create) } - .to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:created) expect_group_snapshot( json.fetch('id'), number: 1, members: snapshot_members, placements:, description: '全体の説明') end - [:update].each do |operation| + [:create, :update].each do |operation| it "synchronises Wiki current state and histories on native #{ operation }" do native_write(operation) expect(response).to have_http_status(operation == :create ? :created : :ok) diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb index fb99dc7..8aebaa6 100644 --- a/backend/spec/requests/wiki_conflict_spec.rb +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -11,7 +11,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do it 'returns 409 when base_revision_id is stale' do page = seed_wiki_page( - tag_name: create(:tag_name, name: 'wiki_conflict_request'), + tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'), body: 'first', created_by_user: user, message: 'init') @@ -26,12 +26,10 @@ RSpec.describe 'Wiki conflict handling', type: :request do base_revision_id: stale_id) put "/wiki/#{ page.id }", - params: { - title: 'wiki_conflict_request', - body: 'third', - message: 'stale', - base_revision_id: stale_id, - }, + params: { title: 'wiki_conflict_request', + body: 'third', + message: 'stale', + base_revision_id: stale_id }, headers: auth_headers(user) expect(response).to have_http_status(:conflict) diff --git a/backend/spec/requests/wiki_creation_freeze_spec.rb b/backend/spec/requests/wiki_creation_freeze_spec.rb index e5f000a..96e4af4 100644 --- a/backend/spec/requests/wiki_creation_freeze_spec.rb +++ b/backend/spec/requests/wiki_creation_freeze_spec.rb @@ -7,17 +7,21 @@ RSpec.describe 'Wiki creation freeze', type: :request do let!(:user) { create_member_user! } let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } - it 'updates an independent group without implicitly creating a legacy page' do + it 'creates missing compatibility history when updating an independent group' do id = seed_group(name: 'independent', description: 'before') expect do put "/tag_groups/#{ id }", params: { name: 'independent', description: 'after', members: [], placements: [] }, headers:, as: :json - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:ok) expect(TagGroup.find(id).description).to eq('after') + page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:independent' }) + expect(page.body).to include('after') + expect(page.current_revision.body).to eq(page.body) + expect(page.wiki_versions.sole.body).to eq(page.body) end context 'existing description write protection' do diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb index 0a12c73..60fda7f 100644 --- a/backend/spec/requests/wiki_history_integrity_spec.rb +++ b/backend/spec/requests/wiki_history_integrity_spec.rb @@ -11,7 +11,7 @@ RSpec.describe 'Wiki history integrity', type: :request do def create_wiki_page title:, body: 'body', message: 'init', user: self.user seed_wiki_page( - tag_name: create(:tag_name, name: title), + tag_name: create(:tag, primary_name: title).tag_name('ja'), body:, created_by_user: user, message:) diff --git a/backend/spec/requests/wiki_index_order_spec.rb b/backend/spec/requests/wiki_index_order_spec.rb new file mode 100644 index 0000000..c5ce7e9 --- /dev/null +++ b/backend/spec/requests/wiki_index_order_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe 'Wiki resource ordering', type: :request do + include TagGroupRecords + let(:user) { create_member_user! } + + it 'selects the latest twenty across both resources before applying the global limit' do + start = Time.utc(2026, 1, 1) + descriptions = 24.times.map do |index| + tag = create(:tag, primary_name: "rank_#{ index }") + TagDescription.create!(tag:, language_code: 'ja', body: 'current', version_no: 1, + updated_at: start + index.hours) + end + groups = [24, 25].map do |hour| + group = TagGroup.find(seed_group(name: "rank_group_#{ hour }")) + group.update_columns(updated_at: start + hour.hours) + group + end + + get '/wiki', params: { title: 'rank' } + + expect(response).to have_http_status(:ok) + expected = groups.reverse.map { ['tag_group', _1.id] } + + descriptions.last(18).reverse.map { ['tag_description', _1.id] } + expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] }).to eq(expected) + end + + it 'breaks timestamp ties by resource kind and ID while retaining legacy overlaps' do + time = Time.utc(2026, 1, 1) + tag = create(:tag, primary_name: 'tied') + TagDescription.create!(id: 51, tag:, language_code: 'ja', body: 'current', + version_no: 1, updated_at: time) + group = TagGroup.find(seed_group(name: 'tied', id: 51)) + group.update_columns(updated_at: time) + + 2.times do + get '/wiki', params: { title: 'tied' } + expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] }) + .to eq([['tag_description', 51], ['tag_group', 51]]) + end + end +end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 012b919..377f851 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -129,7 +129,7 @@ RSpec.describe 'Wiki API', type: :request do { 'X-Transfer-Code' => user.inheritance_code } end - let!(:test_tag_name) { create(:tag_name, name: 'TestPage') } + let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') } let!(:page) do seed_wiki_page( diff --git a/backend/spec/requests/wiki_unmigrated_contract_spec.rb b/backend/spec/requests/wiki_unmigrated_contract_spec.rb index 26f21a9..e60c8e0 100644 --- a/backend/spec/requests/wiki_unmigrated_contract_spec.rb +++ b/backend/spec/requests/wiki_unmigrated_contract_spec.rb @@ -9,29 +9,29 @@ RSpec.describe 'Unmigrated Wiki compatibility', type: :request do end let!(:first_revision) { page.current_revision } let!(:last_revision) do - Wiki::Commit.content!(page:, body: 'after', created_user: user, - message: 'legacy edit', base_revision_id: first_revision.id) + Wiki::Commit.legacy_content!(page:, body: 'after', created_user: user, + message: 'legacy edit', base_revision_id: first_revision.id) end before { expect(TagDescription.exists?(id: page.id)).to be(false) } - it 'keeps a Tag-linked legacy page in the legacy domain when its title changes' do + it 'rejects standalone updates even after a Tag is linked to the legacy page' do create(:tag, primary_tag_name: tag_name) + original = page.reload.attributes expect do put "/wiki/#{ page.id }", params: { title: 'legacy_tag_renamed', body: 'changed legacy body', message: 'legacy rename', base_revision_id: last_revision.id }, headers: { 'X-Transfer-Code' => user.inheritance_code } - end.to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + end.to change(WikiRevision, :count).by(0) + .and change(WikiVersion, :count).by(0) .and change(TagDescription, :count).by(0) .and change(TagDescriptionVersion, :count).by(0) - expect(response).to have_http_status(:ok) - expect(page.reload).to have_attributes(body: 'changed legacy body') - expect(page.title).to eq('legacy_tag_renamed') - expect(page.current_revision).to have_attributes(message: 'legacy rename') + expect(response).to have_http_status(:unprocessable_entity) + expect(page.reload.attributes).to eq(original) + expect(page.current_revision).to have_attributes(message: 'legacy edit') end include TagGroupRecords diff --git a/backend/spec/services/shared_resource_id_allocator_spec.rb b/backend/spec/services/shared_resource_id_allocator_spec.rb new file mode 100644 index 0000000..431af2e --- /dev/null +++ b/backend/spec/services/shared_resource_id_allocator_spec.rb @@ -0,0 +1,65 @@ +require 'rails_helper' + +RSpec.describe SharedResourceIdAllocator do + let(:record) { build(:external_tag) } + let(:connection) { double('connection') } + let(:transaction) { double('transaction', open?: true) } + let(:callbacks) { { } } + let(:sql) { [] } + + before do + config = double('config', database: 'test_shared_ids') + allow(connection).to receive_message_chain(:pool, :db_config).and_return(config) + allow(connection).to receive(:quote) { "'#{ _1 }'" } + allow(connection).to receive(:quote_table_name) { "`#{ _1 }`" } + allow(connection).to receive(:select_value) do |statement| + sql << statement + if statement.include?('GET_LOCK') || statement.include?('RELEASE_LOCK') + 1 + elsif statement.include?('`tags`') + 8 + else + 3 + end + end + allow(ExternalTag).to receive(:connection).and_return(connection) + allow(ExternalTag).to receive(:current_transaction).and_return(transaction) + allow(ExternalTag).to receive(:uncached).and_yield + [:after_commit, :after_rollback].each do |event| + allow(transaction).to receive(event) { |&callback| callbacks[event] = callback } + end + end + + [:after_commit, :after_rollback].each do |event| + it "holds the shared lock until #{ event }" do + described_class.assign!(record) + expect(record.id).to eq(9) + expect(sql.grep(/RELEASE_LOCK/)).to be_empty + expect(sql.grep(/FOR UPDATE/).size).to eq(2) + callbacks.fetch(event).call + expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) + end + end + + it 'does not silently proceed after an acquisition timeout' do + allow(connection).to receive(:select_value).and_return(0) + expect { described_class.assign!(record) }.to raise_error(ActiveRecord::LockWaitTimeout) + expect(record.id).to be_nil + expect(callbacks).to be_empty + end + + it 'releases a successfully acquired lock when allocation fails and rolls back' do + allow(connection).to receive(:select_value).with(/FOR UPDATE/).and_raise('query failed') + expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed') + callbacks.fetch(:after_rollback).call + expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) + end + + it 'preserves explicitly supplied historical IDs' do + record.id = 5 + described_class.assign!(record) + expect(record.id).to eq(5) + expect(sql.grep(/FOR UPDATE/)).to be_empty + callbacks.fetch(:after_commit).call + end +end diff --git a/backend/spec/services/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb index 637e7c6..e58a60c 100644 --- a/backend/spec/services/wiki/commit_integrity_spec.rb +++ b/backend/spec/services/wiki/commit_integrity_spec.rb @@ -7,29 +7,37 @@ RSpec.describe Wiki::Commit do def create_page title:, body: 'initial body' seed_wiki_page( - tag_name: create(:tag_name, name: title), + tag_name: create(:tag, primary_name: title).tag_name('ja'), body:, created_by_user: user, message: 'init') end describe '.create_content!' do - [:create_content!, :create_legacy_content!].each do |operation| - it "freezes #{ operation } without adding page or history" do - name = create(:tag_name, name: 'Help:frozen_service') - counts = [WikiPage.count, WikiRevision.count, WikiVersion.count] - begin - described_class.public_send(operation, tag_name: name, body: 'initial', - created_by_user: user) - rescue ActiveRecord::RecordInvalid - # A validation rejection is allowed; persistence is not. - end - expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts) - end + it 'rejects standalone Help creation without adding page or history' do + name = create(:tag_name, name: 'Help:frozen_service') + counts = [WikiPage.count, WikiRevision.count, WikiVersion.count] + expect do + described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user) + end.to raise_error(ActiveRecord::RecordInvalid) + expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts) + end + + it 'keeps the internal compatibility writer available' do + name = create(:tag_name, name: 'タググループ:compatibility_service') + page = nil + expect do + page = described_class.create_legacy_content!( + tag_name: name, body: 'initial', created_by_user: user) + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(page.current_revision.body).to eq('initial') + expect(page.wiki_versions.sole.body).to eq('initial') end it 'rejects body that becomes blank after normalisation' do - tag_name = create(:tag_name, name: 'commit_integrity_blank') + tag_name = create(:tag, primary_name: 'commit_integrity_blank').tag_name('ja') expect { described_class.create_content!( @@ -45,6 +53,17 @@ RSpec.describe Wiki::Commit do end describe '.content!' do + it 'rejects standalone Help updates without modifying histories' do + page = seed_wiki_page(tag_name: create(:tag_name, name: 'Help:standalone'), + body: 'before', created_by_user: user) + counts = [WikiRevision.count, WikiVersion.count] + expect do + described_class.content!(page:, body: 'after', created_user: user) + end.to raise_error(ActiveRecord::RecordInvalid) + expect(page.reload.body).to eq('before') + expect([WikiRevision.count, WikiVersion.count]).to eq(counts) + end + it 'updates page body, revision, and version' do page = create_page(title: 'commit_integrity_update', body: 'before') current_id = page.current_revision.id diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb index f540a86..6acd297 100644 --- a/backend/spec/services/wiki/commit_spec.rb +++ b/backend/spec/services/wiki/commit_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Wiki::Commit do let(:user) { create_member_user! } def create_page(title: 'commit_spec_page', body: 'initial body') - tag_name = create(:tag_name, name: title) + tag_name = create(:tag, primary_name: title).tag_name('ja') seed_wiki_page( tag_name:, diff --git a/frontend/scripts/help-assets.ts b/frontend/scripts/help-assets.ts index 2b21312..157d0aa 100644 --- a/frontend/scripts/help-assets.ts +++ b/frontend/scripts/help-assets.ts @@ -43,7 +43,9 @@ const validateEdition = (edition: string): void => { } -const generateHelpAssets = async (root: string) => { +export const generateHelpAssets = async ( + root: string, +): Promise<{ modules: Map; files: string[] }> => { const modules = new Map () const files: string[] = [root] const articles: Record = Object.create (null) diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 623f505..5980ebc 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -90,7 +90,9 @@ export const menuOutline = ( { name: '検索', to: '/help' }, { name: '全体履歴', to: '/help/changes' }, { component: , visible: helpVsbl }, - { name: '履歴', to: `/help/changes?identifier=${ helpIdentifier }`, visible: helpVsbl }] }, + { name: '履歴', + to: `/help/changes?identifier=${ helpIdentifier }`, + visible: helpVsbl }] }, { name: 'おたのしみ', visible: false, subMenu: [ { name: '上映会 (β)', to: '/theatres/1' }, { name: 'グカネータ (β)', to: '/gekanator' }] }, diff --git a/frontend/src/lib/help.test.ts b/frontend/src/lib/help.test.ts index efecaa8..d747cb2 100644 --- a/frontend/src/lib/help.test.ts +++ b/frontend/src/lib/help.test.ts @@ -6,51 +6,58 @@ import { createHelpFixture } from '@/test/helpFixtures' describe ('Help file catalogue', () => { it ('uses current_edition rather than the newest file, without YAML titles', async () => { const { api } = createHelpFixture () - expect (api.getHelpArticle ('guide')?.languages.ja.currentEdition).toBe ('2026-10-02') + expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition) + .toBe ('2026-10-02.10') expect (await api.getHelpSearchEntries ('ja')).toEqual ([ - { identifier: 'guide', edition: '2026-10-02', - title: 'ホーム', body: '\nタグを検索する' }, + { identifier: 'guide', edition: '2026-10-02.10', + title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' }, { identifier: '設定 手順', edition: '2026-10-03', title: '設定', body: '\nタグを設定する' }]) }) - it ('orders same-day editions numerically and separates languages', () => { + it ('orders published editions numerically and separates languages', async () => { const { api } = createHelpFixture () - expect (api.getHelpEditions ('guide', 'ja')).toEqual ([ + expect (await api.getHelpEditions ('guide', 'ja')).toEqual ([ '2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']) - expect (api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02']) + expect (await api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02']) + expect (await api.getHelpPublishedEditions ('guide', 'ja')) + .not.toContain ('2026-10-02.11') }) - it ('derives each edition title from its first H1', async () => { + it ('uses generated edition titles without reparsing raw sources', async () => { const { api, raw } = createHelpFixture () expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01')) .toEqual ({ title: '旧ホーム', body: '\n過去限定本文' }) raw['../../help/guide/editions/ja/2026-10-02.mdx'] .mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し') expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-02')) - .toEqual ({ title: '改題', body: '\r\n本文\r\n# 別見出し' }) + .toEqual ({ title: 'ホーム', body: '\nタグを検索する' }) + expect (raw['../../help/guide/editions/ja/2026-10-02.mdx']).not.toHaveBeenCalled () + expect ((await api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))?.source) + .toBe ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し') }) it ('returns missing resources without substituting another edition', async () => { const { api } = createHelpFixture () - expect (api.getHelpArticle ('absent')).toBeNull () - expect (api.getHelpEditions ('absent', 'ja')).toEqual ([]) + expect (await api.getHelpArticle ('absent')).toBeNull () + expect (await api.getHelpEditions ('absent', 'ja')).toEqual ([]) expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull () expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull () }) - it ('rejects a missing current file instead of silently hiding an article', async () => { - const { api, raw } = createHelpFixture () - delete raw['../../help/guide/editions/ja/2026-10-02.mdx'] - await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow () + it ('reports generated search asset failures instead of hiding articles', async () => { + const { api, loads } = createHelpFixture () + loads.search.mockImplementationOnce (() => { throw new Error ('asset failure') }) + await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('asset failure') }) - it ('reports raw loading failures and missing leading H1', async () => { + it ('reports raw loading failures and excludes unpublished editions', async () => { const { api, raw } = createHelpFixture () const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx'] loader.mockRejectedValueOnce (new Error ('fixture failure')) - await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('fixture failure') - loader.mockResolvedValueOnce ('本文\n# 遅い見出し') - await expect (api.getHelpEditionText ('guide', 'ja', '2026-10-02')).rejects.toThrow () + await expect (api.getHelpEditionSource ('guide', 'ja', '2026-10-02')) + .rejects.toThrow ('fixture failure') + expect (await api.getHelpEdition ('guide', 'ja', '2026-10-02.11')).toBeNull () + expect (await api.getHelpEditionSource ('guide', 'ja', '2026-10-02.11')).toBeNull () }) }) diff --git a/frontend/src/lib/helpPrefetch.test.tsx b/frontend/src/lib/helpPrefetch.test.tsx index ae77d96..537175a 100644 --- a/frontend/src/lib/helpPrefetch.test.tsx +++ b/frontend/src/lib/helpPrefetch.test.tsx @@ -23,11 +23,13 @@ const Location = () => { describe ('Help prefetch integration', () => { beforeEach (() => { + helpFixture.reset () settings.linkPreload = 'intent' vi.clearAllMocks () }) - it.each (['/help', '/help/guide', '/help/changes', + it.each (['/help', '/help?title=ホーム&text=検索&page=2', '/help/guide', '/help/changes', + '/help/guide/diff?from=2026-10-01&to=2026-10-02.10', '/help/%E8%A8%AD%E5%AE%9A%20%E6%89%8B%E9%A0%86?edition=2026-10-03']) ( 'registers a unique prefetcher for %s', path => { const url = new URL (path, window.location.origin) @@ -43,13 +45,13 @@ describe ('Help prefetch integration', () => { }/> ) - const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.mdx'] + const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx'] await Promise.all ([ prefetchers.prefetchForURL (queryClient, '/help/guide'), prefetchers.prefetchForURL (queryClient, '/help/guide')]) expect (loader).toHaveBeenCalledTimes (1) fireEvent.click (screen.getByRole ('link', { name: 'Help' })) - await screen.findByRole ('heading', { name: 'ホーム' }) + await screen.findByRole ('heading', { name: 'ホーム第十版' }) expect (loader).toHaveBeenCalledTimes (1) }) @@ -69,11 +71,12 @@ describe ('Help prefetch integration', () => { const article = vi.spyOn (helpFixture.api, 'getHelpArticle') await prefetchers.prefetchForURL (new QueryClient (), '/help/changes') expect (article).not.toHaveBeenCalledWith ('changes') - expect (helpFixture.raw['../../help/guide/editions/ja/2026-10-01.mdx']) - .toHaveBeenCalled () + expect (helpFixture.loads.changes).toHaveBeenCalledTimes (1) + Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ()) + Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ()) }) - it.each (['intent', 'off', 'click']) ('respects %s intent settings and still prefetches clicks', + it.each (['intent', 'off']) ('respects %s intent settings and still prefetches clicks', async mode => { settings.linkPreload = mode const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue () diff --git a/frontend/src/mdx-components.test.tsx b/frontend/src/mdx-components.test.tsx index 248a16f..a4ff0b6 100644 --- a/frontend/src/mdx-components.test.tsx +++ b/frontend/src/mdx-components.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, screen, within } from '@testing-library/react' -import { afterAll, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { Route, Routes } from 'react-router-dom' import HelpDetailPage from '@/pages/help/HelpDetailPage' @@ -21,6 +21,8 @@ const tableSource = [ describe ('Help MDX with the application compiler', () => { + beforeEach (() => helpFixture.reset ()) + it ('compiles GFM table structure, links and emphasis', async () => { const Content = await compileMdx (tableSource) renderWithProviders () @@ -44,7 +46,8 @@ describe ('Help MDX with the application compiler', () => { it ('keeps tables inside the Help dark-mode typography container', async () => { const Content = await compileMdx (tableSource) - vi.spyOn (helpFixture.api, 'getHelpEdition').mockResolvedValue (Content) + helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx'] + .mockResolvedValue ({ default: Content }) renderWithProviders ( }/>, { route: '/help/guide' }) @@ -52,9 +55,12 @@ describe ('Help MDX with the application compiler', () => { expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert') }) - // jsdom cannot measure overflow. Requiring a particular Tailwind class rejects - // valid CSS layouts, including the existing overflow-y-auto container. - it.todo ('keeps wide tables within the viewport in a browser layout test') + it ('preserves long cell contents when rendering a wide table', async () => { + const cell = 'wide'.repeat (100) + const Content = await compileMdx (`| 項目 |\n| --- |\n| ${ cell } |`) + renderWithProviders () + expect (screen.getByRole ('cell')).toHaveTextContent (cell) + }) it.each (['.mdx?raw', '.mdx?url']) ('preserves the %s transform bypass', async suffix => { expect (await transformMdx (tableSource, suffix)).toBeNull () diff --git a/frontend/src/pages/help/HelpChangesPage.test.tsx b/frontend/src/pages/help/HelpChangesPage.test.tsx index 5d6ecdc..eead8ba 100644 --- a/frontend/src/pages/help/HelpChangesPage.test.tsx +++ b/frontend/src/pages/help/HelpChangesPage.test.tsx @@ -1,10 +1,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import { HelmetProvider } from 'react-helmet-async' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { apiGet } from '@/lib/api' import App from '@/App' +import { helpFixture } from '@/test/helpFixtures' vi.hoisted (() => { window.history.replaceState (null, '', '/help/changes') @@ -20,6 +21,8 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({ describe ('Help global edition history', () => { + beforeEach (() => helpFixture.reset ()) + it ('routes /help/changes to published editions in date and numeric order', async () => { const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } }) const { container } = render ( @@ -35,6 +38,15 @@ describe ('Help global edition history', () => { '/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02', '/help/guide?edition=2026-10-01']) expect (container).toHaveTextContent ('旧ホーム') + expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent)) + .toEqual (['差分', 'タイトル', '有効開始年月日']) + expect (container).not.toHaveTextContent ('未公開ホーム') + expect (screen.getAllByRole ('link', { name: '差分' }).map (link => + link.getAttribute ('href'))).toEqual ([ + '/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10', + '/help/guide/diff?from=2026-10-02.2&to=2026-10-02.3', + '/help/guide/diff?from=2026-10-02&to=2026-10-02.2', + '/help/guide/diff?from=2026-10-01&to=2026-10-02']) expect (apiGet).not.toHaveBeenCalled () expect (window.location.pathname).toBe ('/help/changes') }) diff --git a/frontend/src/pages/help/HelpDetailPage.test.tsx b/frontend/src/pages/help/HelpDetailPage.test.tsx index ce31007..ef80d88 100644 --- a/frontend/src/pages/help/HelpDetailPage.test.tsx +++ b/frontend/src/pages/help/HelpDetailPage.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { Route, Routes, useLocation, useNavigate } from 'react-router-dom' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import HelpDetailPage from '@/pages/help/HelpDetailPage' import { helpFixture } from '@/test/helpFixtures' @@ -35,12 +35,15 @@ const renderDetail = (route = '/help/guide') => renderWithProviders ( describe ('Help details', () => { + beforeEach (() => helpFixture.reset ()) + it ('displays current_edition H1 and body even when newer files exist', async () => { renderDetail () - expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument () - expect (screen.getByText ('タグを検索する')).toBeInTheDocument () - expect (screen.queryByText ('第十版本文')).not.toBeInTheDocument () - await waitFor (() => expect (document.title).toContain ('ホーム ヘルプ')) + expect (await screen.findByRole ('heading', { name: 'ホーム第十版' })) + .toBeInTheDocument () + expect (screen.getByText ('タグを検索する 第十版本文')).toBeInTheDocument () + expect (screen.queryByText ('未公開限定本文')).not.toBeInTheDocument () + await waitFor (() => expect (document.title).toContain ('ホーム第十版 ヘルプ')) }) it ('uses the historical H1 in both page and document titles', async () => { @@ -66,7 +69,8 @@ describe ('Help details', () => { expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument () }) - it.each (['/help/missing', '/help/guide?edition=missing']) ( + it.each (['/help/missing', '/help/guide?edition=missing', + '/help/guide?edition=2026-10-02.11']) ( 'shows NotFound without redirecting %s', async route => { renderDetail (route) expect (await screen.findByText ('404')).toBeInTheDocument () @@ -81,9 +85,9 @@ describe ('Help details', () => { helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx'] .mockReturnValueOnce (pending) renderDetail () - await screen.findByRole ('heading', { name: 'ホーム' }) + await screen.findByRole ('heading', { name: 'ホーム第十版' }) fireEvent.click (screen.getByRole ('button', { name: 'switch article' })) - expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument () + expect (screen.queryByText ('タグを検索する 第十版本文')).not.toBeInTheDocument () await waitFor (() => expect (document.title).not.toContain ('ホーム')) await act (async () => finish ({ default: () =>

設定

})) await screen.findByRole ('heading', { name: '設定' }) @@ -95,9 +99,10 @@ describe ('Help details', () => { const pending = new Promise<{ default: () => ReactElement }> (resolve => { finish = resolve }) - helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx'] - .mockReturnValueOnce (pending) + const loader = helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx'] + loader.mockReturnValueOnce (pending) renderDetail () + await waitFor (() => expect (loader).toHaveBeenCalled ()) fireEvent.click (screen.getByRole ('button', { name: 'switch edition' })) await screen.findByRole ('heading', { name: 'ホーム第三版' }) await act (async () => finish ({ default: () =>

遅延した旧本文

})) diff --git a/frontend/src/pages/help/HelpListPage.test.tsx b/frontend/src/pages/help/HelpListPage.test.tsx index 1861cbb..16a425f 100644 --- a/frontend/src/pages/help/HelpListPage.test.tsx +++ b/frontend/src/pages/help/HelpListPage.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import HelpListPage from '@/pages/help/HelpListPage' import { helpFixture } from '@/test/helpFixtures' @@ -9,23 +9,25 @@ vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFi describe ('Help search', () => { + beforeEach (() => helpFixture.reset ()) + it ('initially lists each current article once with encoded detail links', async () => { renderWithProviders (, { route: '/help' }) - expect (await screen.findByRole ('link', { name: 'ホーム' })) + expect (await screen.findByRole ('link', { name: 'ホーム第十版' })) .toHaveAttribute ('href', '/help/guide') expect (screen.getByRole ('link', { name: '設定' })) .toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`) expect (screen.getByText ('2 件')).toBeInTheDocument () expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument () - expect (screen.queryByText ('ホーム第十版')).not.toBeInTheDocument () + expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument () }) it.each ([ - ['ホーム', '', ['ホーム']], - ['', '検索', ['ホーム']], - ['', 'タグ', ['ホーム', '設定']], + ['ホーム', '', ['ホーム第十版']], + ['', '検索', ['ホーム第十版']], + ['', 'タグ', ['設定', 'ホーム第十版']], ['ホーム', '設定', []], - ['ホーム', '検索', ['ホーム']], + ['ホーム', '検索', ['ホーム第十版']], ['旧ホーム', '', []], ['', '過去限定本文', []]]) ( 'searches title %s and body %s together', async (title, body, expected) => { @@ -43,10 +45,11 @@ describe ('Help search', () => { }) it ('displays loading failures without stale results', async () => { - helpFixture.raw['../../help/guide/editions/ja/2026-10-02.mdx'] - .mockRejectedValueOnce (new Error ('fixture loading failed')) + helpFixture.loads.search.mockImplementationOnce (() => { + throw new Error ('fixture loading failed') + }) renderWithProviders () expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed') - expect (screen.queryByRole ('link', { name: 'ホーム' })).not.toBeInTheDocument () + expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument () }) }) diff --git a/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx b/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx index 15deadb..4d05045 100644 --- a/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx +++ b/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx @@ -6,7 +6,7 @@ import { apiGet } from '@/lib/api' import HelpDetailPage from '@/pages/help/HelpDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import { buildWikiPage } from '@/test/factories' -import { editionRows, helpFixture } from '@/test/helpFixtures' +import { helpFixture } from '@/test/helpFixtures' import { renderWithProviders } from '@/test/render' vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () })) @@ -38,10 +38,7 @@ const renderWiki = (title: string) => { describe ('Legacy Wiki to Help compatibility', () => { beforeEach (() => { - editionRows.forEach (([id, language, edition, title, body]) => { - helpFixture.raw[`../../help/${id}/editions/${language}/${edition}.mdx`] - .mockReset ().mockResolvedValue (`# ${title}\n\n${body}`) - }) + helpFixture.reset () vi.mocked (apiGet).mockReset () vi.mocked (apiGet).mockImplementation (async path => { if (path === '/wiki') @@ -57,31 +54,41 @@ describe ('Legacy Wiki to Help compatibility', () => { await screen.findByText ('Wiki の現行本文') expect (screen.getByTestId ('location')).toHaveTextContent (route) expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument () + expect (helpFixture.loads.titles).not.toHaveBeenCalled () }) - it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム']) ( + it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム', 'ホーム第十版']) ( 'redirects a unique current or historical Japanese title: %s', async title => { renderWiki (title) await waitFor (() => { expect (screen.getByTestId ('location')).toHaveTextContent ('/help/guide') }) - expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument () + expect (await screen.findByRole ('heading', { name: 'ホーム第十版' })) + .toBeInTheDocument () + Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ()) expect (apiGet).toHaveBeenCalledWith ( `/wiki/title/${encodeURIComponent (title)}`, expect.anything ()) }) it ('keeps the original URL and displays NotFound when no title matches', async () => { - const { route } = renderWiki ('該当なし') + const { route, queryClient } = renderWiki ('該当なし') await screen.findByText ('404') + await waitFor (() => { + expect (helpFixture.loads.titles).toHaveBeenCalled () + expect (queryClient.isFetching ()).toBe (0) + }) expect (screen.getByTestId ('location')).toHaveTextContent (route) expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument () }) it ('does not choose between articles with the same title', async () => { - vi.spyOn (helpFixture.raw, '../../help/設定 手順/editions/ja/2026-10-03.mdx') - .mockResolvedValue ('# ホーム\n\n別の記事') - const { route } = renderWiki ('ホーム') + helpFixture.assets.titles.default.ja['ホーム'] = ['guide', '設定 手順'] + const { route, queryClient } = renderWiki ('ホーム') await screen.findByText ('404') + await waitFor (() => { + expect (helpFixture.loads.titles).toHaveBeenCalled () + expect (queryClient.isFetching ()).toBe (0) + }) expect (screen.getByTestId ('location')).toHaveTextContent (route) }) @@ -94,5 +101,6 @@ describe ('Legacy Wiki to Help compatibility', () => { }) expect (screen.getByTestId ('location')).toHaveTextContent (route) expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument () + expect (helpFixture.loads.titles).not.toHaveBeenCalled () }) }) diff --git a/frontend/src/pages/wiki/WikiSearchPage.test.tsx b/frontend/src/pages/wiki/WikiSearchPage.test.tsx index e831035..7619f66 100644 --- a/frontend/src/pages/wiki/WikiSearchPage.test.tsx +++ b/frontend/src/pages/wiki/WikiSearchPage.test.tsx @@ -1,62 +1,58 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiGet } from '@/lib/api' import WikiSearchPage from '@/pages/wiki/WikiSearchPage' import { buildWikiPage } from '@/test/factories' import { renderWithProviders } from '@/test/render' -const api = vi.hoisted (() => ({ - apiGet: vi.fn (), -})) +vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () })) -vi.mock ('@/lib/api', () => api) -describe ('WikiSearchPage', () => { +describe ('Wiki resource identity', () => { + beforeEach (() => vi.mocked (apiGet).mockReset ()) + it ('loads initial wiki pages and renders links', async () => { - api.apiGet.mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })]) - + vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })]) renderWithProviders () - - expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute ( - 'href', - '/wiki/%E8%99%B9%E5%A4%8F', - ) - expect (api.apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } }) + expect (await screen.findByRole ('link', { name: '虹夏' })) + .toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F') + expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } }) }) it ('searches by title on submit', async () => { - api.apiGet - .mockResolvedValueOnce ([]) - .mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })]) - + vi.mocked (apiGet).mockResolvedValueOnce ([]) + .mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })]) renderWithProviders () - + await waitFor (() => expect (apiGet).toHaveBeenCalledTimes (1)) fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } }) fireEvent.click (screen.getByRole ('button', { name: '検索' })) - - await waitFor (() => { - expect (api.apiGet).toHaveBeenLastCalledWith ( - '/wiki', - { params: { title: '検索' } }, - ) - }) + await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith ( + '/wiki', { params: { title: '検索' } })) expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument () }) it ('marks deprecated wiki tags in the result title', async () => { - api.apiGet.mockResolvedValueOnce ([ - buildWikiPage ({ - title: '旧タグ', - deprecatedAt: '2026-06-01T00:00:00.000Z', - }), - ]) + vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ + title: '旧タグ', deprecatedAt: '2026-06-01T00:00:00.000Z' })]) + renderWithProviders () + expect (await screen.findByRole ('link', { name: '旧タグ' })).toBeInTheDocument () + expect (screen.getByText ('(廃止)').closest ('a')).toBeNull () + }) + it ('renders colliding description and group IDs with stable distinct keys', async () => { + const errors = vi.spyOn (console, 'error').mockImplementation (() => {}) + const description = buildWikiPage ({ id: 51, title: '説明', resourceType: 'tag_description' }) + const group = buildWikiPage ({ id: 51, title: 'タググループ:一覧', resourceType: 'tag_group' }) + vi.mocked (apiGet).mockResolvedValue ([description, group]) renderWithProviders () - const link = await screen.findByRole ('link', { name: '旧タグ' }) - const marker = screen.getByText ('(廃止)') - - expect (link).toBeInTheDocument () - expect (marker.closest ('a')).toBeNull () + await screen.findByRole ('link', { name: '説明' }) + expect (screen.getByRole ('link', { name: 'タググループ:一覧' })) + .toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`) + await waitFor (() => { + expect (errors.mock.calls.some (args => args.some (value => + String (value).includes ('same key')))).toBe (false) + }) }) }) diff --git a/frontend/src/pages/wiki/WikiSearchPage.tsx b/frontend/src/pages/wiki/WikiSearchPage.tsx index b7b671d..3bb9465 100644 --- a/frontend/src/pages/wiki/WikiSearchPage.tsx +++ b/frontend/src/pages/wiki/WikiSearchPage.tsx @@ -81,7 +81,8 @@ const WikiSearchPage: FC = () => { {results.map (page => ( - + {page.title} diff --git a/frontend/src/test/factories.ts b/frontend/src/test/factories.ts index 2f1b279..cb4654c 100644 --- a/frontend/src/test/factories.ts +++ b/frontend/src/test/factories.ts @@ -59,6 +59,7 @@ export const buildUser = (overrides: Partial = {}): User => ({ }) export const buildWikiPage = (overrides: Partial = {}): WikiPage => ({ + resourceType: 'tag_description', id: 1, title: 'テストWiki', deprecatedAt: null, diff --git a/frontend/src/test/helpFixtures.tsx b/frontend/src/test/helpFixtures.tsx index 3abf5e6..5ae0ac7 100644 --- a/frontend/src/test/helpFixtures.tsx +++ b/frontend/src/test/helpFixtures.tsx @@ -1,7 +1,6 @@ import { createElement } from 'react' import { ModuleKind, ScriptTarget, transpileModule } from 'typescript' import { vi } from 'vitest' -import { parse } from 'yaml' import source from '@/lib/help.ts?raw' @@ -14,51 +13,113 @@ export const editionRows = [ ['guide', 'ja', '2026-10-02', 'ホーム', 'タグを検索する'], ['guide', 'ja', '2026-10-02.2', 'ホーム第二版', '第二版本文'], ['guide', 'ja', '2026-10-02.3', 'ホーム第三版', '第三版本文'], - ['guide', 'ja', '2026-10-02.10', 'ホーム第十版', '第十版本文'], + ['guide', 'ja', '2026-10-02.10', 'ホーム第十版', 'タグを検索する 第十版本文'], + ['guide', 'ja', '2026-10-02.11', '未公開ホーム', '未公開限定本文'], ['guide', 'en', '2026-10-02', 'Home', 'English body'], ['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const -const compiled = transpileModule (source.replaceAll ('import.meta', 'helpMeta'), { +const compiled = transpileModule (source, { compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText -// Replace Vite's file-discovery boundary, executing the actual Help module. +// Supply generated virtual modules, executing the actual runtime Help module. export const createHelpFixture = () => { - const articles: Record = { - '../../help/guide/article.yml': - 'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02\n' - + ' en:\n current_edition: 2026-10-02\n', - '../../help/設定 手順/article.yml': - 'identifier: 設定 手順\nlanguages:\n ja:\n current_edition: 2026-10-03\n' } + const articles: Record = { + guide: { identifier: 'guide', canonicalLanguage: 'ja', languages: { + ja: { currentEdition: '2026-10-02.10', publishedEditions: [ + '2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] }, + en: { currentEdition: '2026-10-02', publishedEditions: ['2026-10-02'] } } }, + '設定 手順': { identifier: '設定 手順', canonicalLanguage: 'ja', languages: { + ja: { currentEdition: '2026-10-03', publishedEditions: ['2026-10-03'] } } } } + const published = editionRows.filter (([id, language, edition]) => + articles[id].languages[language].publishedEditions.includes (edition)) const raw = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [ `../../help/${id}/editions/${language}/${edition}.mdx`, vi.fn (async () => `# ${title}\n\n${body}`)])) - const moduleLoads = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [ + const moduleLoads = Object.fromEntries (published.map (([id, language, edition, title, body]) => [ `../../help/${id}/editions/${language}/${edition}.mdx`, vi.fn (async (): Promise<{ default: () => ReactElement }> => ({ default: () => createElement ('div', null, createElement ('h1', null, title), createElement ('p', null, body)) }))])) // Dynamic imports reuse a module namespace even when called more than once. + const caches: Record ReactElement }> }> = { } const modules = Object.fromEntries (Object.entries (moduleLoads).map (([path, load]) => { - const cache: { promise?: ReturnType } = { } - return [path, vi.fn (() => cache.promise ??= load ())] + caches[path] = { } + return [path, vi.fn (() => caches[path].promise ??= load ())] })) - const glob = (pattern: string, options?: { query?: string }) => { - if (pattern.endsWith ('article.yml')) - return articles - if (pattern.endsWith ('*.mdx')) - return options?.query === '?raw' ? raw : modules - throw new Error (`Unexpected Help glob: ${pattern}`) - } + const texts = Object.fromEntries (published.map (([id, language, edition, title, body]) => [ + `${ id }/${ language }/${ edition }`, + vi.fn (async () => ({ default: { title, body: `\n${ body }` } }))])) + const components = Object.fromEntries (published.map (([id, language, edition]) => [ + `${ id }/${ language }/${ edition }`, + modules[`../../help/${ id }/editions/${ language }/${ edition }.mdx`]])) + const sources = Object.fromEntries (published.map (([id, language, edition]) => [ + `${ id }/${ language }/${ edition }`, + async () => ({ default: await raw[ + `../../help/${ id }/editions/${ language }/${ edition }.mdx`] () })])) + const search: Record = { } + const changes: Record = { } + const titles: Record = { } + published.forEach (([id, language, edition, title, body]) => { + const editions = articles[id].languages[language].publishedEditions + const index = editions.indexOf (edition) + const searchRows = search[language] ??= [] + if (articles[id].languages[language].currentEdition === edition) + searchRows.push ({ identifier: id, edition, title, body: `\n${ body }` }) + const rows = changes[language] ??= [] + rows.push ({ identifier: id, edition, title, + pred: editions[index - 1] ?? null, + change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] }) + const lookup = (titles[language] ??= { }) + const identifiers = lookup[title.trim ().normalize ('NFKC').toLowerCase ()] ??= [] + if (!(identifiers.includes (id))) + identifiers.push (id) + }) + Object.values (changes).forEach (rows => rows.sort ((a, b) => { + const [dateA, revisionA = '1'] = a.edition.split ('.') + const [dateB, revisionB = '1'] = b.edition.split ('.') + return dateB.localeCompare (dateA) || Number (revisionB) - Number (revisionA) + || a.identifier.localeCompare (b.identifier) + })) + const assets = { + articles: { default: articles }, search: { default: search }, + changes: { default: changes }, titles: { default: titles } } + const snapshot = JSON.stringify (assets) + const loaders = { components, sources, texts } + const loads = { + articles: vi.fn (() => assets.articles), search: vi.fn (() => assets.search), + changes: vi.fn (() => assets.changes), titles: vi.fn (() => assets.titles), + loaders: vi.fn (() => loaders) } const api = { } as typeof Help const requireFixture = (name: string) => { - if (name === 'yaml') - return { parse } + const key = name.replace ('virtual:help/', '') as keyof typeof loads + if (Object.hasOwn (loads, key)) + return loads[key] () throw new Error (`Unexpected Help dependency: ${name}`) } - new Function ('require', 'exports', 'helpMeta', compiled) ( - requireFixture, api, { glob }) - return { api, articles, raw, modules, moduleLoads } + new Function ('require', 'exports', compiled) (requireFixture, api) + const reset = () => { + Object.assign (assets, JSON.parse (snapshot)) + editionRows.forEach (([id, language, edition, title, body]) => { + const path = `../../help/${ id }/editions/${ language }/${ edition }.mdx` + raw[path].mockReset ().mockResolvedValue (`# ${ title }\n\n${ body }`) + }) + published.forEach (([id, language, edition, title, body]) => { + const path = `../../help/${ id }/editions/${ language }/${ edition }.mdx` + caches[path].promise = undefined + moduleLoads[path].mockClear () + modules[path].mockReset ().mockImplementation (() => + caches[path].promise ??= moduleLoads[path] ()) + texts[`${ id }/${ language }/${ edition }`].mockReset ().mockResolvedValue ({ + default: { title, body: `\n${ body }` } }) + }) + loads.articles.mockReset ().mockImplementation (() => assets.articles) + loads.search.mockReset ().mockImplementation (() => assets.search) + loads.changes.mockReset ().mockImplementation (() => assets.changes) + loads.titles.mockReset ().mockImplementation (() => assets.titles) + loads.loaders.mockReset ().mockImplementation (() => loaders) + } + return { api, assets, loads, raw, modules, moduleLoads, texts, reset } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2b9b718..1f9e959 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -452,6 +452,7 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha export type WikiPage = { id: number + resourceType: 'tag_description' | 'tag_group' title: string deprecatedAt: string | null createdUserId: number