このコミットが含まれているのは:
2026-10-12 20:10:04 +09:00
コミット 2e98ed02e3
41個のファイルの変更、624行の追加、244行の削除
+25 -30
ファイルの表示
@@ -9,7 +9,8 @@ class WikiPagesController < ApplicationController
TagDescription.where(language_code: 'ja') TagDescription.where(language_code: 'ja')
.includes(tag: :tag_names) .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? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions =
@@ -22,7 +23,9 @@ class WikiPagesController < ApplicationController
json = descriptions.map { |description| WikiPageRepr.tag_description(description) } json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) }) 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? json = json.first(20) if title.present?
render json: render json:
end end
@@ -99,7 +102,7 @@ class WikiPagesController < ApplicationController
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
'deprecated_at' => nil } 'deprecated_at' => nil }
else else
raise 'お前やりませんねぇスギ!' return head :not_found
end end
end end
@@ -141,7 +144,7 @@ class WikiPagesController < ApplicationController
page = page =
if title.include?(':') if title.include?(':')
raise 'お前やりませんねぇスギ!' return render_unprocessable_entity field: :title
else else
tag_name = TagName.find_by(language_code: 'ja', name: title) tag_name = TagName.find_by(language_code: 'ja', name: title)
return render_unprocessable_entity field: :title unless tag_name&.tag 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:) tag: tag_name.tag, body:, created_by_user: current_user, message:)
end 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 rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record render_validation_error e.record
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
@@ -180,44 +184,34 @@ class WikiPagesController < ApplicationController
return head :ok return head :ok
end end
page = WikiPage.find_by(id: params[:id]) return render_unprocessable_entity field: :title if title.include?(':')
if legacy_group_page?(page) 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 return render_unprocessable_entity field: :title
end 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 base_revision_id = params[:base_revision_id].presence
ApplicationRecord.transaction do ApplicationRecord.transaction do
page.lock! tag.lock!
old_title = tag_name.name
old_title = page.title if title != old_title
tag = page.tag_name.tag
if tag && title != old_title
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
end end
page.tag_name.update!(name: title) if title != old_title tag_name.update!(name: title) if title != old_title
message = params[:message].presence message = params[:message].presence
description = TagDescription.find_by(id: page.id) TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user,
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:) message:, base_revision_id:)
else
Wiki::Commit.content!(page:, body:, created_user: current_user,
message:, base_revision_id:)
end
if tag && title != old_title if title != old_title
tag.reload tag.reload
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end end
@@ -306,6 +300,7 @@ class WikiPagesController < ApplicationController
updated_at = version.created_at updated_at = version.created_at
render json: { render json: {
'id' => version.tag_group_id, 'id' => version.tag_group_id,
'resource_type' => 'tag_group',
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }", 'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
'deprecated_at' => nil, 'deprecated_at' => nil,
'body' => body, 'body' => body,
+2
ファイルの表示
@@ -1,4 +1,6 @@
class ExternalTag < ApplicationRecord class ExternalTag < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
enum :platform, nico: 'nico' enum :platform, nico: 'nico'
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys } validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
+2
ファイルの表示
@@ -2,6 +2,8 @@ require 'set'
class Tag < ApplicationRecord class Tag < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
class NicoTagNormalisationError < ArgumentError class NicoTagNormalisationError < ArgumentError
; ;
end end
+2
ファイルの表示
@@ -1,3 +1,5 @@
class TagDescription < ApplicationRecord class TagDescription < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
belongs_to :tag belongs_to :tag
end end
+2
ファイルの表示
@@ -1,4 +1,6 @@
class TagGroup < ApplicationRecord class TagGroup < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true
+2 -1
ファイルの表示
@@ -13,6 +13,7 @@ module WikiPageRepr
def tag_description description def tag_description description
tag = description.tag tag = description.tag
{ 'id' => description.id, { 'id' => description.id,
'resource_type' => 'tag_description',
'tag_name_id' => tag.tag_name('ja')&.id, 'tag_name_id' => tag.tag_name('ja')&.id,
'body' => description.body, 'body' => description.body,
'created_user_id' => description.created_by_user_id, 'created_user_id' => description.created_by_user_id,
@@ -26,7 +27,7 @@ module WikiPageRepr
end end
def tag_group tag_group def tag_group tag_group
TagGroupRepr.tag_group(tag_group) TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
end end
def many wiki_pages def many wiki_pages
+56
ファイルの表示
@@ -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
+22 -4
ファイルの表示
@@ -8,9 +8,9 @@ class TagDescriptionOperation
tag_id: tag.id, language_code: LANGUAGE_CODE) tag_id: tag.id, language_code: LANGUAGE_CODE)
page = Wiki::CompatibilityWriter.create!( 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 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) description: nil)
page page
end end
@@ -22,7 +22,20 @@ class TagDescriptionOperation
tag.lock! tag.lock!
description = TagDescription.lock.find_by!( description = TagDescription.lock.find_by!(
tag_id: tag.id, language_code: LANGUAGE_CODE) 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!( revision = Wiki::CompatibilityWriter.update!(
page:, body:, updated_by_user:, message:, page:, body:, updated_by_user:, message:,
base_revision_id:) base_revision_id:)
@@ -34,6 +47,11 @@ class TagDescriptionOperation
end end
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: def self.record_version! tag:, revision:, body:, user:, description:
version_no = description ? description.version_no + 1 : 1 version_no = description ? description.version_no + 1 : 1
TagDescriptionVersion.create!( TagDescriptionVersion.create!(
@@ -44,7 +62,7 @@ class TagDescriptionOperation
description.update!(body:, version_no:, updated_by_user_id: user.id) description.update!(body:, version_no:, updated_by_user_id: user.id)
else else
TagDescription.create!( TagDescription.create!(
id: revision.wiki_page_id, tag_id: tag.id, tag_id: tag.id,
language_code: LANGUAGE_CODE, version_no:, body:, language_code: LANGUAGE_CODE, version_no:, body:,
created_by_user_id: user.id, updated_by_user_id: user.id) created_by_user_id: user.id, updated_by_user_id: user.id)
end end
+1 -1
ファイルの表示
@@ -349,7 +349,7 @@ class TagGroupOperation
tag_name.primary_flg = true tag_name.primary_flg = true
tag_name.auto_generated = false tag_name.auto_generated = false
tag_name.save! 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 end
nil nil
end end
+9 -5
ファイルの表示
@@ -8,12 +8,13 @@ module Wiki
end end
def self.create_content! tag_name:, body:, created_by_user:, message: nil 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!( return TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user:, message:) tag: tag_name.tag, body:, created_by_user:, message:)
end end
create_legacy_content!(tag_name:, body:, created_by_user:, message:) tag_name.errors.add(:base, :invalid)
raise ActiveRecord::RecordInvalid, tag_name
end end
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
@@ -42,14 +43,17 @@ module Wiki
end end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
description = TagDescription.find_by(id: page.id) description = TagDescription.find_by(tag_id: page.tag_name.tag_id, language_code: 'ja')
if description 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!( return TagDescriptionOperation.update!(
tag: description.tag, body:, updated_by_user: created_user, tag: description.tag, body:, updated_by_user: created_user,
message:, base_revision_id:) message:, base_revision_id:)
end end
legacy_content!(page:, body:, created_user:, message:, base_revision_id:) page.errors.add(:base, :invalid)
raise ActiveRecord::RecordInvalid, page
end end
def self.legacy_content! page:, body:, created_user:, message: nil, def self.legacy_content! page:, body:, created_user:, message: nil,
+13 -3
ファイルの表示
@@ -1,8 +1,16 @@
module Wiki module Wiki
class CompatibilityWriter class CompatibilityWriter
def self.create! tag_name:, body:, created_by_user:, message: nil def self.create! tag_name:, body:, created_by_user:, message: nil
Commit.create_legacy_content!( ApplicationRecord.transaction do
tag_name:, body:, created_by_user:, message:) 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 end
def self.update! page:, body:, updated_by_user:, message: nil, def self.update! page:, body:, updated_by_user:, message: nil,
@@ -23,7 +31,9 @@ module Wiki
end end
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!( Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:, page:, body: normalised, created_user: updated_by_user, message:,
+2 -2
ファイルの表示
@@ -5,11 +5,11 @@ RSpec.describe 'Tag description migration identity' do
it 'preserves every revision ID and repeated body in an independent sequence' do it 'preserves every revision ID and repeated body in an independent sequence' do
user = create_member_user! user = create_member_user!
tag_name = create(:tag_name, name: 'description_migration') 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) tag_name:, body: 'A', created_by_user: user)
revisions = [page.current_revision] revisions = [page.current_revision]
['A', 'B', 'B', 'A'].each do |body| ['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) page:, body:, created_user: user, base_revision_id: revisions.last.id)
end end
tag = create(:tag, primary_tag_name: tag_name) tag = create(:tag, primary_tag_name: tag_name)
+7
ファイルの表示
@@ -2,6 +2,7 @@ require 'rails_helper'
RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
include TagGroupRecords include TagGroupRecords
let(:user) { create_member_user! }
def create_resource resource def create_resource resource
if resource == :tag 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) expect(create_resource(resource).id).to eq(21)
end 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 it "preserves legacy overlaps and gaps when creating #{ resource }" do
[1, 2, 5].each { create(:tag, id: _1) } [1, 2, 5].each { create(:tag, id: _1) }
[1, 3, 8].each { create(:external_tag, id: _1) } [1, 3, 8].each { create(:external_tag, id: _1) }
+2 -2
ファイルの表示
@@ -10,8 +10,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
end end
let!(:revisions) do let!(:revisions) do
first = page.current_revision first = page.current_revision
second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user) second = Wiki::Commit.legacy_content!(page:, body: 'old-second', created_user: user)
third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user) third = Wiki::Commit.legacy_content!(page:, body: 'old-third', created_user: user)
[first, second, third] [first, second, third]
end end
let!(:tag) { create(:tag, primary_tag_name: tag_name) } let!(:tag) { create(:tag, primary_tag_name: tag_name) }
+47 -7
ファイルの表示
@@ -32,20 +32,38 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
headers: headers headers: headers
end 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 } expect { create_description }
.to change(WikiPage, :count).by(0) .to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(0) .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
.and change(TagDescription, :count).by(1) .and change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
expect(description).to have_attributes(body: 'initial', version_no: 1) 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')) 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) .to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id)
end 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| ['missing_description', 'orphan_description'].each do |title|
it "rejects the bare title #{ title } without writing any resource" do it "rejects the bare title #{ title } without writing any resource" do
create(:tag_name, name: title) if title == 'orphan_description' 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
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 it "rolls back new description creation when #{ model.name } persistence fails" do
before_state = stored_state before_state = stored_state
aggregate_failures do aggregate_failures do
@@ -81,6 +99,28 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
end end
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 describe 'PUT /wiki/:id' do
let!(:page) do let!(:page) do
tag tag
@@ -144,10 +184,10 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
page_state = page.attributes page_state = page.attributes
description_state = description.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(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(WikiRevision.count).to eq(revision_count)
expect(WikiVersion.count).to eq(wiki_version_count) expect(WikiVersion.count).to eq(wiki_version_count)
expect(TagDescriptionVersion.count).to eq(description_version_count) expect(TagDescriptionVersion.count).to eq(description_version_count)
+9 -5
ファイルの表示
@@ -33,11 +33,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
end end
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 } expect { create_group }
.to change(WikiPage, :count).by(0) .to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(0) .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
group = group_rows('tag_groups', name: '新規').sole group = group_rows('tag_groups', name: '新規').sole
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規') 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) version = group_version(group.fetch('id'), 1)
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id) 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) 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 end
it 'keeps repeated placements but only one membership and one snapshot member' do 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 end
tables = TagGroupRecords::GROUP_TABLES 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| tables.each do |table|
it "rolls back all writes after a real #{ table } write fails" do it "rolls back all writes after a real #{ table } write fails" do
expect_atomic_group_write(table) { write_group(operation, headers) } expect_atomic_group_write(table) { write_group(operation, headers) }
+16 -15
ファイルの表示
@@ -71,17 +71,16 @@ RSpec.describe 'Tag group write integrity', type: :request do
expect(page.body).to eq('- 伊地知ニジカ') expect(page.body).to eq('- 伊地知ニジカ')
end 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') page = create_help('Help body')
id = seed_group(name: '別グループ', id: page.id, members:, placements:) id = seed_group(name: '別グループ', id: page.id, members:, placements:)
group_before = TagGroup.find(id).attributes group_before = TagGroup.find(id).attributes
version_before = group_version(id, 1) 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 body')
expect(page.reload.body).to eq('Help after') expect(page.title).to eq('Help:既存')
expect(page.title).to eq('Help:改名')
expect(TagGroup.find(id).attributes).to eq(group_before) expect(TagGroup.find(id).attributes).to eq(group_before)
expect(group_version(id, 1)).to eq(version_before) expect(group_version(id, 1)).to eq(version_before)
end 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 it 'keeps a description update separate from a colliding group' do
page = Wiki::Commit.create_content!( page = Wiki::Commit.create_content!(
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user) 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 group_before = TagGroup.find(id).attributes
put_wiki(id, title: '洗澡鹿', body: 'description after') put_wiki(id, title: '洗澡鹿', body: 'description after')
@@ -116,18 +116,19 @@ RSpec.describe 'Tag group write integrity', type: :request do
description = TagDescription.create!( description = TagDescription.create!(
id:, tag:, language_code: 'ja', version_no: 1, body: 'description') 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 before')
expect(page.reload.body).to eq('Help after')
expect(description.reload.body).to eq('description') expect(description.reload.body).to eq('description')
expect(TagGroup.find(id).version_no).to eq(1) 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: 'タググループ:更新') put_wiki(id, title: 'タググループ:更新')
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).version_no).to eq(2) expect(TagGroup.find(id).version_no).to eq(2)
expect(page.reload.body).to eq('Help after') expect(page.reload.body).to eq('Help before')
expect(description.reload.body).to eq('description') expect(description.reload.body).to eq('description after')
end end
end end
@@ -139,9 +140,9 @@ RSpec.describe 'Tag group write integrity', type: :request do
expect do expect do
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
end.to change(WikiPage, :count).by(0) end.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(0) .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).name).to eq('改名') expect(TagGroup.find(id).name).to eq('改名')
+5 -5
ファイルの表示
@@ -391,18 +391,18 @@ RSpec.describe 'Tag group native API', type: :request do
end end
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) } expect { native_write(:create) }
.to change(WikiPage, :count).by(0) .to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(0) .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect_group_snapshot( expect_group_snapshot(
json.fetch('id'), number: 1, members: snapshot_members, json.fetch('id'), number: 1, members: snapshot_members,
placements:, description: '全体の説明') placements:, description: '全体の説明')
end end
[:update].each do |operation| [:create, :update].each do |operation|
it "synchronises Wiki current state and histories on native #{ operation }" do it "synchronises Wiki current state and histories on native #{ operation }" do
native_write(operation) native_write(operation)
expect(response).to have_http_status(operation == :create ? :created : :ok) expect(response).to have_http_status(operation == :create ? :created : :ok)
+3 -5
ファイルの表示
@@ -11,7 +11,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
it 'returns 409 when base_revision_id is stale' do it 'returns 409 when base_revision_id is stale' do
page = page =
seed_wiki_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', body: 'first',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
@@ -26,12 +26,10 @@ RSpec.describe 'Wiki conflict handling', type: :request do
base_revision_id: stale_id) base_revision_id: stale_id)
put "/wiki/#{ page.id }", put "/wiki/#{ page.id }",
params: { params: { title: 'wiki_conflict_request',
title: 'wiki_conflict_request',
body: 'third', body: 'third',
message: 'stale', message: 'stale',
base_revision_id: stale_id, base_revision_id: stale_id },
},
headers: auth_headers(user) headers: auth_headers(user)
expect(response).to have_http_status(:conflict) expect(response).to have_http_status(:conflict)
+8 -4
ファイルの表示
@@ -7,17 +7,21 @@ RSpec.describe 'Wiki creation freeze', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } 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') id = seed_group(name: 'independent', description: 'before')
expect do expect do
put "/tag_groups/#{ id }", params: { put "/tag_groups/#{ id }", params: {
name: 'independent', description: 'after', members: [], placements: [] }, name: 'independent', description: 'after', members: [], placements: [] },
headers:, as: :json headers:, as: :json
end.to change(WikiPage, :count).by(0) end.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(0) .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).description).to eq('after') 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 end
context 'existing description write protection' do context 'existing description write protection' do
+1 -1
ファイルの表示
@@ -11,7 +11,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
def create_wiki_page title:, body: 'body', message: 'init', user: self.user def create_wiki_page title:, body: 'body', message: 'init', user: self.user
seed_wiki_page( seed_wiki_page(
tag_name: create(:tag_name, name: title), tag_name: create(:tag, primary_name: title).tag_name('ja'),
body:, body:,
created_by_user: user, created_by_user: user,
message:) message:)
+42
ファイルの表示
@@ -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
+1 -1
ファイルの表示
@@ -129,7 +129,7 @@ RSpec.describe 'Wiki API', type: :request do
{ 'X-Transfer-Code' => user.inheritance_code } { 'X-Transfer-Code' => user.inheritance_code }
end 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 let!(:page) do
seed_wiki_page( seed_wiki_page(
+8 -8
ファイルの表示
@@ -9,29 +9,29 @@ RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
end end
let!(:first_revision) { page.current_revision } let!(:first_revision) { page.current_revision }
let!(:last_revision) do let!(:last_revision) do
Wiki::Commit.content!(page:, body: 'after', created_user: user, Wiki::Commit.legacy_content!(page:, body: 'after', created_user: user,
message: 'legacy edit', base_revision_id: first_revision.id) message: 'legacy edit', base_revision_id: first_revision.id)
end end
before { expect(TagDescription.exists?(id: page.id)).to be(false) } 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) create(:tag, primary_tag_name: tag_name)
original = page.reload.attributes
expect do expect do
put "/wiki/#{ page.id }", put "/wiki/#{ page.id }",
params: { title: 'legacy_tag_renamed', body: 'changed legacy body', params: { title: 'legacy_tag_renamed', body: 'changed legacy body',
message: 'legacy rename', base_revision_id: last_revision.id }, message: 'legacy rename', base_revision_id: last_revision.id },
headers: { 'X-Transfer-Code' => user.inheritance_code } headers: { 'X-Transfer-Code' => user.inheritance_code }
end.to change(WikiRevision, :count).by(1) end.to change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(0)
.and change(TagDescription, :count).by(0) .and change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0) .and change(TagDescriptionVersion, :count).by(0)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:unprocessable_entity)
expect(page.reload).to have_attributes(body: 'changed legacy body') expect(page.reload.attributes).to eq(original)
expect(page.title).to eq('legacy_tag_renamed') expect(page.current_revision).to have_attributes(message: 'legacy edit')
expect(page.current_revision).to have_attributes(message: 'legacy rename')
end end
include TagGroupRecords include TagGroupRecords
+65
ファイルの表示
@@ -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
+29 -10
ファイルの表示
@@ -7,29 +7,37 @@ RSpec.describe Wiki::Commit do
def create_page title:, body: 'initial body' def create_page title:, body: 'initial body'
seed_wiki_page( seed_wiki_page(
tag_name: create(:tag_name, name: title), tag_name: create(:tag, primary_name: title).tag_name('ja'),
body:, body:,
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
end end
describe '.create_content!' do describe '.create_content!' do
[:create_content!, :create_legacy_content!].each do |operation| it 'rejects standalone Help creation without adding page or history' do
it "freezes #{ operation } without adding page or history" do
name = create(:tag_name, name: 'Help:frozen_service') name = create(:tag_name, name: 'Help:frozen_service')
counts = [WikiPage.count, WikiRevision.count, WikiVersion.count] counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
begin expect do
described_class.public_send(operation, tag_name: name, body: 'initial', described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user)
created_by_user: user) end.to raise_error(ActiveRecord::RecordInvalid)
rescue ActiveRecord::RecordInvalid
# A validation rejection is allowed; persistence is not.
end
expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts) expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
end 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 end
it 'rejects body that becomes blank after normalisation' do 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 { expect {
described_class.create_content!( described_class.create_content!(
@@ -45,6 +53,17 @@ RSpec.describe Wiki::Commit do
end end
describe '.content!' do 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 it 'updates page body, revision, and version' do
page = create_page(title: 'commit_integrity_update', body: 'before') page = create_page(title: 'commit_integrity_update', body: 'before')
current_id = page.current_revision.id current_id = page.current_revision.id
+1 -1
ファイルの表示
@@ -5,7 +5,7 @@ RSpec.describe Wiki::Commit do
let(:user) { create_member_user! } let(:user) { create_member_user! }
def create_page(title: 'commit_spec_page', body: 'initial body') 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( seed_wiki_page(
tag_name:, tag_name:,
+3 -1
ファイルの表示
@@ -43,7 +43,9 @@ const validateEdition = (edition: string): void => {
} }
const generateHelpAssets = async (root: string) => { export const generateHelpAssets = async (
root: string,
): Promise<{ modules: Map<string, string>; files: string[] }> => {
const modules = new Map<string, string> () const modules = new Map<string, string> ()
const files: string[] = [root] const files: string[] = [root]
const articles: Record<string, Article> = Object.create (null) const articles: Record<string, Article> = Object.create (null)
+3 -1
ファイルの表示
@@ -90,7 +90,9 @@ export const menuOutline = (
{ name: '検索', to: '/help' }, { name: '検索', to: '/help' },
{ name: '全体履歴', to: '/help/changes' }, { name: '全体履歴', to: '/help/changes' },
{ component: <Separator/>, visible: helpVsbl }, { component: <Separator/>, visible: helpVsbl },
{ name: '履歴', to: `/help/changes?identifier=${ helpIdentifier }`, visible: helpVsbl }] }, { name: '履歴',
to: `/help/changes?identifier=${ helpIdentifier }`,
visible: helpVsbl }] },
{ name: 'おたのしみ', visible: false, subMenu: [ { name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' }, { name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] }, { name: 'グカネータ (β)', to: '/gekanator' }] },
+25 -18
ファイルの表示
@@ -6,51 +6,58 @@ import { createHelpFixture } from '@/test/helpFixtures'
describe ('Help file catalogue', () => { describe ('Help file catalogue', () => {
it ('uses current_edition rather than the newest file, without YAML titles', async () => { it ('uses current_edition rather than the newest file, without YAML titles', async () => {
const { api } = createHelpFixture () 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 ([ expect (await api.getHelpSearchEntries ('ja')).toEqual ([
{ identifier: 'guide', edition: '2026-10-02', { identifier: 'guide', edition: '2026-10-02.10',
title: 'ホーム', body: '\nタグを検索する' }, title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' },
{ identifier: '設定 手順', edition: '2026-10-03', { identifier: '設定 手順', edition: '2026-10-03',
title: '設定', body: '\nタグを設定する' }]) title: '設定', body: '\nタグを設定する' }])
}) })
it ('orders same-day editions numerically and separates languages', () => { it ('orders published editions numerically and separates languages', async () => {
const { api } = createHelpFixture () 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']) '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 () const { api, raw } = createHelpFixture ()
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01')) expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
.toEqual ({ title: '旧ホーム', body: '\n過去限定本文' }) .toEqual ({ title: '旧ホーム', body: '\n過去限定本文' })
raw['../../help/guide/editions/ja/2026-10-02.mdx'] raw['../../help/guide/editions/ja/2026-10-02.mdx']
.mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し') .mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-02')) 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 () => { it ('returns missing resources without substituting another edition', async () => {
const { api } = createHelpFixture () const { api } = createHelpFixture ()
expect (api.getHelpArticle ('absent')).toBeNull () expect (await api.getHelpArticle ('absent')).toBeNull ()
expect (api.getHelpEditions ('absent', 'ja')).toEqual ([]) expect (await api.getHelpEditions ('absent', 'ja')).toEqual ([])
expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull () expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull ()
expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull () expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull ()
}) })
it ('rejects a missing current file instead of silently hiding an article', async () => { it ('reports generated search asset failures instead of hiding articles', async () => {
const { api, raw } = createHelpFixture () const { api, loads } = createHelpFixture ()
delete raw['../../help/guide/editions/ja/2026-10-02.mdx'] loads.search.mockImplementationOnce (() => { throw new Error ('asset failure') })
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow () 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 { api, raw } = createHelpFixture ()
const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx'] const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx']
loader.mockRejectedValueOnce (new Error ('fixture failure')) loader.mockRejectedValueOnce (new Error ('fixture failure'))
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('fixture failure') await expect (api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))
loader.mockResolvedValueOnce ('本文\n# 遅い見出し') .rejects.toThrow ('fixture failure')
await expect (api.getHelpEditionText ('guide', 'ja', '2026-10-02')).rejects.toThrow () expect (await api.getHelpEdition ('guide', 'ja', '2026-10-02.11')).toBeNull ()
expect (await api.getHelpEditionSource ('guide', 'ja', '2026-10-02.11')).toBeNull ()
}) })
}) })
+9 -6
ファイルの表示
@@ -23,11 +23,13 @@ const Location = () => {
describe ('Help prefetch integration', () => { describe ('Help prefetch integration', () => {
beforeEach (() => { beforeEach (() => {
helpFixture.reset ()
settings.linkPreload = 'intent' settings.linkPreload = 'intent'
vi.clearAllMocks () 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']) ( '/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 => { 'registers a unique prefetcher for %s', path => {
const url = new URL (path, window.location.origin) const url = new URL (path, window.location.origin)
@@ -43,13 +45,13 @@ describe ('Help prefetch integration', () => {
<Route path="/help/:identifier" element={<HelpDetailPage/>}/> <Route path="/help/:identifier" element={<HelpDetailPage/>}/>
</Routes> </Routes>
</>) </>)
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 ([ await Promise.all ([
prefetchers.prefetchForURL (queryClient, '/help/guide'), prefetchers.prefetchForURL (queryClient, '/help/guide'),
prefetchers.prefetchForURL (queryClient, '/help/guide')]) prefetchers.prefetchForURL (queryClient, '/help/guide')])
expect (loader).toHaveBeenCalledTimes (1) expect (loader).toHaveBeenCalledTimes (1)
fireEvent.click (screen.getByRole ('link', { name: 'Help' })) fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
await screen.findByRole ('heading', { name: 'ホーム' }) await screen.findByRole ('heading', { name: 'ホーム第十版' })
expect (loader).toHaveBeenCalledTimes (1) expect (loader).toHaveBeenCalledTimes (1)
}) })
@@ -69,11 +71,12 @@ describe ('Help prefetch integration', () => {
const article = vi.spyOn (helpFixture.api, 'getHelpArticle') const article = vi.spyOn (helpFixture.api, 'getHelpArticle')
await prefetchers.prefetchForURL (new QueryClient (), '/help/changes') await prefetchers.prefetchForURL (new QueryClient (), '/help/changes')
expect (article).not.toHaveBeenCalledWith ('changes') expect (article).not.toHaveBeenCalledWith ('changes')
expect (helpFixture.raw['../../help/guide/editions/ja/2026-10-01.mdx']) expect (helpFixture.loads.changes).toHaveBeenCalledTimes (1)
.toHaveBeenCalled () 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 => { async mode => {
settings.linkPreload = mode settings.linkPreload = mode
const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue () const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue ()
+11 -5
ファイルの表示
@@ -1,5 +1,5 @@
import { fireEvent, screen, within } from '@testing-library/react' 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 { Route, Routes } from 'react-router-dom'
import HelpDetailPage from '@/pages/help/HelpDetailPage' import HelpDetailPage from '@/pages/help/HelpDetailPage'
@@ -21,6 +21,8 @@ const tableSource = [
describe ('Help MDX with the application compiler', () => { describe ('Help MDX with the application compiler', () => {
beforeEach (() => helpFixture.reset ())
it ('compiles GFM table structure, links and emphasis', async () => { it ('compiles GFM table structure, links and emphasis', async () => {
const Content = await compileMdx (tableSource) const Content = await compileMdx (tableSource)
renderWithProviders (<Content/>) renderWithProviders (<Content/>)
@@ -44,7 +46,8 @@ describe ('Help MDX with the application compiler', () => {
it ('keeps tables inside the Help dark-mode typography container', async () => { it ('keeps tables inside the Help dark-mode typography container', async () => {
const Content = await compileMdx (tableSource) 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 ( renderWithProviders (
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>, <Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
{ route: '/help/guide' }) { route: '/help/guide' })
@@ -52,9 +55,12 @@ describe ('Help MDX with the application compiler', () => {
expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert') expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert')
}) })
// jsdom cannot measure overflow. Requiring a particular Tailwind class rejects it ('preserves long cell contents when rendering a wide table', async () => {
// valid CSS layouts, including the existing overflow-y-auto container. const cell = 'wide'.repeat (100)
it.todo ('keeps wide tables within the viewport in a browser layout test') const Content = await compileMdx (`| 項目 |\n| --- |\n| ${ cell } |`)
renderWithProviders (<Content/>)
expect (screen.getByRole ('cell')).toHaveTextContent (cell)
})
it.each (['.mdx?raw', '.mdx?url']) ('preserves the %s transform bypass', async suffix => { it.each (['.mdx?raw', '.mdx?url']) ('preserves the %s transform bypass', async suffix => {
expect (await transformMdx (tableSource, suffix)).toBeNull () expect (await transformMdx (tableSource, suffix)).toBeNull ()
+13 -1
ファイルの表示
@@ -1,10 +1,11 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async' import { HelmetProvider } from 'react-helmet-async'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import App from '@/App' import App from '@/App'
import { helpFixture } from '@/test/helpFixtures'
vi.hoisted (() => { vi.hoisted (() => {
window.history.replaceState (null, '', '/help/changes') window.history.replaceState (null, '', '/help/changes')
@@ -20,6 +21,8 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({
describe ('Help global edition history', () => { describe ('Help global edition history', () => {
beforeEach (() => helpFixture.reset ())
it ('routes /help/changes to published editions in date and numeric order', async () => { it ('routes /help/changes to published editions in date and numeric order', async () => {
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } }) const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
const { container } = render ( 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-02.2', '/help/guide?edition=2026-10-02',
'/help/guide?edition=2026-10-01']) '/help/guide?edition=2026-10-01'])
expect (container).toHaveTextContent ('旧ホーム') 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 (apiGet).not.toHaveBeenCalled ()
expect (window.location.pathname).toBe ('/help/changes') expect (window.location.pathname).toBe ('/help/changes')
}) })
+15 -10
ファイルの表示
@@ -1,6 +1,6 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom' 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 HelpDetailPage from '@/pages/help/HelpDetailPage'
import { helpFixture } from '@/test/helpFixtures' import { helpFixture } from '@/test/helpFixtures'
@@ -35,12 +35,15 @@ const renderDetail = (route = '/help/guide') => renderWithProviders (
describe ('Help details', () => { describe ('Help details', () => {
beforeEach (() => helpFixture.reset ())
it ('displays current_edition H1 and body even when newer files exist', async () => { it ('displays current_edition H1 and body even when newer files exist', async () => {
renderDetail () renderDetail ()
expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument () expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
expect (screen.getByText ('タグを検索する')).toBeInTheDocument () .toBeInTheDocument ()
expect (screen.queryByText ('第十版本文')).not.toBeInTheDocument () expect (screen.getByText ('タグを検索する 第十版本文')).toBeInTheDocument ()
await waitFor (() => expect (document.title).toContain ('ホーム ヘルプ')) expect (screen.queryByText ('未公開限定本文')).not.toBeInTheDocument ()
await waitFor (() => expect (document.title).toContain ('ホーム第十版 ヘルプ'))
}) })
it ('uses the historical H1 in both page and document titles', async () => { it ('uses the historical H1 in both page and document titles', async () => {
@@ -66,7 +69,8 @@ describe ('Help details', () => {
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument () 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 => { 'shows NotFound without redirecting %s', async route => {
renderDetail (route) renderDetail (route)
expect (await screen.findByText ('404')).toBeInTheDocument () expect (await screen.findByText ('404')).toBeInTheDocument ()
@@ -81,9 +85,9 @@ describe ('Help details', () => {
helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx'] helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx']
.mockReturnValueOnce (pending) .mockReturnValueOnce (pending)
renderDetail () renderDetail ()
await screen.findByRole ('heading', { name: 'ホーム' }) await screen.findByRole ('heading', { name: 'ホーム第十版' })
fireEvent.click (screen.getByRole ('button', { name: 'switch article' })) 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 waitFor (() => expect (document.title).not.toContain ('ホーム'))
await act (async () => finish ({ default: () => <h1>設定</h1> })) await act (async () => finish ({ default: () => <h1>設定</h1> }))
await screen.findByRole ('heading', { name: '設定' }) await screen.findByRole ('heading', { name: '設定' })
@@ -95,9 +99,10 @@ describe ('Help details', () => {
const pending = new Promise<{ default: () => ReactElement }> (resolve => { const pending = new Promise<{ default: () => ReactElement }> (resolve => {
finish = resolve finish = resolve
}) })
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx'] const loader = helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx']
.mockReturnValueOnce (pending) loader.mockReturnValueOnce (pending)
renderDetail () renderDetail ()
await waitFor (() => expect (loader).toHaveBeenCalled ())
fireEvent.click (screen.getByRole ('button', { name: 'switch edition' })) fireEvent.click (screen.getByRole ('button', { name: 'switch edition' }))
await screen.findByRole ('heading', { name: 'ホーム第三版' }) await screen.findByRole ('heading', { name: 'ホーム第三版' })
await act (async () => finish ({ default: () => <h1>遅延した旧本文</h1> })) await act (async () => finish ({ default: () => <h1>遅延した旧本文</h1> }))
+13 -10
ファイルの表示
@@ -1,5 +1,5 @@
import { fireEvent, screen } from '@testing-library/react' 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 HelpListPage from '@/pages/help/HelpListPage'
import { helpFixture } from '@/test/helpFixtures' import { helpFixture } from '@/test/helpFixtures'
@@ -9,23 +9,25 @@ vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFi
describe ('Help search', () => { describe ('Help search', () => {
beforeEach (() => helpFixture.reset ())
it ('initially lists each current article once with encoded detail links', async () => { it ('initially lists each current article once with encoded detail links', async () => {
renderWithProviders (<HelpListPage/>, { route: '/help' }) renderWithProviders (<HelpListPage/>, { route: '/help' })
expect (await screen.findByRole ('link', { name: 'ホーム' })) expect (await screen.findByRole ('link', { name: 'ホーム第十版' }))
.toHaveAttribute ('href', '/help/guide') .toHaveAttribute ('href', '/help/guide')
expect (screen.getByRole ('link', { name: '設定' })) expect (screen.getByRole ('link', { name: '設定' }))
.toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`) .toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`)
expect (screen.getByText ('2 件')).toBeInTheDocument () expect (screen.getByText ('2 件')).toBeInTheDocument ()
expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument () expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument ()
expect (screen.queryByText ('ホーム第十版')).not.toBeInTheDocument () expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument ()
}) })
it.each ([ it.each ([
['ホーム', '', ['ホーム']], ['ホーム', '', ['ホーム第十版']],
['', '検索', ['ホーム']], ['', '検索', ['ホーム第十版']],
['', 'タグ', ['ホーム', '設定']], ['', 'タグ', ['設定', 'ホーム第十版']],
['ホーム', '設定', []], ['ホーム', '設定', []],
['ホーム', '検索', ['ホーム']], ['ホーム', '検索', ['ホーム第十版']],
['旧ホーム', '', []], ['旧ホーム', '', []],
['', '過去限定本文', []]]) ( ['', '過去限定本文', []]]) (
'searches title %s and body %s together', async (title, body, expected) => { '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 () => { it ('displays loading failures without stale results', async () => {
helpFixture.raw['../../help/guide/editions/ja/2026-10-02.mdx'] helpFixture.loads.search.mockImplementationOnce (() => {
.mockRejectedValueOnce (new Error ('fixture loading failed')) throw new Error ('fixture loading failed')
})
renderWithProviders (<HelpListPage/>) renderWithProviders (<HelpListPage/>)
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed') expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
expect (screen.queryByRole ('link', { name: 'ホーム' })).not.toBeInTheDocument () expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument ()
}) })
}) })
+19 -11
ファイルの表示
@@ -6,7 +6,7 @@ import { apiGet } from '@/lib/api'
import HelpDetailPage from '@/pages/help/HelpDetailPage' import HelpDetailPage from '@/pages/help/HelpDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildWikiPage } from '@/test/factories' import { buildWikiPage } from '@/test/factories'
import { editionRows, helpFixture } from '@/test/helpFixtures' import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () })) vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
@@ -38,10 +38,7 @@ const renderWiki = (title: string) => {
describe ('Legacy Wiki to Help compatibility', () => { describe ('Legacy Wiki to Help compatibility', () => {
beforeEach (() => { beforeEach (() => {
editionRows.forEach (([id, language, edition, title, body]) => { helpFixture.reset ()
helpFixture.raw[`../../help/${id}/editions/${language}/${edition}.mdx`]
.mockReset ().mockResolvedValue (`# ${title}\n\n${body}`)
})
vi.mocked (apiGet).mockReset () vi.mocked (apiGet).mockReset ()
vi.mocked (apiGet).mockImplementation (async path => { vi.mocked (apiGet).mockImplementation (async path => {
if (path === '/wiki') if (path === '/wiki')
@@ -57,31 +54,41 @@ describe ('Legacy Wiki to Help compatibility', () => {
await screen.findByText ('Wiki の現行本文') await screen.findByText ('Wiki の現行本文')
expect (screen.getByTestId ('location')).toHaveTextContent (route) expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument () 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 => { 'redirects a unique current or historical Japanese title: %s', async title => {
renderWiki (title) renderWiki (title)
await waitFor (() => { await waitFor (() => {
expect (screen.getByTestId ('location')).toHaveTextContent ('/help/guide') 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 ( expect (apiGet).toHaveBeenCalledWith (
`/wiki/title/${encodeURIComponent (title)}`, expect.anything ()) `/wiki/title/${encodeURIComponent (title)}`, expect.anything ())
}) })
it ('keeps the original URL and displays NotFound when no title matches', async () => { 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 screen.findByText ('404')
await waitFor (() => {
expect (helpFixture.loads.titles).toHaveBeenCalled ()
expect (queryClient.isFetching ()).toBe (0)
})
expect (screen.getByTestId ('location')).toHaveTextContent (route) expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument () expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument ()
}) })
it ('does not choose between articles with the same title', async () => { it ('does not choose between articles with the same title', async () => {
vi.spyOn (helpFixture.raw, '../../help/設定 手順/editions/ja/2026-10-03.mdx') helpFixture.assets.titles.default.ja['ホーム'] = ['guide', '設定 手順']
.mockResolvedValue ('# ホーム\n\n別の記事') const { route, queryClient } = renderWiki ('ホーム')
const { route } = renderWiki ('ホーム')
await screen.findByText ('404') await screen.findByText ('404')
await waitFor (() => {
expect (helpFixture.loads.titles).toHaveBeenCalled ()
expect (queryClient.isFetching ()).toBe (0)
})
expect (screen.getByTestId ('location')).toHaveTextContent (route) expect (screen.getByTestId ('location')).toHaveTextContent (route)
}) })
@@ -94,5 +101,6 @@ describe ('Legacy Wiki to Help compatibility', () => {
}) })
expect (screen.getByTestId ('location')).toHaveTextContent (route) expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument () expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
}) })
}) })
+32 -36
ファイルの表示
@@ -1,62 +1,58 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' 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 WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import { buildWikiPage } from '@/test/factories' import { buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
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 () => { it ('loads initial wiki pages and renders links', async () => {
api.apiGet.mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })]) vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
renderWithProviders (<WikiSearchPage/>) renderWithProviders (<WikiSearchPage/>)
expect (await screen.findByRole ('link', { name: '虹夏' }))
expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute ( .toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F')
'href', expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
'/wiki/%E8%99%B9%E5%A4%8F',
)
expect (api.apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
}) })
it ('searches by title on submit', async () => { it ('searches by title on submit', async () => {
api.apiGet vi.mocked (apiGet).mockResolvedValueOnce ([])
.mockResolvedValueOnce ([])
.mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })]) .mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })])
renderWithProviders (<WikiSearchPage/>) renderWithProviders (<WikiSearchPage/>)
await waitFor (() => expect (apiGet).toHaveBeenCalledTimes (1))
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } }) fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } })
fireEvent.click (screen.getByRole ('button', { name: '検索' })) fireEvent.click (screen.getByRole ('button', { name: '検索' }))
await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith (
await waitFor (() => { '/wiki', { params: { title: '検索' } }))
expect (api.apiGet).toHaveBeenLastCalledWith (
'/wiki',
{ params: { title: '検索' } },
)
})
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument () expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
}) })
it ('marks deprecated wiki tags in the result title', async () => { it ('marks deprecated wiki tags in the result title', async () => {
api.apiGet.mockResolvedValueOnce ([ vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({
buildWikiPage ({ title: '旧タグ', deprecatedAt: '2026-06-01T00:00:00.000Z' })])
title: '旧タグ', renderWithProviders (<WikiSearchPage/>)
deprecatedAt: '2026-06-01T00:00:00.000Z', 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 (<WikiSearchPage/>) renderWithProviders (<WikiSearchPage/>)
const link = await screen.findByRole ('link', { name: '旧タグ' }) await screen.findByRole ('link', { name: '説明' })
const marker = screen.getByText ('(廃止)') expect (screen.getByRole ('link', { name: 'タググループ:一覧' }))
.toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`)
expect (link).toBeInTheDocument () await waitFor (() => {
expect (marker.closest ('a')).toBeNull () expect (errors.mock.calls.some (args => args.some (value =>
String (value).includes ('same key')))).toBe (false)
})
}) })
}) })
+2 -1
ファイルの表示
@@ -81,7 +81,8 @@ const WikiSearchPage: FC = () => {
</thead> </thead>
<tbody> <tbody>
{results.map (page => ( {results.map (page => (
<tr key={page.id} className="even:bg-gray-100 dark:even:bg-gray-700"> <tr key={`${ page.resourceType }:${ page.id }`}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2"> <td className="p-2">
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}> <PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title} {page.title}
+1
ファイルの表示
@@ -59,6 +59,7 @@ export const buildUser = (overrides: Partial<User> = {}): User => ({
}) })
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({ export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
resourceType: 'tag_description',
id: 1, id: 1,
title: 'テストWiki', title: 'テストWiki',
deprecatedAt: null, deprecatedAt: null,
+86 -25
ファイルの表示
@@ -1,7 +1,6 @@
import { createElement } from 'react' import { createElement } from 'react'
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript' import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
import { vi } from 'vitest' import { vi } from 'vitest'
import { parse } from 'yaml'
import source from '@/lib/help.ts?raw' import source from '@/lib/help.ts?raw'
@@ -14,51 +13,113 @@ export const editionRows = [
['guide', 'ja', '2026-10-02', 'ホーム', 'タグを検索する'], ['guide', 'ja', '2026-10-02', 'ホーム', 'タグを検索する'],
['guide', 'ja', '2026-10-02.2', 'ホーム第二版', '第二版本文'], ['guide', 'ja', '2026-10-02.2', 'ホーム第二版', '第二版本文'],
['guide', 'ja', '2026-10-02.3', 'ホーム第三版', '第三版本文'], ['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'], ['guide', 'en', '2026-10-02', 'Home', 'English body'],
['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const ['設定 手順', '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 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 = () => { export const createHelpFixture = () => {
const articles: Record<string, string> = { const articles: Record<string, Help.ArticleMeta> = {
'../../help/guide/article.yml': guide: { identifier: 'guide', canonicalLanguage: 'ja', languages: {
'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02\n' ja: { currentEdition: '2026-10-02.10', publishedEditions: [
+ ' en:\n current_edition: 2026-10-02\n', '2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] },
'../../help/設定 手順/article.yml': en: { currentEdition: '2026-10-02', publishedEditions: ['2026-10-02'] } } },
'identifier: 設定 手順\nlanguages:\n ja:\n current_edition: 2026-10-03\n' } '設定 手順': { 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]) => [ const raw = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
`../../help/${id}/editions/${language}/${edition}.mdx`, `../../help/${id}/editions/${language}/${edition}.mdx`,
vi.fn (async () => `# ${title}\n\n${body}`)])) 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`, `../../help/${id}/editions/${language}/${edition}.mdx`,
vi.fn (async (): Promise<{ default: () => ReactElement }> => ({ vi.fn (async (): Promise<{ default: () => ReactElement }> => ({
default: () => createElement ('div', null, default: () => createElement ('div', null,
createElement ('h1', null, title), createElement ('p', null, body)) }))])) createElement ('h1', null, title), createElement ('p', null, body)) }))]))
// Dynamic imports reuse a module namespace even when called more than once. // Dynamic imports reuse a module namespace even when called more than once.
const caches: Record<string, { promise?: Promise<{ default: () => ReactElement }> }> = { }
const modules = Object.fromEntries (Object.entries (moduleLoads).map (([path, load]) => { const modules = Object.fromEntries (Object.entries (moduleLoads).map (([path, load]) => {
const cache: { promise?: ReturnType<typeof load> } = { } caches[path] = { }
return [path, vi.fn (() => cache.promise ??= load ())] return [path, vi.fn (() => caches[path].promise ??= load ())]
})) }))
const glob = (pattern: string, options?: { query?: string }) => { const texts = Object.fromEntries (published.map (([id, language, edition, title, body]) => [
if (pattern.endsWith ('article.yml')) `${ id }/${ language }/${ edition }`,
return articles vi.fn (async () => ({ default: { title, body: `\n${ body }` } }))]))
if (pattern.endsWith ('*.mdx')) const components = Object.fromEntries (published.map (([id, language, edition]) => [
return options?.query === '?raw' ? raw : modules `${ id }/${ language }/${ edition }`,
throw new Error (`Unexpected Help glob: ${pattern}`) 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<string, Help.HelpSearchEntry[]> = { }
const changes: Record<string, Help.HelpChange[]> = { }
const titles: Record<string, Help.HelpTitleIndex> = { }
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 api = { } as typeof Help
const requireFixture = (name: string) => { const requireFixture = (name: string) => {
if (name === 'yaml') const key = name.replace ('virtual:help/', '') as keyof typeof loads
return { parse } if (Object.hasOwn (loads, key))
return loads[key] ()
throw new Error (`Unexpected Help dependency: ${name}`) throw new Error (`Unexpected Help dependency: ${name}`)
} }
new Function ('require', 'exports', 'helpMeta', compiled) ( new Function ('require', 'exports', compiled) (requireFixture, api)
requireFixture, api, { glob }) const reset = () => {
return { api, articles, raw, modules, moduleLoads } 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 }
} }
+1
ファイルの表示
@@ -452,6 +452,7 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha
export type WikiPage = { export type WikiPage = {
id: number id: number
resourceType: 'tag_description' | 'tag_group'
title: string title: string
deprecatedAt: string | null deprecatedAt: string | null
createdUserId: number createdUserId: number