ヘルプを Wiki から分離 (#424) #430

マージ済み
みてるぞ が 14 個のコミットを feature/424 から main へマージ 2026-10-13 12:25:12 +09:00
85個のファイルの変更、4126行の追加、764行の削除
+4 -1
ファイルの表示
@@ -70,7 +70,10 @@ class MaterialsController < ApplicationController
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless material return head :not_found unless material
wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.body wiki_page_body =
TagDescription
.find_by(tag_id: material.tag_id, language_code: locale.language_code)
&.body
render json: MaterialRepr.base(material, host: request.base_url, render json: MaterialRepr.base(material, host: request.base_url,
language_code: locale.language_code).merge(wiki_page_body:) language_code: locale.language_code).merge(wiki_page_body:)
+1 -1
ファイルの表示
@@ -26,7 +26,7 @@ class NicoTagsController < ApplicationController
ExternalTag ExternalTag
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.external_tag_id = external_tags.id') 'ON post_tag_max.external_tag_id = external_tags.id')
.includes(linked_tags: { tag_names: :wiki_page }) .includes(linked_tags: [:tag_names, :tag_descriptions])
if name if name
q = q.where(('external_tags.name LIKE ? ' + q = q.where(('external_tags.name LIKE ? ' +
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"), "OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
+3 -3
ファイルの表示
@@ -53,7 +53,7 @@ class PostsController < ApplicationController
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:external_tags, :uploaded_user, :parents, :children, .preload(:external_tags, :uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_names: :wiki_page }] }]) :tag_names, :tag_descriptions] }])
.with_attached_thumbnail .with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -111,7 +111,7 @@ class PostsController < ApplicationController
filtered_posts(locale) filtered_posts(locale)
.preload(:uploaded_user, :parents, :children, .preload(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_names: :wiki_page }] }]) :tag_names, :tag_descriptions] }])
.with_attached_thumbnail .with_attached_thumbnail
.order('RAND()') .order('RAND()')
.first .first
@@ -198,7 +198,7 @@ class PostsController < ApplicationController
Post Post
.includes(:uploaded_user, :parents, :children, .includes(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials, post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_names: :wiki_page }] }]) :tag_names, :tag_descriptions] }])
.with_attached_thumbnail .with_attached_thumbnail
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
+1 -1
ファイルの表示
@@ -51,7 +51,7 @@ class TagVersionsController < ApplicationController
tags_by_id = tags_by_id =
Tag Tag
.includes(:tag_names, :materials, tag_names: :wiki_page) .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.where(id: all_parent_tag_ids) .where(id: all_parent_tag_ids)
.index_by(&:id) .index_by(&:id)
+8 -8
ファイルの表示
@@ -175,7 +175,7 @@ class TagsController < ApplicationController
tags_by_id = tags_by_id =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page) .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.where(tag_names: { language_code: locale.language_code, primary_flg: true }, .where(tag_names: { language_code: locale.language_code, primary_flg: true },
id: tag_ids) id: tag_ids)
.index_by(&:id) .index_by(&:id)
@@ -238,7 +238,7 @@ class TagsController < ApplicationController
base = base =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page) .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.where(tag_names: { language_code: locale.language_code, primary_flg: true }, .where(tag_names: { language_code: locale.language_code, primary_flg: true },
deprecated_at: nil) deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only base = base.where('tags.post_count > 0') if present_only
@@ -282,7 +282,7 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page) .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.find_by(id: params[:id], .find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true }) tag_names: { language_code: locale.language_code, primary_flg: true })
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
@@ -302,7 +302,7 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page) .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
@@ -320,7 +320,7 @@ class TagsController < ApplicationController
tag = tag =
Tag.joins(:tag_names) Tag.joins(:tag_names)
.includes(:tag_names, tag_names: :wiki_page) .includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
.find_by(id: params[:id], .find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true }) tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag return head :not_found unless tag
@@ -340,7 +340,7 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, tag_names: :wiki_page) .includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag return head :not_found unless tag
@@ -359,7 +359,7 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, tag_names: :wiki_page) .includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
.find_by(id: params[:id], .find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true }) tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag return head :not_found unless tag
@@ -424,7 +424,7 @@ class TagsController < ApplicationController
tag = tag =
Tag Tag
.joins(:tag_names) .joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page) .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag return head :not_found unless tag
+32 -92
ファイルの表示
@@ -8,9 +8,9 @@ class WikiPagesController < ApplicationController
descriptions = descriptions =
TagDescription.where(language_code: 'ja') TagDescription.where(language_code: 'ja')
.includes(tag: :tag_names) .includes(tag: :tag_names)
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
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 =
@@ -19,12 +19,13 @@ class WikiPagesController < ApplicationController
.where('tag_names.name LIKE ?', pattern).limit(20) .where('tag_names.name LIKE ?', pattern).limit(20)
tag_groups = tag_groups.where( tag_groups = tag_groups.where(
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20) 'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
end end
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.concat(WikiPageRepr.base(pages)) 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
@@ -37,11 +38,7 @@ class WikiPagesController < ApplicationController
tag_group = TagGroup.find_by(id: params[:id]) tag_group = TagGroup.find_by(id: params[:id])
return render_tag_group tag_group if tag_group return render_tag_group tag_group if tag_group
page = WikiPage.joins(:tag_name) render_wiki_page_or_404 nil
.includes(tag_name: :tag)
.find_by(id: params[:id])
render_wiki_page_or_404 page
end end
def show_by_title def show_by_title
@@ -58,20 +55,14 @@ class WikiPagesController < ApplicationController
return render_wiki_page_by_tag_description tag_description if tag_description return render_wiki_page_by_tag_description tag_description if tag_description
page = render_wiki_page_or_404 nil
WikiPage
.joins(:tag_name)
.includes(tag_name: :tag)
.find_by(tag_name: { name: title })
render_wiki_page_or_404 page
end end
def exists def exists
return head :no_content if TagDescription.exists?(id: params[:id]) return head :no_content if TagDescription.exists?(id: params[:id])
return head :no_content if TagGroup.exists?(id: params[:id]) return head :no_content if TagGroup.exists?(id: params[:id])
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found head :not_found
end end
def exists_by_title def exists_by_title
@@ -82,8 +73,7 @@ class WikiPagesController < ApplicationController
end end
return head :no_content if tag_description_by_title(title) return head :no_content if tag_description_by_title(title)
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) head :not_found
head exists ? :no_content : :not_found
end end
def diff def diff
@@ -112,15 +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
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) return head :not_found
from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
return render_unprocessable_entity('差分を表示できない版です.')
end
from_body = from_rev&.body
to_body = to_rev.body
resource = WikiPageRepr.base(page)
end end
end end
@@ -160,17 +142,9 @@ class WikiPagesController < ApplicationController
status: :created) status: :created)
end end
page = if title.include?(':') page =
ApplicationRecord.transaction do if title.include?(':')
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| return render_unprocessable_entity field: :title
name.script_code = 'Jpan'
name.primary_flg = true
name.auto_generated = false
end
Wiki::Commit.create_legacy_content!(
tag_name:, body:, created_by_user: current_user, message:)
end
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
@@ -179,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
@@ -209,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
@@ -273,23 +238,7 @@ class WikiPagesController < ApplicationController
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id)) return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
end end
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages json = []
revisions = WikiRevision.joins(wiki_page: :tag_name)
.where(wiki_page_id: pages.select(:id))
.includes(:created_user, wiki_page: { tag_name: :tag })
.order(id: :desc).limit(200)
json = revisions.map { |rev|
{ revision_id: rev.id,
pred: rev.base_revision_id,
succ: nil,
wiki_page: { id: rev.wiki_page_id,
title: rev.wiki_page.title,
deprecated_at: rev.wiki_page.deprecated_at },
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
kind: rev.kind,
message: rev.message,
timestamp: rev.created_at }
}
unless id unless id
json.concat(tag_group_changes(TagGroupVersion.all)) json.concat(tag_group_changes(TagGroupVersion.all))
json.concat(description_changes(TagDescription.where(language_code: 'ja'))) json.concat(description_changes(TagDescription.where(language_code: 'ja')))
@@ -351,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,
@@ -453,16 +403,6 @@ class WikiPagesController < ApplicationController
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title }) .find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
end end
def unmigrated_wiki_pages
descriptions = TagDescription.where(language_code: 'ja')
WikiPage
.joins(:tag_name)
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
descriptions.select(:tag_id))
end
def description_changes descriptions def description_changes descriptions
versions = TagDescriptionVersion.where(language_code: 'ja', versions = TagDescriptionVersion.where(language_code: 'ja',
tag_id: descriptions.select(:tag_id)) tag_id: descriptions.select(:tag_id))
+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 }
+10 -1
ファイルの表示
@@ -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
@@ -51,6 +53,7 @@ class Tag < ApplicationRecord
has_many :tag_versions has_many :tag_versions
has_many :tag_names has_many :tag_names
has_many :tag_descriptions
attribute :version_no, :integer, default: 1 attribute :version_no, :integer, default: 1
@@ -96,7 +99,13 @@ class Tag < ApplicationRecord
def deprecated? = deprecated_at? def deprecated? = deprecated_at?
def has_wiki = wiki_page.present? def has_description? language_code:
if tag_descriptions.loaded?
tag_descriptions.any? { _1.language_code == language_code }
else
tag_descriptions.exists?(language_code:)
end
end
def material_id = materials.first&.id def material_id = materials.first&.id
+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
+11 -4
ファイルの表示
@@ -3,21 +3,28 @@
module TagRepr module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at], BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze methods: [:material_id, :has_deerjikists] }.freeze
module_function module_function
def base tag, language_code: 'ja' def base tag, language_code: 'ja'
tag.as_json(BASE).merge(name: name_for(tag, language_code), tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
name: name_for(tag, language_code),
aliases: tag.snapshot_aliases(language_code), aliases: tag.snapshot_aliases(language_code),
parents: tag.parents.map { inline(_1, language_code:) }) parents: tag.parents.map { inline(_1, language_code:) })
end end
def inline tag, language_code: 'ja' def inline tag, language_code: 'ja'
tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: []) tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
name: name_for(tag, language_code), aliases: [], parents: [])
end end
def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) } def many tags, language_code: 'ja'
records = tags.to_a
ActiveRecord::Associations::Preloader.new(
records:, associations: [:tag_descriptions, { parents: :tag_descriptions }]).call
records.map { |tag| base(tag, language_code:) }
end
def name_for tag, language_code def name_for tag, language_code
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil) tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
+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:,
+51
ファイルの表示
@@ -0,0 +1,51 @@
namespace :help do
desc 'ヘルプ移行'
task migrate: :environment do
help_path = ENV.fetch('BTRC_HUB_HELP_PATH')
abort 'ヘルプ置場指定してね.' if help_path.blank?
# ファイルがすでに存在する場合は通さなぃ.
abort 'ファイル消してね.' if Dir.glob("#{ help_path }/*").present?
# 現状物理削除はなぃはず
pages =
WikiPage
.joins(:tag_name)
.where(tag_names: { tag_id: nil, language_code: 'ja' })
.where.not("tag_names.name LIKE 'タググループ:%'")
puts "移行対象: #{pages.count} 件"
abort '移行対象がありません.' if pages.empty?
pages.find_each do |page|
puts page.body
puts page.title
puts 'この identifier は何にする?'
identifier = $stdin.gets.chomp
Dir.mkdir "#{ help_path }/#{ identifier }"
Dir.mkdir "#{ help_path }/#{ identifier }/editions"
Dir.mkdir "#{ help_path }/#{ identifier }/editions/ja"
last_file_name = ''
WikiVersion.where(wiki_page_id: page.id).order(:version_no).each do |ver|
last_file_name, last_file_name_no = last_file_name.split('.')
file_name = ver.created_at.strftime('%Y-%m-%d')
if file_name == last_file_name
file_name += ".#{ (last_file_name_no || 1).to_i + 1 }"
end
body = "# #{ ver.title.sub(/^ヘルプ:/, '') }\n\n#{ ver.body }"
File.write "#{ help_path }/#{ identifier }/editions/ja/#{ file_name }.mdx", body
last_file_name = file_name
end
article_meta = { 'identifier' => identifier,
'canonical_language' => 'ja',
'languages' => { 'ja' => { 'current_edition' => last_file_name } } }
File.write "#{ help_path }/#{ identifier }/article.yml", article_meta.to_yaml
end
end
end
+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)
+84
ファイルの表示
@@ -0,0 +1,84 @@
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
Tag.find_or_create_by_tag_name!(Locale.nipponese, 'shared_id_new', category: :general)
else
ExternalTag.create!(platform: :nico, name: 'shared_id_new')
end
end
[:tag, :external_tag].each do |resource|
it "starts #{ resource } at 1 when both tables are empty" do
expect(Tag.count).to eq(0)
expect(ExternalTag.count).to eq(0)
expect(create_resource(resource).id).to eq(1)
end
it "allocates #{ resource } above the other table's maximum" do
create(:tag, id: resource == :tag ? 1 : 20)
create(:external_tag, id: resource == :external_tag ? 1 : 20)
expect(create_resource(resource).id).to eq(21)
end
it "uses the current maximum after deleting earlier #{ resource } records" do
tag = create(:tag, id: 100)
TagName.where(tag_id: tag.id).update_all(tag_id: nil)
tag.destroy!
create(:external_tag, id: 200).destroy!
expect(Tag.unscoped.count).to eq(0)
expect(ExternalTag.unscoped.count).to eq(0)
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) }
tags = Tag.order(:id).map(&:attributes)
external_tags = ExternalTag.order(:id).map(&:attributes)
record = create_resource(resource)
aggregate_failures do
expect(record.id).to eq(9)
expect(Tag.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(tags)
expect(ExternalTag.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(external_tags)
expect(Tag.exists?(id: 3)).to be(false)
expect(ExternalTag.exists?(id: 2)).to be(false)
end
end
it "ignores the description and group ID space when creating #{ resource }" do
tag = create(:tag, id: 1)
create(:external_tag, id: 2)
TagDescription.create!(id: 100, tag:, language_code: 'ja', body: '', version_no: 1)
seed_group(name: 'separate_space', id: 200)
expect(create_resource(resource).id).to eq(3)
end
end
it 'preserves overlapping IDs during updates without rewriting the other resource' do
tag = create(:tag, id: 5)
external = create(:external_tag, id: 5)
tag.update!(category: :meta)
external.update!(name: 'updated_external')
expect(tag.reload.id).to eq(5)
expect(external.reload.id).to eq(5)
expect(tag.category).to eq('meta')
expect(external.name).to eq('updated_external')
expect(Tag.count).to eq(1)
expect(ExternalTag.count).to eq(1)
end
it 'allocates distinct IDs for successive creations across both tables' do
records = [:tag, :external_tag].map { create_resource(_1) }
expect(records.map(&:id)).to eq([1, 2])
end
end
+50
ファイルの表示
@@ -0,0 +1,50 @@
require 'rails_helper'
RSpec.describe TagRepr do
let(:tag) { create(:tag) }
def description_for record
TagDescription.create!(tag: record, language_code: 'ja', body: '', version_no: 1)
end
it 'keeps has_wiki boolean for the selected language in base and inline' do
description_for(tag)
[:base, :inline].each do |method|
expect(described_class.public_send(method, tag).as_json.fetch('has_wiki')).to be(true)
expect(described_class.public_send(method, tag, language_code: 'en')
.as_json.fetch('has_wiki')).to be(false)
end
end
it 'uses the same language for a tag and its parents' do
parent = create(:tag)
description_for(tag)
description_for(parent)
TagImplication.create!(tag:, parent_tag: parent)
['ja', 'en'].each do |language_code|
row = described_class.base(tag, language_code:).as_json
expected = language_code == 'ja'
expect(row.fetch('has_wiki')).to be(expected)
expect(row.fetch('parents').sole.fetch('has_wiki')).to be(expected)
end
end
it 'preloads existence checks for many tags and their parents without Wiki reads' do
parent = create(:tag)
description_for(parent)
records = Array.new(3) do
create(:tag).tap { TagImplication.create!(tag: _1, parent_tag: parent) }
end
description_for(records.first)
queries = []
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
rows = nil
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
rows = described_class.many(records).as_json
end
expect(rows.map { _1.fetch('has_wiki') }).to eq([true, false, false])
expect(rows.map { _1.fetch('parents').sole.fetch('has_wiki') }).to eq([true] * 3)
expect(queries.grep(/\bSELECT\b.*\btag_descriptions\b/im).size).to be <= 2
expect(queries.grep(/\bwiki_(pages|revisions|versions)\b/i)).to be_empty
end
end
+41
ファイルの表示
@@ -1,6 +1,47 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Tag, type: :model do RSpec.describe Tag, type: :model do
describe '#has_description?' do
let(:tag) { create(:tag) }
it 'checks the requested language and treats an empty description as present' do
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
expect(tag.has_description?(language_code: 'ja')).to be(true)
expect(tag.has_description?(language_code: 'en')).to be(false)
end
it 'returns false without a description' do
expect(tag.has_description?(language_code: 'ja')).to be(false)
end
it 'does not use a Wiki page as evidence of a description' do
user = create_member_user!
Wiki::Commit.create_legacy_content!(tag_name: tag.tag_name('ja'), body: 'legacy',
created_by_user: user)
expect(tag.has_description?(language_code: 'ja')).to be(false)
end
it 'reuses preloaded descriptions without reading Wiki or querying again' do
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
loaded = Tag.includes(:tag_descriptions).find(tag.id)
queries = []
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
expect(loaded.has_description?(language_code: 'ja')).to be(true)
expect(loaded.has_description?(language_code: 'en')).to be(false)
end
expect(queries).to be_empty
end
it 'keeps language selection generic without enabling another Language record' do
tag.association(:tag_descriptions).target = [
TagDescription.new(tag:, language_code: 'en', body: '', version_no: 1)]
tag.association(:tag_descriptions).loaded!
expect(tag.has_description?(language_code: 'en')).to be(true)
expect(tag.has_description?(language_code: 'ja')).to be(false)
end
end
describe 'external tag separation' do describe 'external tag separation' do
['nico:reserved', 'NiCo:reserved'].each do |name| ['nico:reserved', 'NiCo:reserved'].each do |name|
it "rejects the reserved prefix #{ name } for internal tags" do it "rejects the reserved prefix #{ name } for internal tags" do
+51
ファイルの表示
@@ -178,6 +178,7 @@ RSpec.describe 'Materials API', type: :request do
end end
describe 'GET /materials/:id' do describe 'GET /materials/:id' do
include WikiRecords
let!(:tag) do let!(:tag) do
create(:tag, primary_name: 'material_show', category: :material) create(:tag, primary_name: 'material_show', category: :material)
end end
@@ -205,6 +206,56 @@ RSpec.describe 'Materials API', type: :request do
get '/materials/999999999' get '/materials/999999999'
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
def get_material_without_legacy_reads
queries = []
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i)
queries << sql
end
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
get "/materials/#{ material.id }"
end
expect(response).to have_http_status(:ok)
expect(queries).to be_empty
end
it 'reads the description even when the legacy revision has different content' do
page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy body',
created_by_user: member_user)
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
.update!(body: 'description body')
get_material_without_legacy_reads
expect(json.fetch('wiki_page_body')).to eq('description body')
expect(page.current_revision.body).to eq('legacy body')
end
it 'uses tag_id rather than description ID without a Wiki page or TagName' do
description = TagDescription.create!(id: tag.id + 1000, tag:, language_code: 'ja',
body: 'independent body', version_no: 1)
tag.tag_names.update_all(tag_id: nil)
get_material_without_legacy_reads
expect(description.id).not_to eq(tag.id)
expect(json.fetch('wiki_page_body')).to eq('independent body')
expect(json.fetch('tag').fetch('has_wiki')).to be(true)
end
it 'returns nil when only legacy Wiki content exists' do
seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy only',
created_by_user: member_user)
TagDescription.where(tag_id: tag.id).delete_all
get_material_without_legacy_reads
expect(json.fetch('wiki_page_body')).to be_nil
expect(json.fetch('tag').fetch('has_wiki')).to be(false)
end
it 'returns nil for an untagged material' do
material.update!(tag: nil)
get_material_without_legacy_reads
expect(json.fetch('wiki_page_body')).to be_nil
end
end end
describe 'POST /materials' do describe 'POST /materials' do
+7 -6
ファイルの表示
@@ -1,16 +1,17 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag description Wiki compatibility', type: :request do RSpec.describe 'Tag description Wiki compatibility', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'description_contract') } let!(:tag_name) { create(:tag_name, name: 'description_contract') }
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( seed_wiki_page(
tag_name:, body: 'old-revision', created_by_user: user) tag_name:, body: 'old-revision', created_by_user: user)
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) }
@@ -242,8 +243,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
end end
describe 'mixed changes' do describe 'mixed changes' do
it 'includes unmigrated history and ignores migrated legacy-only revisions' do it 'excludes unmigrated history and migrated legacy-only revisions' do
legacy_page = Wiki::Commit.create_content!( legacy_page = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help_mixed'), tag_name: create(:tag_name, name: 'Help_mixed'),
body: 'help', created_by_user: user) body: 'help', created_by_user: user)
legacy_revision = legacy_page.current_revision legacy_revision = legacy_page.current_revision
@@ -256,7 +257,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id } migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id) expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id)
expect(json).to include(include( expect(json).not_to include(include(
'revision_id' => legacy_revision.id, 'revision_id' => legacy_revision.id,
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed'))) 'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
end end
+60 -53
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag description Wiki dual writes', type: :request do RSpec.describe 'Tag description Wiki dual writes', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'description_write') } let!(:tag_name) { create(:tag_name, name: 'description_write') }
let(:tag) { create(:tag, primary_tag_name: tag_name) } let(:tag) { create(:tag, primary_tag_name: tag_name) }
@@ -31,7 +32,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
headers: headers headers: headers
end end
it 'creates both current records and histories with a shared revision ID' do it 'creates a description with matching Wiki content and histories' do
expect { create_description } expect { create_description }
.to change(WikiPage, :count).by(1) .to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1) .and change(WikiRevision, :count).by(1)
@@ -39,17 +40,28 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
.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)
page = WikiPage.find(json.fetch('id'))
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja') expect(description).to have_attributes(body: 'initial', version_no: 1)
expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1) page = WikiPage.find_by!(tag_name:)
expect(version).to have_attributes(
id: page.current_revision.id, body: 'initial', version_no: 1,
event_type: 'create', created_by_user_id: user.id)
expect(page.body).to eq(description.body) expect(page.body).to eq(description.body)
expect(page.current_revision.body).to eq(version.body) expect(page.current_revision.body).to eq(description.body)
expect(page.wiki_versions.last.body).to eq(version.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 end
['missing_description', 'orphan_description'].each do |title| ['missing_description', 'orphan_description'].each do |title|
@@ -65,48 +77,19 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
end end
end end
it 'creates a namespaced legacy Wiki resource without a Tag' do [false, true].each do |linked|
expect do it "rejects namespaced creation with Tag present: #{ linked }" do
post '/wiki', params: { title: 'Help:description_write', body: 'legacy' }, name = create(:tag_name, name: 'Help:frozen')
headers: headers create(:tag, primary_tag_name: name) if linked
end.to change(WikiPage, :count).by(1) before_state = stored_state
.and change(WikiRevision, :count).by(1) post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers
.and change(WikiVersion, :count).by(1) expect(response.status).to be_between(400, 499)
.and change(TagDescription, :count).by(0) expect(stored_state).to eq(before_state)
.and change(TagDescriptionVersion, :count).by(0)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
expect(page.tag_name.tag).to be_nil
expect(page.body).to eq('legacy')
expect(page.current_revision.body).to eq('legacy')
end end
it 'creates only legacy Wiki for a namespaced title with an existing Tag' do
namespaced_name = create(:tag_name, name: 'help:foo')
namespaced_tag = create(:tag, primary_tag_name: namespaced_name)
expect do
post '/wiki', params: { title: namespaced_name.name, body: 'legacy' },
headers: headers
end.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagName, :count).by(0)
.and change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
expect(page).to have_attributes(tag_name_id: namespaced_name.id, body: 'legacy')
expect(page.tag_name.tag).to eq(namespaced_tag)
expect(page.current_revision.body).to eq('legacy')
expect(page.wiki_versions.last.body).to eq('legacy')
end end
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back both systems when #{ model.name } persistence fails" do it "rolls back new description creation when #{ model.name } persistence fails" do
tag_name
before_state = stored_state before_state = stored_state
aggregate_failures do aggregate_failures do
fail_save(model) { create_description } fail_save(model) { create_description }
@@ -116,10 +99,32 @@ 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
Wiki::Commit.create_content!( seed_wiki_page(
tag_name:, body: 'before', created_by_user: user) tag_name:, body: 'before', created_by_user: user)
end end
let!(:first_revision) { page.current_revision } let!(:first_revision) { page.current_revision }
@@ -138,7 +143,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
expect(page.reload.version_no).to eq(1) expect(page.reload.version_no).to eq(1)
expect(description.version_no).to eq(1) expect(description.version_no).to eq(1)
expect { update_description } expect { update_description }
.to change(WikiRevision, :count).by(1) .to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -157,7 +163,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
tag_name.update_columns(tag_id: nil) tag_name.update_columns(tag_id: nil)
expect { update_description } expect { update_description }
.to change(WikiRevision, :count).by(1) .to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1)
@@ -177,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)
+11 -2
ファイルの表示
@@ -206,7 +206,13 @@ RSpec.describe 'Tag description native API', type: :request do
context 'without an existing description' do context 'without an existing description' do
before { tag } before { tag }
it 'creates both current resources and histories with matching compatibility IDs' do it 'creates a description with synchronised Wiki content and histories' do
unrelated = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:unrelated_native_description'),
body: 'unrelated body', created_by_user: author)
unrelated_state = [unrelated.attributes,
unrelated.wiki_revisions.map(&:attributes),
unrelated.wiki_versions.map(&:attributes)]
expect { native_write(body: 'created natively') } expect { native_write(body: 'created natively') }
.to change(TagDescription, :count).by(1) .to change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1)
@@ -215,7 +221,7 @@ RSpec.describe 'Tag description native API', type: :request do
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :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')
page = WikiPage.find(description.id) page = WikiPage.find_by!(tag_name: tag.tag_name('ja'))
revision = page.current_revision revision = page.current_revision
version = TagDescriptionVersion.find(revision.id) version = TagDescriptionVersion.find(revision.id)
expect(description).to have_attributes( expect(description).to have_attributes(
@@ -227,6 +233,9 @@ RSpec.describe 'Tag description native API', type: :request do
expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body) expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body)
expect(revision.body).to eq(version.body) expect(revision.body).to eq(version.body)
expect(page.wiki_versions.last.body).to eq(version.body) expect(page.wiki_versions.last.body).to eq(version.body)
unrelated.reload
expect([unrelated.attributes, unrelated.wiki_revisions.map(&:attributes),
unrelated.wiki_versions.map(&:attributes)]).to eq(unrelated_state)
expect_current(description) expect_current(description)
get "/wiki/#{ description.id }" get "/wiki/#{ description.id }"
+27 -22
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag group Wiki facade reads', type: :request do RSpec.describe 'Tag group Wiki facade reads', type: :request do
include WikiRecords
include TagGroupRecords include TagGroupRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
@@ -69,34 +70,36 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end end
end end
it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do
help = Wiki::Commit.create_legacy_content!( help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user) tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user)
get '/wiki' get '/wiki'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include( expect(json.map { _1.fetch('title') }).to include(
'タググループ:読取り契約', help.title, '新タグ') 'タググループ:読取り契約', '新タグ')
expect(json.map { _1.fetch('title') }).not_to include(help.title)
end end
it 'keeps Help readable when its ID collides with a new group' do it 'excludes Help when its ID collides with a new group' do
help = Wiki::Commit.create_legacy_content!( help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body', tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
created_by_user: user) created_by_user: user)
seed_group(name: '別 ID重複', id: help.id, description: 'group body') seed_group(name: '別 ID重複', id: help.id, description: 'group body')
get '/wiki' get '/wiki'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include(help.title, 'タググループ:別 ID重複') expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複')
expect(json.map { _1.fetch('title') }).not_to include(help.title)
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }" get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:not_found)
expect(json.fetch('body')).to eq('help body')
get '/wiki/changes' get '/wiki/changes'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.map { _1.dig('wiki_page', 'title') }).to include( expect(json.map { _1.dig('wiki_page', 'title') }).to include(
help.title, 'タググループ:別 ID重複') 'タググループ:別 ID重複')
expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title)
end end
it 'finds groups by a partial prefix of their displayed title' do it 'finds groups by a partial prefix of their displayed title' do
@@ -122,7 +125,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end end
it 'orders mixed resource history by timestamp rather than independent IDs' do it 'orders mixed resource history by timestamp rather than independent IDs' do
help = Wiki::Commit.create_legacy_content!( help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help', tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
created_by_user: user) created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
@@ -136,8 +139,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) } timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
expect(timestamps).to eq(timestamps.sort.reverse) expect(timestamps).to eq(timestamps.sort.reverse)
titles = json.map { _1.dig('wiki_page', 'title') } titles = json.map { _1.dig('wiki_page', 'title') }
expect(titles.index('新タグ')).to be < titles.index(help.title) expect(titles).not_to include(help.title)
expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約') expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
end end
it 'renders nullable group editors without raising' do it 'renders nullable group editors without raising' do
@@ -166,16 +169,16 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約')) 'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
end end
it 'includes Help and group history but excludes legacy-only migrated revisions' do it 'includes group history but excludes all legacy-only revisions' do
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user) extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
help = Wiki::Commit.create_legacy_content!( help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user) tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
get '/wiki/changes' get '/wiki/changes'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' } rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' }
expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id]) expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id) expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
expect(json).to include(include( expect(json).not_to include(include(
'revision_id' => help.current_revision.id, 'revision_id' => help.current_revision.id,
'wiki_page' => include('title' => help.title))) 'wiki_page' => include('title' => help.title)))
end end
@@ -214,7 +217,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
context 'when Help, description and group share the same numeric ID' do context 'when Help, description and group share the same numeric ID' do
let(:colliding_help) do let(:colliding_help) do
Wiki::Commit.create_legacy_content!( seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:三者ID衝突'), tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
body: 'help', created_by_user: user) body: 'help', created_by_user: user)
end end
@@ -239,13 +242,12 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
colliding_description_version colliding_description_version
end end
it 'lists all three resources without deduplicating their numeric ID' do it 'lists both new resources sharing a numeric ID without legacy Help' do
get '/wiki' get '/wiki'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('id') == colliding_help.id } rows = json.select { _1.fetch('id') == colliding_help.id }
expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly( expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly(
[colliding_help.id, colliding_help.title],
[colliding_description.id, '新タグ'], [colliding_description.id, '新タグ'],
[colliding_group_id, 'タググループ:三者ID衝突']) [colliding_group_id, 'タググループ:三者ID衝突'])
end end
@@ -259,7 +261,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')] [row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
end end
expect(histories).to contain_exactly( expect(histories).to contain_exactly(
[colliding_help.current_revision.id, colliding_help.title],
[colliding_description_version.id, '新タグ'], [colliding_description_version.id, '新タグ'],
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突']) [group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
end end
@@ -285,8 +286,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end end
end end
it 'resolves colliding group, Help and description IDs by title' do it 'resolves new resources by title while excluding colliding legacy Help' do
help = Wiki::Commit.create_legacy_content!( help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user) tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
seed_group(name: 'ID衝突', id: help.id, description: 'group') seed_group(name: 'ID衝突', id: help.id, description: 'group')
TagDescription.create!( TagDescription.create!(
@@ -294,11 +295,15 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
TagDescriptionVersion.create!( TagDescriptionVersion.create!(
tag_id: new_tag.id, language_code: 'ja', version_no: 1, tag_id: new_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'description', created_at: Time.current) event_type: 'create', body: 'description', created_at: Time.current)
{ 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => 'description' } { 'タググループ:ID衝突' => 'group', '新タグ' => 'description' }
.each do |title, body| .each do |title, body|
get "/wiki/title/#{ CGI.escapeURIComponent(title) }" get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq(body) expect(json.fetch('body').strip).to eq(body)
end end
without_group_legacy_reads do
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
end
expect(response).to have_http_status(:not_found)
end end
end end
+9 -6
ファイルの表示
@@ -33,8 +33,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
end end
end end
it 'creates the group, first snapshot and compatibility Wiki history' do it 'creates the group, snapshot and matching compatibility histories' do
create_group expect { create_group }
.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(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' => 'タググループ:新規')
@@ -43,10 +46,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
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: 'タググループ:新規' }) page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
expect(page.body.strip).to eq('- 洗澡鹿') expect(page.body).to eq('- 洗澡鹿')
expect(page.current_revision.body).to eq(page.body) expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.sole.body).to eq(page.body) expect(page.wiki_versions.sole.body).to eq(page.body)
# New IDs are independently allocated; no global identity assertion here.
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
@@ -293,8 +295,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
expect(group_state).to eq(before_state) expect(group_state).to eq(before_state)
end end
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions']) tables = TagGroupRecords::GROUP_TABLES
.each do |table| tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions']
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) }
end end
+20 -18
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag group write integrity', type: :request do RSpec.describe 'Tag group write integrity', type: :request do
include WikiRecords
include TagGroupRecords include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers include ActiveSupport::Testing::TimeHelpers
@@ -24,7 +25,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
end end
def create_help body def create_help body
Wiki::Commit.create_legacy_content!( seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user) tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
end end
@@ -70,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
@@ -98,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')
@@ -115,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
@@ -136,14 +138,14 @@ RSpec.describe 'Tag group write integrity', type: :request do
id = seed_group(name: '更新', id: help.id, members:, placements:) id = seed_group(name: '更新', id: help.id, members:, placements:)
help_before = help.attributes help_before = help.attributes
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(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' }) expect(TagGroup.find(id).name).to eq('改名')
expect(page.id).not_to eq(help.id)
expect(page.body).to eq('- 洗澡鹿')
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.sole.body).to eq(page.body)
expect(help.reload.attributes).to eq(help_before) expect(help.reload.attributes).to eq(help_before)
end end
+21 -11
ファイルの表示
@@ -391,6 +391,17 @@ RSpec.describe 'Tag group native API', type: :request do
end end
end end
it 'creates native state and its compatibility histories' do
expect { native_write(:create) }
.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
[:create, :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)
@@ -418,19 +429,18 @@ RSpec.describe 'Tag group native API', type: :request do
end end
end end
it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do it 'assigns the next group ID from the existing maximum' do
inserts = [] child_id
subscriber = lambda do |*args| expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1
sql = args.last.fetch(:sql)
inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i)
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
native_write(:create) native_write(:create)
end
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
expect(inserts.size).to eq(1) expect(json.fetch('id')).to eq(expected_id)
columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') } end
expect(columns).not_to include('id')
TagGroupRecords::GROUP_TABLES.each do |table|
it "rolls back native creation after #{ table } writes" do
expect_atomic_group_write(table) { native_write(:create) }
end
end end
it 'updates a native group through Wiki and then reads structured current data' do it 'updates a native group through Wiki and then reads structured current data' do
+101
ファイルの表示
@@ -0,0 +1,101 @@
require 'rails_helper'
RSpec.describe 'Tag resource ID allocation', type: :request do
include TagGroupRecords
include WikiRecords
let!(:user) { create_member_user! }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
def seed_description id
tag = create(:tag, primary_name: "description_id_#{ id }")
TagDescription.create!(id:, tag:, language_code: 'ja', body: 'existing', version_no: 1)
end
def create_resource resource
if resource == :description
tag = create(:tag, primary_name: 'new_description')
post '/wiki', params: { title: tag.name('ja'), body: 'new description' }, headers: headers
else
post '/tag_groups', params: {
name: 'new_group', description: 'new group', members: [], placements: [] },
headers:, as: :json
end
expect(response).to have_http_status(:created)
end
[:description, :group].each do |resource|
it "keeps #{ resource } independent of the Tag and ExternalTag ID space" do
create(:tag, id: 100)
create(:external_tag, id: 200)
seed_description(1)
seed_group(name: 'shared_space', id: 2)
create_resource(resource)
expect(json.fetch('id')).to eq(3)
end
it "uses the other resource's larger maximum when allocating #{ resource }" do
seed_description(resource == :description ? 1 : 20)
seed_group(name: 'other_space', id: resource == :group ? 1 : 20)
create_resource(resource)
expect(json.fetch('id')).to eq(21)
end
it "starts #{ resource } at 1 even after previous allocated rows were removed" do
expect(TagDescription.count).to eq(0)
expect(TagGroup.count).to eq(0)
if resource == :description
seed_description(100).destroy!
else
id = seed_group(name: 'removed', id: 100)
TagGroup.find(id).destroy!
end
create_resource(resource)
expect(json.fetch('id')).to eq(1)
end
it "allocates #{ resource } above both maxima, preserving legacy overlaps and gaps" do
[1, 2, 5].each { seed_description(_1) }
[1, 3, 8].each { seed_group(name: "group_#{ _1 }", id: _1) }
descriptions = TagDescription.order(:id).map(&:attributes)
groups = TagGroup.order(:id).map(&:attributes)
create_resource(resource)
expect(json.fetch('id')).to eq(9)
expect(TagDescription.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(descriptions)
expect(TagGroup.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(groups)
expect(TagDescription.exists?(id: 3)).to be(false)
expect(TagGroup.exists?(id: 2)).to be(false)
end
end
it 'preserves group ID and unrelated rows on update' do
id = seed_group(name: 'existing', id: 5, description: 'before')
other_id = seed_group(name: 'other', id: 8, description: 'other')
other = TagGroup.find(other_id).attributes
put "/tag_groups/#{ id }", params: {
name: 'renamed', description: 'after', members: [], placements: [] }, headers:, as: :json
expect(response).to have_http_status(:ok)
expect(json.fetch('id')).to eq(id)
expect(TagGroup.find(id).description).to eq('after')
expect(TagGroup.find(other_id).attributes).to eq(other)
expect(TagGroup.count).to eq(2)
end
it 'preserves description identity on a compatibility update' do
tag = create(:tag, primary_name: 'existing_description')
page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
id = description.id
put "/wiki/#{ id }", params: {
title: tag.name('ja'), body: 'after', base_revision_id: page.current_revision.id },
headers: headers
expect(response).to have_http_status(:ok)
expect(description.reload.id).to eq(id)
expect(description.body).to eq('after')
expect(TagDescription.count).to eq(1)
end
end
+2 -1
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag and wiki history integrity', type: :request do RSpec.describe 'Tag and wiki history integrity', type: :request do
include WikiRecords
let(:member_user) { create(:user, role: 'member') } let(:member_user) { create(:user, role: 'member') }
def stub_current_user user def stub_current_user user
@@ -13,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: primary_tag_name_for(tag, 'ja'), tag_name: primary_tag_name_for(tag, 'ja'),
body:, body:,
created_by_user: user, created_by_user: user,
+16
ファイルの表示
@@ -38,6 +38,22 @@ RSpec.describe 'Tags API', type: :request do
end end
describe 'GET /tags' do describe 'GET /tags' do
it 'serialises descriptions and parents without loading legacy Wiki pages' do
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
TagDescription.create!(tag: tag2, language_code: 'ja', body: '', version_no: 1)
TagImplication.create!(tag:, parent_tag: tag2)
queries = []
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
get '/tags', params: { name: 'spec_tag' }
end
expect(response).to have_http_status(:ok)
row = response_tags.sole
expect(row.fetch('has_wiki')).to be(true)
expect(row.fetch('parents').sole.fetch('has_wiki')).to be(true)
expect(queries.grep(/\bSELECT\b.*\bwiki_(pages|revisions|versions)\b/im)).to be_empty
end
it 'includes legacy external JSON alongside an internal tag with the same id' do it 'includes legacy external JSON alongside an internal tag with the same id' do
external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3) external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3)
+5 -6
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Wiki conflict handling', type: :request do RSpec.describe 'Wiki conflict handling', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
@@ -9,8 +10,8 @@ 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 =
Wiki::Commit.create_content!( 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')
@@ -25,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)
+63
ファイルの表示
@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.describe 'Wiki creation freeze', type: :request do
include TagGroupRecords
include WikiRecords
let!(:user) { create_member_user! }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
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(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
let!(:tag) { create(:tag, primary_name: 'protected_description') }
let!(:page) do
seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
end
[nil, :guest, :banned].each do |role|
it "rejects #{ role || 'anonymous' } without changing either history" do
actor = role && create(:user, role == :banned ? :member : role)
actor.update!(banned_at: Time.current) if role == :banned
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
before_state = group_state
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
headers: request_headers
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
expect(group_state).to eq(before_state)
end
end
it 'rejects banned IPs without writes' do
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
before_state = group_state
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' }, headers: headers
expect(response).to have_http_status(:forbidden)
expect(group_state).to eq(before_state)
end
['', "\r\n\r\n"].each do |body|
it "rejects invalid description body #{ body.inspect } atomically" do
before_state = group_state
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: }, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
end
end
+18 -46
ファイルの表示
@@ -2,6 +2,7 @@ require 'cgi'
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Wiki history integrity', type: :request do RSpec.describe 'Wiki history integrity', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
@@ -9,49 +10,22 @@ RSpec.describe 'Wiki history integrity', type: :request do
end end
def create_wiki_page title:, body: 'body', message: 'init', user: self.user def create_wiki_page title:, body: 'body', message: 'init', user: self.user
Wiki::Commit.create_content!( 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:)
end end
describe 'POST /wiki' do describe 'POST /wiki' do
it 'creates wiki_page, wiki_revision, and wiki_version atomically' do it 'rejects frozen creation without partial page or history' do
expect { expect do
post '/wiki', post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' },
params: {
title: 'Help:wiki_history_create_atomic',
body: "a\nb\nc",
message: 'initial commit',
},
headers: auth_headers(user) headers: auth_headers(user)
} end.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.status).to be_between(400, 499)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('Help:wiki_history_create_atomic')
expect(page.body).to eq("a\nb\nc")
expect(revision).to be_content
expect(revision.message).to eq('initial commit')
expect(revision.lines_count).to eq(3)
expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'Help:wiki_history_create_atomic',
body: "a\nb\nc",
reason: 'initial commit',
created_by_user_id: user.id
)
end end
it 'returns 422 and creates nothing when normalised body is blank' do it 'returns 422 and creates nothing when normalised body is blank' do
@@ -71,21 +45,19 @@ RSpec.describe 'Wiki history integrity', type: :request do
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist .where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
end end
it 'returns 422 and creates no partial page when title already exists' do it 'rejects frozen creation without modifying an existing page or history' do
create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first') create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first')
expect { expect do
post '/wiki', post '/wiki',
params: { params: { title: 'Help:wiki_history_duplicate_title',
title: 'Help:wiki_history_duplicate_title', body: 'second', message: 'duplicate' },
body: 'second',
message: 'duplicate',
},
headers: auth_headers(user) headers: auth_headers(user)
} end.to change(WikiPage, :count).by(0)
.not_to change(WikiPage, :count) .and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:unprocessable_entity) expect(response.status).to be_between(400, 499)
expect(WikiPage.joins(:tag_name) expect(WikiPage.joins(:tag_name)
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1) .where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
end end
+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
+20 -97
ファイルの表示
@@ -4,6 +4,7 @@ require 'securerandom'
RSpec.describe 'Wiki API', type: :request do RSpec.describe 'Wiki API', type: :request do
include WikiRecords
def auth_headers(user) def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code } { 'X-Transfer-Code' => user.inheritance_code }
end end
@@ -18,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do
deprecated_at: Time.zone.local(2026, 6, 1)) deprecated_at: Time.zone.local(2026, 6, 1))
end end
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: tn, tag_name: tn,
body: 'init', body: 'init',
created_by_user: user, created_by_user: user,
@@ -108,94 +109,14 @@ RSpec.describe 'Wiki API', type: :request do
end end
end end
context 'when success' do ['Help:TestPage', 'ヘルプ:ホーム'].each do |title|
it 'creates wiki_page and first content revision' do it "rejects frozen creation of #{ title } without history writes" do
expect do expect do
post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' }, post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member)
headers: auth_headers(member) end.to change(WikiPage, :count).by(0)
end .and change(WikiRevision, :count).by(0)
.to change(WikiPage, :count).by(1) .and change(WikiVersion, :count).by(0)
.and change(WikiRevision, :count).by(1) expect(response.status).to be_between(400, 499)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
page_id = json.fetch('id')
expect(json.fetch('title')).to eq('Help:TestPage')
created_page = WikiPage.find(page_id)
version = created_page.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'Help:TestPage',
body: "a\nb\nc",
created_by_user_id: member.id
)
rev = created_page.current_revision
expect(rev).to be_present
expect(rev).to be_content
expect(rev.message).to eq('init')
expect(created_page.body).to eq("a\nb\nc")
expect(rev.lines_count).to eq(3)
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c'])
end
it 'reuses existing WikiLine rows by sha256' do
# 先に同じ行を作っておく
WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current)
post endpoint,
params: { title: 'Help:Reuse', body: "a\na" },
headers: auth_headers(member)
page = WikiPage.find(JSON.parse(response.body).fetch('id'))
rev = page.current_revision
expect(rev.lines_count).to eq(2)
# "a" の WikiLine が増殖しない(1行のはず)
expect(WikiLine.where(body: 'a').count).to eq(1)
end
it 'deduplicates duplicated new lines before upsert' do
duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
post endpoint,
params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
headers: auth_headers(member)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
rev = page.current_revision
expect(rev.lines_count).to eq(2)
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(rev.wiki_revision_lines.count).to eq(2)
expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
end
it 'normalises CRLF and strips trailing newlines' do
post endpoint,
params: { title: 'Help:NormalisedBody',
body: "a\r\nb\r\n\r\n", message: 'normalise' },
headers: auth_headers(member)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
rev = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(version.body).to eq("a\nb")
expect(rev.lines_count).to eq(2)
expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
end end
end end
end end
@@ -208,10 +129,10 @@ 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
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: test_tag_name, tag_name: test_tag_name,
body: "a\nb", body: "a\nb",
created_by_user: member, created_by_user: member,
@@ -249,7 +170,8 @@ RSpec.describe 'Wiki API', type: :request do
expect do expect do
put "/wiki/#{page.id}", put "/wiki/#{page.id}",
params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, params: { title: 'TestPage', body: "x\ny", message: 'edit',
base_revision_id: current_id },
headers: auth_headers(member) headers: auth_headers(member)
end end
.to change(WikiRevision, :count).by(1) .to change(WikiRevision, :count).by(1)
@@ -284,7 +206,7 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: member) created_by_user: member)
linked_page = linked_page =
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: linked_tag_name, tag_name: linked_tag_name,
body: 'before', body: 'before',
created_by_user: member, created_by_user: member,
@@ -358,13 +280,14 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do describe 'GET /wiki/search' do
before do before do
Wiki::Commit.create_content!( search_tag = create(:tag, primary_name: 'spec_wiki_title_2')
tag_name: create(:tag_name, name: 'spec_wiki_title_2'), seed_wiki_page(
tag_name: search_tag.tag_name('ja'),
body: 'search body 2', body: 'search body 2',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: create(:tag_name, name: 'unrelated_title'), tag_name: create(:tag_name, name: 'unrelated_title'),
body: 'unrelated body', body: 'unrelated body',
created_by_user: user, created_by_user: user,
@@ -516,7 +439,7 @@ RSpec.describe 'Wiki API', type: :request do
it 'raises because redirect revisions are deprecated' do it 'raises because redirect revisions are deprecated' do
target_tag_name = create(:tag_name, name: 'redirect_deprecated_target') target_tag_name = create(:tag_name, name: 'redirect_deprecated_target')
target = target =
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: target_tag_name, tag_name: target_tag_name,
body: 'target', body: 'target',
created_by_user: user, created_by_user: user,
@@ -539,7 +462,7 @@ RSpec.describe 'Wiki API', type: :request do
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general)
linked_page = linked_page =
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: linked_tag_name, tag_name: linked_tag_name,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+10 -11
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Wiki title collision', type: :request do RSpec.describe 'Wiki title collision', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
@@ -8,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do
end end
def create_wiki_page title:, body: def create_wiki_page title:, body:
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
@@ -43,20 +44,18 @@ RSpec.describe 'Wiki title collision', type: :request do
expect(source.wiki_versions.count).to eq(source_version_count) expect(source.wiki_versions.count).to eq(source_version_count)
end end
it 'returns 422 when creating wiki with existing title' do it 'rejects frozen creation with an existing title without adding history' do
create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists') create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists')
expect { expect do
post '/wiki', post '/wiki',
params: { params: { title: 'Help:wiki_collision_create', body: 'new body',
title: 'Help:wiki_collision_create', message: 'duplicate create' },
body: 'new body',
message: 'duplicate create',
},
headers: auth_headers(user) headers: auth_headers(user)
} end.to change(WikiPage, :count).by(0)
.not_to change(WikiPage, :count) .and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:unprocessable_entity) expect(response.status).to be_between(400, 499)
end end
end end
+44 -81
ファイルの表示
@@ -1,115 +1,78 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Unmigrated Wiki compatibility', type: :request do RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'Help_contract') } let!(:tag_name) { create(:tag_name, name: 'Help_contract') }
let!(:page) do let!(:page) do
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user) seed_wiki_page(tag_name:, body: 'before', created_by_user: user)
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
it 'shows legacy current and historical content by ID' do include TagGroupRecords
get "/wiki/#{ page.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id,
'pred' => first_revision.id, 'succ' => nil)
get "/wiki/#{ page.id }", params: { version: first_revision.id }
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id,
'pred' => nil, 'succ' => last_revision.id)
end
it 'shows legacy current and historical content by title' do [:id, :title].each do |lookup|
get "/wiki/title/#{ tag_name.name }" [nil, :historical].each do |edition|
expect(response).to have_http_status(:ok) it "rejects legacy #{ lookup } #{ edition || 'current' } without legacy SELECTs" do
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id) path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id } params = edition ? { version: first_revision.id } : { }
expect(response).to have_http_status(:ok) without_group_legacy_reads { get path, params: }
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id)
end
it 'recognises legacy IDs' do
get "/wiki/#{ page.id }/exists"
expect(response).to have_http_status(:no_content)
end
it 'recognises legacy titles' do
get "/wiki/title/#{ tag_name.name }/exists"
expect(response).to have_http_status(:no_content)
end
it 'diffs legacy revisions with explicit and current target' do
[last_revision.id, nil].each do |to|
get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: }
expect(response).to have_http_status(:ok)
expect(json).to include(
'wiki_page_id' => page.id, 'title' => tag_name.name,
'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id,
'diff' => [{ 'type' => 'removed', 'content' => 'before' },
{ 'type' => 'added', 'content' => 'after' }])
end
end
describe 'revision resource scope' do
let!(:other_page) do
Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'Help_other_contract'),
body: 'other page history', created_by_user: user)
end
let(:other_revision) { other_page.current_revision }
it 'returns 404 when show specifies another page revision' do
get "/wiki/#{ page.id }", params: { version: other_revision.id }
expect(response).to have_http_status(:not_found)
end
it 'returns 404 when diff from belongs to another page' do
get "/wiki/#{ page.id }/diff",
params: { from: other_revision.id, to: last_revision.id }
expect(response).to have_http_status(:not_found)
end
it 'returns 404 when diff to belongs to another page' do
get "/wiki/#{ page.id }/diff",
params: { from: first_revision.id, to: other_revision.id }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
it 'retains legacy history metadata and ordering' do it "does not recognise legacy #{ lookup } existence" do
get '/wiki/changes', params: { id: page.id } path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
without_group_legacy_reads { get "#{ path }/exists" }
expect(response).to have_http_status(:not_found)
end
end
['/wiki', '/wiki/search', '/wiki/changes'].each do |path|
it "excludes unmigrated content from #{ path } without legacy SELECTs" do
without_group_legacy_reads { get path }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('revision_id') }).to eq( expect(json).to eq([])
[last_revision.id, first_revision.id]) end
expect(json.first).to include( end
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3), it 'returns empty scoped history without legacy SELECTs' do
'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil }, without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
'user' => { 'id' => user.id, 'name' => user.name }) expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
[false, true].each do |explicit|
it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do
params = { from: first_revision.id }
params[:to] = last_revision.id if explicit
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
expect(response).to have_http_status(:not_found)
end
end end
end end
+143
ファイルの表示
@@ -0,0 +1,143 @@
require 'rails_helper'
require 'timeout'
RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
self.use_transactional_tests = false
before do
expect(Rails.env.test?).to be(true)
expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i)
expect(ApplicationRecord.connection_pool.size).to be >= 3
@prefix = "shared_ids_#{ SecureRandom.hex(8) }"
@names = [0, 1].map { create(:tag_name, name: "#{ @prefix }_#{ _1 }") }
@tag = Tag.create!(tag_name_id: @names.first.id, category: :general)
@names.first.update!(tag_id: @tag.id)
@threads = []
@release = Queue.new
end
after do
@release << true if @release
begin
Array(@threads).each do |thread|
raise 'Shared ID worker did not finish' unless thread.join(15)
end
ensure
if @prefix
ExternalTag.where(name: "#{ @prefix }_external").delete_all
TagGroup.where(name: "#{ @prefix }_group").delete_all
end
TagDescription.where(tag_id: @tag.id).delete_all if @tag
if @names
ids = @names.map(&:id)
TagName.where(id: ids).update_all(tag_id: nil)
Tag.where(tag_name_id: ids).delete_all
TagName.where(id: ids).delete_all
end
end
end
def await_queue queue
Timeout.timeout(15) { queue.pop }
end
def worker &block
Thread.new do
Thread.current.report_on_exception = false
ApplicationRecord.connection_pool.with_connection(&block)
end.tap { @threads << _1 }
end
def create_resource kind
case kind
when :tag
Tag.create!(tag_name_id: @names.last.id, category: :general)
when :external
ExternalTag.create!(platform: :nico, name: "#{ @prefix }_external")
when :description
TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1)
when :group
TagGroup.create!(name: "#{ @prefix }_group", version_no: 1)
end
end
def hold_creation kind, rollback: false
ready = Queue.new
thread = worker do |connection|
id = nil
connection.transaction do
lock_name = nil
subscriber = lambda do |*args|
match = args.last.fetch(:sql).match(/GET_LOCK\('([^']+)'/)
lock_name = match[1] if match
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
id = create_resource(kind).id
end
ready << [id, connection.select_value('SELECT CONNECTION_ID()').to_i, lock_name]
await_queue(@release)
raise ActiveRecord::Rollback if rollback
end
id
end
[thread, await_queue(ready)]
end
def result thread
raise 'Shared ID worker did not finish' unless thread.join(15)
thread.value
end
{ tags: [:external, :tag], wiki: [:description, :group] }.each do |space, kinds|
[false, true].each do |rollback|
it "serialises #{ space } inserts through #{ rollback ? 'rollback' : 'commit' }" do
tables = SharedResourceIdAllocator::SPACES.fetch(space.to_s)
maxima = tables.map do |table|
ApplicationRecord.connection.select_value("SELECT MAX(id) FROM #{ table }").to_i
end
first, (first_id, connection_id, lock_name) = hold_creation(kinds.first, rollback:)
expect(first_id).to eq(maxima.max + 1)
connection = ApplicationRecord.connection
quoted_lock = connection.quote(lock_name)
expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i)
.to eq(connection_id)
started = Queue.new
second = worker do |other|
started << other.select_value('SELECT CONNECTION_ID()').to_i
create_resource(kinds.last).id
end
expect(await_queue(started)).not_to eq(connection_id)
@release << true
expect(result(first)).to eq(first_id)
expect(result(second)).to eq(rollback ? first_id : first_id + 1)
expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i).to eq(1)
end
end
end
it 'allows the other ID space to commit while the first space remains locked' do
first, (_id, connection_id, lock_name) = hold_creation(:external)
second = worker { create_resource(:group).id }
expect(result(second)).to be_positive
connection = ApplicationRecord.connection
quoted_lock = connection.quote(lock_name)
expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i)
.to eq(connection_id)
@release << true
result(first)
end
it 'raises on a real lock acquisition failure without inserting a record' do
first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true)
stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0)
expect { create_resource(:external) }.to raise_error(ActiveRecord::LockWaitTimeout)
expect(ExternalTag.where(name: "#{ @prefix }_external")).not_to exist
@release << true
result(first)
connection = ApplicationRecord.connection
quoted_lock = connection.quote(lock_name)
expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i)
.to eq(1)
end
end
+88
ファイルの表示
@@ -0,0 +1,88 @@
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
it 'uses the same named lock within each space and different locks between spaces' do
locks = [Tag, ExternalTag, TagDescription, TagGroup].map do |model|
allow(model).to receive(:connection).and_return(connection)
allow(model).to receive(:current_transaction).and_return(transaction)
allow(model).to receive(:uncached).and_yield
record = model.new(id: 50)
described_class.assign!(record)
statement = sql.grep(/GET_LOCK/).last
callbacks.fetch(:after_commit).call
statement
end
expect(locks[0]).to eq(locks[1])
expect(locks[2]).to eq(locks[3])
expect(locks[0]).not_to eq(locks[2])
end
it 'reports lock release failures' do
described_class.assign!(record)
allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil)
expect { callbacks.fetch(:after_commit).call }
.to raise_error(ActiveRecord::ActiveRecordError, /lock release failed/)
end
end
+34 -22
ファイルの表示
@@ -2,41 +2,42 @@ require 'digest'
require 'rails_helper' require 'rails_helper'
RSpec.describe Wiki::Commit do RSpec.describe Wiki::Commit do
include WikiRecords
let(:user) { create_member_user! } let(:user) { create_member_user! }
def create_page title:, body: 'initial body' def create_page title:, body: 'initial body'
described_class.create_content!( 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
it 'creates page, revision, and version with normalised body' do it 'rejects standalone Help creation without adding page or history' do
expect { name = create(:tag_name, name: 'Help:frozen_service')
described_class.create_content!( counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
tag_name: create(:tag_name, name: 'commit_integrity_create'), expect do
body: "a\r\nb\r\n\r\n", described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user)
created_by_user: user, end.to raise_error(ActiveRecord::RecordInvalid)
message: 'init') expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
} end
.to change(WikiPage, :count).by(1)
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(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(1)
expect(page.current_revision.body).to eq('initial')
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' }) expect(page.wiki_versions.sole.body).to eq('initial')
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(revision.lines_count).to eq(2)
expect(version.body).to eq("a\nb")
expect(version.reason).to eq('init')
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!(
@@ -52,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
@@ -80,7 +92,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = create(:tag, primary_tag_name: tag_name, category: :general)
page = page =
described_class.create_content!( seed_wiki_page(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+5 -4
ファイルの表示
@@ -1,12 +1,13 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Wiki::Commit do RSpec.describe Wiki::Commit do
include WikiRecords
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')
Wiki::Commit.create_content!( seed_wiki_page(
tag_name:, tag_name:,
body:, body:,
created_by_user: user, created_by_user: user,
@@ -84,7 +85,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = create(:tag, primary_tag_name: tag_name, category: :general)
page = page =
described_class.create_content!( seed_wiki_page(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
@@ -111,7 +112,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = create(:tag, primary_tag_name: tag_name, category: :general)
page = page =
described_class.create_content!( seed_wiki_page(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+2 -1
ファイルの表示
@@ -1,10 +1,11 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe WikiVersionRecorder do RSpec.describe WikiVersionRecorder do
include WikiRecords
let(:user) { create_member_user! } let(:user) { create_member_user! }
def create_page title:, body: 'body' def create_page title:, body: 'body'
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
+20
ファイルの表示
@@ -0,0 +1,20 @@
# Historical records must remain seedable after application creation is frozen.
module WikiRecords
def seed_wiki_page(tag_name:, body:, created_by_user:, message: nil)
page = WikiPage.create!(tag_name:, body:,
created_user: created_by_user, updated_user: created_by_user)
revision = Wiki::Commit.legacy_content!(
page:, body:, created_user: created_by_user, message:)
if tag_name.tag && tag_name.language_code == 'ja'
TagDescription.create!(
id: page.id, tag: tag_name.tag, language_code: 'ja', body: page.reload.body,
version_no: 1, created_by_user_id: created_by_user.id,
updated_by_user_id: created_by_user.id)
TagDescriptionVersion.create!(
id: revision.id, tag_id: tag_name.tag_id, language_code: 'ja',
version_no: 1, event_type: 'create', body: page.body,
created_by_user_id: created_by_user.id, created_at: revision.created_at)
end
page
end
end
+2
ファイルの表示
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/help
生成ファイル
+4 -43
ファイルの表示
@@ -38,6 +38,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1", "unist-util-visit-parents": "^6.0.1",
"yaml": "^2.9.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
@@ -2060,9 +2061,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2077,9 +2075,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2094,9 +2089,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2111,9 +2103,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2128,9 +2117,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2145,9 +2131,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2162,9 +2145,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2179,9 +2159,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2196,9 +2173,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2213,9 +2187,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2230,9 +2201,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2247,9 +2215,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2264,9 +2229,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -9114,10 +9076,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.3", "version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
+1
ファイルの表示
@@ -42,6 +42,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1", "unist-util-visit-parents": "^6.0.1",
"yaml": "^2.9.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
+112
ファイルの表示
@@ -0,0 +1,112 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { generateHelpAssets } from './help-assets'
import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help'
vi.hoisted (() => {
vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor)
})
afterAll (() => vi.unstubAllGlobals ())
const readAsset = <T,> (modules: Map<string, string>, name: string): T => {
const source = modules.get (`virtual:help/${ name }`)!
const argument = source.slice ('export default JSON.parse('.length, -1)
return JSON.parse (JSON.parse (argument))
}
describe ('Build-time Help assets', () => {
let root: string
beforeEach (async () => {
root = await mkdtemp (path.join (tmpdir (), 'btrc-help-assets-'))
})
afterEach (async () => {
await rm (root, { recursive: true, force: true })
})
const writeArticle = async (
identifier: string,
editions: Record<string, string>,
current = '2026-10-02.10',
) => {
const articleRoot = path.join (root, identifier)
const editionRoot = path.join (articleRoot, 'editions', 'ja')
await mkdir (editionRoot, { recursive: true })
await writeFile (path.join (articleRoot, 'article.yml'), [
`identifier: ${ identifier }`, 'canonical_language: ja', 'title: stale YAML title',
'languages:', ' ja:', ` current_edition: ${ current }`].join ('\n'))
await Promise.all (Object.entries (editions).map (([edition, source]) =>
writeFile (path.join (editionRoot, `${ edition }.mdx`), source)))
}
it ('generates current search and ordered published history from each edition H1', async () => {
await writeArticle ('guide', {
'2026-10-01': '# 旧ホーム\n\n過去本文',
'2026-10-02': '# ホーム\n\n最初の同日版',
'2026-10-02.2': '# ホーム\n\n第二版',
'2026-10-02.3': '# ホーム\n\n第三版',
'2026-10-02.10': '\uFEFF# 最新ホーム\r\n\r\n現在本文\r\n# 別見出し',
'2026-10-02.11': '# 未公開\n\n検索に含めない' })
const { modules } = await generateHelpAssets (root)
const articles = readAsset<Record<string, ArticleMeta>> (modules, 'articles')
expect (articles.guide.languages.ja).toEqual ({
currentEdition: '2026-10-02.10', publishedEditions: [
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] })
expect (readAsset<Record<string, HelpSearchEntry[]>> (modules, 'search').ja).toEqual ([{
identifier: 'guide', edition: '2026-10-02.10', title: '最新ホーム',
body: '\r\n現在本文\r\n# 別見出し' }])
const changes = readAsset<Record<string, HelpChange[]>> (modules, 'changes').ja
expect (changes.map (row => [row.edition, row.title, row.pred, row.change, row.date]))
.toEqual ([
['2026-10-02.10', '最新ホーム', '2026-10-02.3', 'update', '2026-10-02'],
['2026-10-02.3', 'ホーム', '2026-10-02.2', 'update', '2026-10-02'],
['2026-10-02.2', 'ホーム', '2026-10-02', 'update', '2026-10-02'],
['2026-10-02', 'ホーム', '2026-10-01', 'update', '2026-10-02'],
['2026-10-01', '旧ホーム', null, 'new', '2026-10-01']])
const titles = readAsset<Record<string, HelpTitleIndex>> (modules, 'titles').ja
expect (titles).toEqual ({ '旧ホーム': ['guide'], 'ホーム': ['guide'],
'最新ホーム': ['guide'] })
expect (modules.get ('virtual:help/loaders')).not.toContain ('2026-10-02.11')
expect (modules.get ('virtual:help/loaders')).toContain ('2026-10-02.10.mdx?raw')
expect (modules.get ('virtual:help/search')).not.toContain ('import(')
})
it ('retains ambiguous title identifiers and normalises width and case', async () => {
await writeArticle ('alpha', { '2026-10-02': '# HOME\n本文' }, '2026-10-02')
await writeArticle ('beta', { '2026-10-02': '# Home\n本文' }, '2026-10-02')
const { modules } = await generateHelpAssets (root)
expect (readAsset<Record<string, HelpTitleIndex>> (modules, 'titles').ja.home)
.toEqual (['alpha', 'beta'])
expect (readAsset<Record<string, HelpChange[]>> (modules, 'changes').ja
.map (row => row.identifier)).toEqual (['alpha', 'beta'])
})
it ('rejects missing current files during generation', async () => {
await writeArticle ('guide', { '2026-10-02': '# ホーム' })
await expect (generateHelpAssets (root)).rejects.toThrow ('Missing current Help edition')
})
it.each (['本文\n# 遅い見出し', '# \n本文']) (
'rejects missing leading H1 at build time: %s', async source => {
await writeArticle ('guide', { '2026-10-02.10': source })
await expect (generateHelpAssets (root)).rejects.toThrow ('Missing leading Help H1')
})
it ('rejects absent canonical metadata instead of generating incomplete assets', async () => {
await writeArticle ('guide', { '2026-10-02.10': '# ホーム' })
await writeFile (path.join (root, 'guide', 'article.yml'),
'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02.10')
await expect (generateHelpAssets (root)).rejects.toThrow ('Invalid Help metadata')
})
it ('rejects an empty Help checkout', async () => {
await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found')
})
})
+192
ファイルの表示
@@ -0,0 +1,192 @@
import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
import { normalizePath as normalisePath } from 'vite'
import { parse as parseYAML } from 'yaml'
import type { Plugin } from 'vite'
type Language = { currentEdition: string; publishedEditions: string[] }
type Article = {
identifier: string
canonicalLanguage: string
languages: Record<string, Language> }
type Text = { title: string; body: string }
type SearchEntry = Text & { identifier: string; edition: string }
type Change = {
identifier: string
edition: string
title: string
pred: string | null
change: 'new' | 'update'
date: string }
type RawArticle = {
identifier: string
canonical_language: string
languages: Record<string, { current_edition: string }> }
const prefix = 'virtual:help/'
const compareEditions = (a: string, b: string): number => {
const [dateA, revisionA = '1'] = a.split ('.')
const [dateB, revisionB = '1'] = b.split ('.')
return dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB)
}
const validateEdition = (edition: string): void => {
const [date, revision = '1'] = edition.split ('.')
if (!(/^\d{4}-\d{2}-\d{2}(?:\.[1-9]\d*)?$/.test (edition))
|| new Date (date).toISOString ().slice (0, 10) !== date
|| !(Number.isSafeInteger (Number (revision))))
throw new Error (`Invalid Help edition: ${ edition }`)
}
export const generateHelpAssets = async (
root: string,
): Promise<{ modules: Map<string, string>; files: string[] }> => {
const modules = new Map<string, string> ()
const files: string[] = [root]
const articles: Record<string, Article> = Object.create (null)
const search: Record<string, SearchEntry[]> = Object.create (null)
const changes: Record<string, Change[]> = Object.create (null)
const titles: Record<string, Record<string, string[]>> = Object.create (null)
const components: string[] = []
const sources: string[] = []
const texts: string[] = []
const directories = (await readdir (root, { withFileTypes: true }))
.filter (entry => entry.isDirectory () && !(entry.name.startsWith ('.')))
.sort ((a, b) => a.name.localeCompare (b.name))
for (const directory of directories)
{
const articlePath = path.join (root, directory.name, 'article.yml')
files.push (articlePath)
const raw = parseYAML (await readFile (articlePath, 'utf8')) as RawArticle | null
if (raw == null || raw.identifier !== directory.name
|| typeof raw.canonical_language !== 'string'
|| raw.languages == null || typeof raw.languages !== 'object'
|| !(Object.hasOwn (raw.languages, raw.canonical_language)))
throw new Error (`Invalid Help metadata: ${ articlePath }`)
const article: Article = {
identifier: raw.identifier,
canonicalLanguage: raw.canonical_language,
languages: Object.create (null) }
articles[raw.identifier] = article
for (const [language, metadata] of Object.entries (raw.languages))
{
if (!(/^[a-zA-Z0-9-]+$/.test (language))
|| typeof metadata?.current_edition !== 'string')
throw new Error (`Invalid Help language: ${ articlePath }/${ language }`)
const currentEdition = metadata.current_edition
validateEdition (currentEdition)
const editionRoot = path.join (root, directory.name, 'editions', language)
files.push (editionRoot)
const editions = (await readdir (editionRoot)).filter (name => name.endsWith ('.mdx'))
.map (name => name.slice (0, -4))
editions.forEach (validateEdition)
if (!(editions.includes (currentEdition)))
throw new Error (`Missing current Help edition: ${ editionRoot }/${ currentEdition }`)
const publishedEditions = editions
.filter (edition => compareEditions (edition, currentEdition) <= 0)
.sort (compareEditions)
article.languages[language] = { currentEdition, publishedEditions }
search[language] ??= []
changes[language] ??= []
titles[language] ??= Object.create (null)
for (const [index, edition] of publishedEditions.entries ())
{
const sourcePath = path.join (editionRoot, `${ edition }.mdx`)
files.push (sourcePath)
const mdx = (await readFile (sourcePath, 'utf8')).replace (/^\uFEFF/, '')
const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/)
if (heading == null || !(heading[1].trim ()))
throw new Error (`Missing leading Help H1: ${ sourcePath }`)
const text = { title: heading[1].trim (), body: mdx.slice (heading[0].length) }
const key = JSON.stringify (`${ raw.identifier }/${ language }/${ edition }`)
const textId = `${ prefix }text/${ raw.identifier }/${ language }/${ edition }`
const componentId = JSON.stringify (normalisePath (sourcePath))
const sourceId = JSON.stringify (normalisePath (sourcePath) + '?raw')
modules.set (textId, `export default ${ JSON.stringify (text) }`)
texts.push (`${ key }: () => import(${ JSON.stringify (textId) })`)
components.push (`${ key }: () => import(${ componentId })`)
sources.push (`${ key }: () => import(${ sourceId })`)
if (edition === currentEdition)
search[language].push ({ identifier: raw.identifier, edition, ...text })
changes[language].push ({ identifier: raw.identifier, edition, title: text.title,
pred: publishedEditions[index - 1] ?? null,
change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] })
const title = text.title.trim ().normalize ('NFKC').toLowerCase ()
const identifiers = titles[language][title] ??= []
if (!(identifiers.includes (raw.identifier)))
identifiers.push (raw.identifier)
}
}
}
if (Object.keys (articles).length === 0)
throw new Error (`No Help articles found: ${ root }`)
Object.values (changes).forEach (rows => rows.sort ((a, b) =>
compareEditions (b.edition, a.edition) || a.identifier.localeCompare (b.identifier)))
for (const [name, data] of Object.entries ({ articles, search, changes, titles }))
{
modules.set (`${ prefix }${ name }`,
`export default JSON.parse(${ JSON.stringify (JSON.stringify (data)) })`)
}
modules.set (`${ prefix }loaders`, [
`export const components = { ${ components.join (',\n') } };`,
`export const sources = { ${ sources.join (',\n') } };`,
`export const texts = { ${ texts.join (',\n') } };`].join ('\n'))
return { modules, files }
}
export const helpAssets = (): Plugin => {
const state: { root: string; data?: ReturnType<typeof generateHelpAssets> } = { root: '' }
const data = () => state.data ??= generateHelpAssets (state.root)
return {
name: 'help-assets',
configResolved (config) {
state.root = path.resolve (config.root, 'help')
},
async buildStart () {
state.data = generateHelpAssets (state.root)
const generated = await data ()
generated.files.forEach (file => this.addWatchFile (file))
},
resolveId (id) {
return id.startsWith (prefix) ? `\0${ id }` : null
},
async load (id) {
if (!(id.startsWith (`\0${ prefix }`)))
return null
const source = (await data ()).modules.get (id.slice (1))
if (source == null)
throw new Error (`Unknown Help asset: ${ id.slice (1) }`)
return source
},
configureServer (server) {
server.watcher.add (state.root)
server.watcher.on ('all', (_event, file) => {
if (!(file.startsWith (state.root + path.sep)) || !(/\.(mdx|yml)$/.test (file)))
return
state.data = undefined
for (const module of server.moduleGraph.idToModuleMap.values ())
{
if (module.id?.startsWith (`\0${ prefix }`))
server.moduleGraph.invalidateModule (module)
}
server.ws.send ({ type: 'full-reload' })
})
} }
}
+19 -43
ファイルの表示
@@ -15,7 +15,6 @@ import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { applyClientAnimationMode, import { applyClientAnimationMode,
applyClientAppearance, applyClientAppearance,
type ClientAnimationMode,
fetchUserThemeSlots, fetchUserThemeSlots,
fetchUserSettings, fetchUserSettings,
getClientThemeMode, getClientThemeMode,
@@ -25,7 +24,6 @@ import { applyClientAnimationMode,
seedClientThemeMode } from '@/lib/settings' seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
@@ -34,12 +32,15 @@ import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage' import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage' import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage' import GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx' import TOSPage from '@/pages/TOSPage.mdx'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import HelpChangesPage from '@/pages/help/HelpChangesPage'
import HelpDiffPage from '@/pages/help/HelpDiffPage'
import HelpListPage from '@/pages/help/HelpListPage'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
@@ -60,6 +61,7 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import type { Dispatch, FC, SetStateAction } from 'react' import type { Dispatch, FC, SetStateAction } from 'react'
import type { ClientAnimationMode } from '@/lib/settings'
import type { User } from '@/types' import type { User } from '@/types'
@@ -69,9 +71,7 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
setUser: Dispatch<SetStateAction<User | null>> }) => { setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation () const location = useLocation ()
if (animationMode === 'off') const routes = (
{
return (
<Routes location={location}> <Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/> <Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/> <Route path="/posts" element={<PostListPage/>}/>
@@ -99,6 +99,10 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/> <Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/> <Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/> <Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/help" element={<HelpListPage/>}/>
<Route path="/help/changes" element={<HelpChangesPage/>}/>
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/> <Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/> <Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/> <Route path="/tos" element={<TOSPage/>}/>
@@ -106,44 +110,13 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
<Route path="/more" element={<MorePage/>}/> <Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/> <Route path="*" element={<NotFound/>}/>
</Routes>) </Routes>)
}
if (animationMode === 'off')
return routes
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<Routes location={location}> {routes}
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
</AnimatePresence>) </AnimatePresence>)
} }
@@ -166,9 +139,9 @@ const RoutedApp: FC = () => {
() => ( () => (
animationMode === 'off' animationMode === 'off'
? { duration: 0 } ? { duration: 0 }
: animationMode === 'reduced' : (animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const } ? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const } : { duration: .2, ease: 'easeOut' as const })
), ),
[animationMode], [animationMode],
) )
@@ -285,10 +258,12 @@ const RoutedApp: FC = () => {
</DialogueProvider>) </DialogueProvider>)
} }
const router = createBrowserRouter ([{ const router = createBrowserRouter ([{
path: '*', path: '*',
element: <RoutedApp/> }]) element: <RoutedApp/> }])
const App: FC = () => ( const App: FC = () => (
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
@@ -296,4 +271,5 @@ const App: FC = () => (
<RouterProvider router={router}/> <RouterProvider router={router}/>
</>) </>)
export default App export default App
+115 -30
ファイルの表示
@@ -20,6 +20,7 @@ import type { FC, MouseEvent } from 'react'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
type NavDensity = 'auto' | 'comfortable' | 'compact'
export const menuOutline = ( export const menuOutline = (
@@ -40,6 +41,9 @@ export const menuOutline = (
const materialFlg = /^\/materials\/\d+/.test (pathName) const materialFlg = /^\/materials\/\d+/.test (pathName)
const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName)
const helpIdentifier = pathName.split ('/')[2] ?? ''
return [ return [
{ name: '広場', to: '/posts', subMenu: [ { name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
@@ -71,7 +75,7 @@ export const menuOutline = (
visible: materialFlg && Boolean (material?.tag) }, visible: materialFlg && Boolean (material?.tag) },
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`, { name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] }, visible: materialFlg }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: 'Wiki', to: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' }, { name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new', visible: editable }, { name: '新規', to: '/wiki/new', visible: editable },
{ name: '全体履歴', to: '/wiki/changes' }, { name: '全体履歴', to: '/wiki/changes' },
@@ -82,6 +86,13 @@ export const menuOutline = (
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] }, visible: wikiPageFlg && editable }] },
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
{ name: '検索', to: '/help' },
{ name: '全体履歴', to: '/help/changes' },
{ component: <Separator/>, 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' }] },
@@ -104,6 +115,8 @@ const TopNav: FC<Props> = ({ user }) => {
const dirRef = useRef<(-1) | 1> (1) const dirRef = useRef<(-1) | 1> (1)
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
const navRef = useRef<HTMLDivElement | null> (null) const navRef = useRef<HTMLDivElement | null> (null)
const layoutRef = useRef<HTMLDivElement | null> (null)
const barRef = useRef<HTMLElement | null> (null)
const measure = (idx: number) => { const measure = (idx: number) => {
const nav = navRef.current const nav = navRef.current
@@ -128,6 +141,8 @@ const TopNav: FC<Props> = ({ user }) => {
width: 0, width: 0,
visible: false }) visible: false })
const [menuOpen, setMenuOpen] = useState (false) const [menuOpen, setMenuOpen] = useState (false)
const [desktop, setDesktop] = useState (false)
const [density, setDensity] = useState<NavDensity> ('auto')
const [moreVsbl, setMoreVsbl] = useState (false) const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1) const [openItemIdx, setOpenItemIdx] = useState (-1)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
@@ -194,15 +209,81 @@ const TopNav: FC<Props> = ({ user }) => {
const dir = dirRef.current const dir = dirRef.current
useLayoutEffect (() => { useLayoutEffect (() => {
const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx)) const layout = layoutRef.current
const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx)) const bar = barRef.current
const primary = bar?.querySelector<HTMLElement> ('.top-nav-primary')
if (layout == null || bar == null || primary == null)
return
addEventListener ('resize', onResize) const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
return () => { let raf = 0
cancelAnimationFrame (raf) let active = true
removeEventListener ('resize', onResize)
const update = () => {
if (!(window.matchMedia ('(min-width: 768px)').matches))
{
setDesktop (false)
setDensity ('auto')
return
} }
})
// Measure the same desktop elements while the mobile layout is active.
layout.setAttribute ('data-measuring', '')
const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
const fittingDensity = (() => {
try
{
for (const candidate of ['auto', 'comfortable', 'compact'] as const)
{
layout.setAttribute ('data-density', candidate)
const style = getComputedStyle (bar)
const required = primary.getBoundingClientRect ().width
+ (userLink?.getBoundingClientRect ().width ?? 0)
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
+ (userLink == null ? 0 : parseFloat (style.columnGap))
if (required <= bar.clientWidth)
return candidate
}
return null
}
finally
{
layout.setAttribute ('data-density', previousDensity)
layout.removeAttribute ('data-measuring')
}
}) ()
setDensity (fittingDensity ?? 'compact')
setDesktop (fittingDensity != null)
if (fittingDensity != null)
measure (moreVsbl ? -1 : activeIdx)
}
const schedule = () => {
if (!(active))
return
cancelAnimationFrame (raf)
raf = requestAnimationFrame (update)
}
const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
observer?.observe (bar)
observer?.observe (primary)
if (userLink != null)
observer?.observe (userLink)
update ()
addEventListener ('resize', schedule)
document.fonts?.addEventListener ('loadingdone', schedule)
void document.fonts?.ready.then (schedule)
return () => {
active = false
cancelAnimationFrame (raf)
observer?.disconnect ()
removeEventListener ('resize', schedule)
document.fonts?.removeEventListener ('loadingdone', schedule)
}
}, [activeIdx, moreVsbl, desktop, density, user?.name])
useEffect (() => { useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId) const unsubscribe = WikiIdBus.subscribe (setWikiId)
@@ -215,19 +296,22 @@ const TopNav: FC<Props> = ({ user }) => {
}, [activeIdx, location]) }, [activeIdx, location])
return ( return (
<> <div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
<nav className="top-nav-root px-3 flex justify-between items-center w-full"> data-desktop={desktop} data-density={density}>
<div className="flex items-center gap-2 h-12"> <nav ref={barRef}
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
<div className="top-nav-primary flex shrink-0 items-center h-12">
<PrefetchLink <PrefetchLink
to="/posts" to="/posts"
className="top-nav-brand-link mx-4 text-xl font-bold" className="top-nav-brand-link shrink-0 whitespace-nowrap font-bold"
onClick={() => { onClick={() => {
scroll (0, 0) scroll (0, 0)
}}> }}>
ぼざクリ タグ広場 ぼざクリ タグ広場
</PrefetchLink> </PrefetchLink>
<div ref={navRef} className="relative hidden md:flex h-12 items-center"> <div ref={navRef}
className="top-nav-desktop-menu relative shrink-0 h-12 items-center">
<div aria-hidden <div aria-hidden
className={cn ('absolute inset-y-0 h-12', className={cn ('absolute inset-y-0 h-12',
'top-nav-highlight', 'top-nav-highlight',
@@ -239,6 +323,7 @@ const TopNav: FC<Props> = ({ user }) => {
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
<motion.div <motion.div
key={item.to} key={item.to}
className="shrink-0 whitespace-nowrap"
{...(animationsOff {...(animationsOff
? { } ? { }
: { layoutId: `menu-${ item.name }` })} : { layoutId: `menu-${ item.name }` })}
@@ -252,7 +337,7 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el itemsRef.current[i] = el
}} }}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5', className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
(i === openItemIdx) && 'font-bold')}> (i === openItemIdx) && 'font-bold')}>
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
@@ -267,7 +352,8 @@ const TopNav: FC<Props> = ({ user }) => {
setMoreVsbl (true) setMoreVsbl (true)
measure (-1) measure (-1)
}} }}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5', className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
'shrink-0 whitespace-nowrap',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}> (openItemIdx < 0 || moreVsbl) && 'font-bold')}>
その他 &raquo; その他 &raquo;
</PrefetchLink> </PrefetchLink>
@@ -278,7 +364,8 @@ const TopNav: FC<Props> = ({ user }) => {
<button <button
type="button" type="button"
className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4" className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
ml-auto border-0 bg-transparent pr-4"
onClick={() => { onClick={() => {
setMenuOpen (!(menuOpen)) setMenuOpen (!(menuOpen))
}}> }}>
@@ -291,7 +378,8 @@ const TopNav: FC<Props> = ({ user }) => {
key="submenu-shell" key="submenu-shell"
{...(animationsOff ? { } {...(animationsOff ? { }
: { layout: true })} : { layout: true })}
className="relative z-20 hidden md:block overflow-hidden top-nav-submenu" className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu"
animate={{ height: submenuHeight }} animate={{ height: submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
@@ -305,7 +393,8 @@ const TopNav: FC<Props> = ({ user }) => {
? ( ? (
moreMenu.map ((item, i) => ( moreMenu.map ((item, i) => (
<div key={i} className="relative h-[40px]"> <div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3"> <div className="absolute inset-0 flex items-center px-3
overflow-x-auto whitespace-nowrap">
<motion.div <motion.div
transition={topNavTransition} transition={topNavTransition}
{...(animationsOff {...(animationsOff
@@ -315,7 +404,7 @@ const TopNav: FC<Props> = ({ user }) => {
: { initial: { x: 40, y: -40, opacity: 0 }, : { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 }, animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))} exit: { x: 40, y: -40, opacity: 0 } }))}
className="z-10 h-full flex items-center px-3 font-bold w-28"> className="z-10 h-full flex shrink-0 items-center px-3 font-bold w-28">
<h2>{item.name}</h2> <h2>{item.name}</h2>
</motion.div> </motion.div>
{item.subMenu {item.subMenu
@@ -363,7 +452,8 @@ const TopNav: FC<Props> = ({ user }) => {
<div className="relative h-[40px]"> <div className="relative h-[40px]">
{animationsOff {animationsOff
? ( ? (
<div className="absolute inset-0 flex items-center px-3"> <div className="absolute inset-0 flex items-center px-3
overflow-x-auto whitespace-nowrap">
{(visibleMenu[activeIdx]?.subMenu ?? []) {(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true) .filter (item => item.visible ?? true)
.map ((item, i) => ( .map ((item, i) => (
@@ -392,7 +482,8 @@ const TopNav: FC<Props> = ({ user }) => {
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }), variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 }, centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }} exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3" className="absolute inset-0 flex items-center px-3
overflow-x-auto whitespace-nowrap"
initial="enter" initial="enter"
animate="centre" animate="centre"
exit="exit" exit="exit"
@@ -432,7 +523,7 @@ const TopNav: FC<Props> = ({ user }) => {
? ( ? (
menuOpen && ( menuOpen && (
<div <div
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden', className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
'items-start')}> 'items-start')}>
<Separator/> <Separator/>
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
@@ -476,9 +567,6 @@ const TopNav: FC<Props> = ({ user }) => {
</Fragment>))} </Fragment>))}
<PrefetchLink <PrefetchLink
to="/more" to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0) ((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}> && 'top-nav-mobile-active font-bold'))}>
@@ -492,7 +580,7 @@ const TopNav: FC<Props> = ({ user }) => {
{menuOpen && ( {menuOpen && (
<motion.div <motion.div
key="spmenu" key="spmenu"
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden', className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
'items-start')} 'items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 }, height: 0 },
@@ -558,9 +646,6 @@ const TopNav: FC<Props> = ({ user }) => {
</Fragment>))} </Fragment>))}
<PrefetchLink <PrefetchLink
to="/more" to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0) ((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}> && 'top-nav-mobile-active font-bold'))}>
@@ -570,7 +655,7 @@ const TopNav: FC<Props> = ({ user }) => {
<Separator/> <Separator/>
</motion.div>)} </motion.div>)}
</AnimatePresence>)} </AnimatePresence>)}
</>) </div>)
} }
export default TopNav export default TopNav
+2 -2
ファイルの表示
@@ -15,8 +15,8 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
return return
const className = cn ((sp const className = cn ((sp
? 'md:hidden w-full min-h-[40px] flex items-center pl-8' ? 'top-nav-user-mobile w-full min-h-[40px] flex items-center pl-8'
: 'hidden md:block ml-auto pr-4'), : 'top-nav-user-desktop shrink-0 whitespace-nowrap ml-auto'),
'font-bold text-red-600 hover:text-red-400', 'font-bold text-red-600 hover:text-red-400',
'dark:text-yellow-400 dark:hover:text-yellow-200') 'dark:text-yellow-400 dark:hover:text-yellow-200')
+233 -1
ファイルの表示
@@ -197,12 +197,244 @@ body
background: var(--top-nav-root-bg-mobile); background: var(--top-nav-root-bg-mobile);
} }
.top-nav-bar
{
padding-inline: 8px;
column-gap: 4px;
}
.top-nav-primary
{
column-gap: 4px;
}
.top-nav-layout .top-nav-brand-link
{
margin-inline: 4px;
font-size: 1rem;
}
.top-nav-desktop-menu,
.top-nav-desktop-submenu,
.top-nav-user-desktop
{
display: none;
}
.top-nav-desktop-menu
{
font-size: .875rem;
}
.top-nav-desktop-menu .top-nav-menu-link
{
padding-inline: 8px;
}
.top-nav-user-desktop
{
padding-right: 4px;
font-size: .875rem;
}
.top-nav-user-mobile
{
overflow-wrap: anywhere;
}
@media (min-width: 768px) @media (min-width: 768px)
{ {
.top-nav-root .top-nav-layout .top-nav-brand-link
{
font-size: 1.125rem;
}
.top-nav-layout[data-desktop='true'] .top-nav-root
{ {
background: var(--top-nav-root-bg-desktop); background: var(--top-nav-root-bg-desktop);
} }
.top-nav-layout[data-desktop='true'] .top-nav-desktop-menu,
.top-nav-layout[data-measuring] .top-nav-desktop-menu
{
display: flex;
}
.top-nav-layout[data-desktop='true'] .top-nav-user-desktop,
.top-nav-layout[data-measuring] .top-nav-user-desktop,
.top-nav-layout[data-desktop='true'] .top-nav-desktop-submenu
{
display: block;
}
.top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle,
.top-nav-layout[data-measuring] .top-nav-mobile-toggle,
.top-nav-layout[data-desktop='true'] .top-nav-mobile-menu,
.top-nav-layout[data-desktop='true'] .top-nav-user-mobile
{
display: none;
}
.top-nav-layout[data-measuring]
{
visibility: hidden;
}
}
@media (min-width: 1024px)
{
.top-nav-bar
{
padding-inline: 12px;
column-gap: 8px;
}
.top-nav-primary
{
column-gap: 6px;
}
.top-nav-layout .top-nav-brand-link
{
margin-inline: 8px;
font-size: 1.25rem;
}
.top-nav-desktop-menu
{
column-gap: 2px;
}
.top-nav-desktop-menu,
.top-nav-user-desktop
{
font-size: .9375rem;
}
.top-nav-desktop-menu .top-nav-menu-link
{
padding-inline: 12px;
}
.top-nav-user-desktop
{
padding-right: 8px;
}
}
@media (min-width: 1280px)
{
.top-nav-bar
{
column-gap: 12px;
}
.top-nav-primary
{
column-gap: 8px;
}
.top-nav-layout .top-nav-brand-link
{
margin-inline: 16px;
}
.top-nav-desktop-menu
{
column-gap: 4px;
}
.top-nav-desktop-menu,
.top-nav-user-desktop
{
font-size: 1rem;
}
.top-nav-desktop-menu .top-nav-menu-link
{
padding-inline: 20px;
}
.top-nav-user-desktop
{
padding-right: 16px;
}
}
.top-nav-layout[data-density='comfortable'] .top-nav-bar
{
padding-inline: 12px;
column-gap: 8px;
}
.top-nav-layout[data-density='comfortable'] .top-nav-primary
{
column-gap: 6px;
}
.top-nav-layout[data-density='comfortable'] .top-nav-brand-link
{
margin-inline: 8px;
font-size: 1.25rem;
}
.top-nav-layout[data-density='comfortable'] .top-nav-desktop-menu
{
column-gap: 2px;
}
.top-nav-layout[data-density='comfortable'] .top-nav-desktop-menu,
.top-nav-layout[data-density='comfortable'] .top-nav-user-desktop
{
font-size: .9375rem;
}
.top-nav-layout[data-density='comfortable'] .top-nav-desktop-menu .top-nav-menu-link
{
padding-inline: 12px;
}
.top-nav-layout[data-density='comfortable'] .top-nav-user-desktop
{
padding-right: 8px;
}
.top-nav-layout[data-density='compact'] .top-nav-bar
{
padding-inline: 8px;
column-gap: 4px;
}
.top-nav-layout[data-density='compact'] .top-nav-primary
{
column-gap: 4px;
}
.top-nav-layout[data-density='compact'] .top-nav-brand-link
{
margin-inline: 4px;
font-size: 1.125rem;
}
.top-nav-layout[data-density='compact'] .top-nav-desktop-menu
{
column-gap: 0;
}
.top-nav-layout[data-density='compact'] .top-nav-desktop-menu,
.top-nav-layout[data-density='compact'] .top-nav-user-desktop
{
font-size: .875rem;
}
.top-nav-layout[data-density='compact'] .top-nav-desktop-menu .top-nav-menu-link
{
padding-inline: 8px;
}
.top-nav-layout[data-density='compact'] .top-nav-user-desktop
{
padding-right: 4px;
} }
.top-nav-brand-link, .top-nav-brand-link,
+130
ファイルの表示
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest'
import { createHelpFixture } from '@/test/helpFixtures'
describe ('Help file catalogue', () => {
it.each ([['Éclair', 'eclair'], ['home', 'HOME'], ['e\u0301clair', 'Éclair']]) (
'compares title %s and %s equally at primary strength', (a, b) => {
const { api } = createHelpFixture ()
expect (api.compareHelpTitles (a, b)).toBe (0)
})
it ('orders distinct Latin titles in both directions', () => {
const { api } = createHelpFixture ()
expect (api.compareHelpTitles ('Apple', 'Zebra')).toBeLessThan (0)
expect (api.compareHelpTitles ('Zebra', 'Apple')).toBeGreaterThan (0)
})
it.each ([['ほーむ', 'ホーム'], ['ホーム', 'ほーむ'], ['ホーム', 'ホーム'],
['ABC', 'abc'], ['ガイド', 'がいど']]) (
'normalises search text %s and %s identically', (query, text) => {
const { api } = createHelpFixture ()
expect (api.normaliseHelpSearchText (query)).toBe (api.normaliseHelpSearchText (text))
})
it.each ([['2026-10-02', null], ['2026-10-02.2', 2],
['2026-10-02.3', 3], ['2026-10-02.10', 10]] as const) (
'separates the date and revision of %s', (edition, revision) => {
const { api } = createHelpFixture ()
expect (api.parseHelpEdition (edition)).toEqual ({ date: '2026-10-02', revision })
const date = api.helpEditionDate (edition)!
expect ([date.getFullYear (), date.getMonth (), date.getDate ()]).toEqual ([2026, 9, 2])
})
it.each (['missing', '2026-02-30', '2026-10-02.0', '2026-10-02.2.extra',
'2026-10-02.9007199254740992']) ('rejects invalid edition %s', edition => {
const { api } = createHelpFixture ()
expect (api.parseHelpEdition (edition)).toBeNull ()
expect (api.helpEditionDate (edition)).toBeNull ()
})
it ('compares numeric revision suffixes without losing the edition identifiers', () => {
const { api } = createHelpFixture ()
expect (['2026-10-02.10', '2026-10-02.3', '2026-10-02', '2026-10-02.2']
.sort (api.compareHelpEditions)).toEqual ([
'2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
})
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
const { api } = createHelpFixture ()
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.10',
title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' },
{ identifier: '設定 手順', edition: '2026-10-03',
title: '設定', body: '\nタグを設定する' }])
})
it ('orders published editions numerically and separates languages', async () => {
const { api } = createHelpFixture ()
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 (await api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
expect (await api.getHelpPublishedEditions ('guide', 'ja'))
.not.toContain ('2026-10-02.11')
})
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: '\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 (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 ('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 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.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 ()
})
it ('compares raw H1 and body lines in the requested direction', async () => {
const { api } = createHelpFixture ()
const forward = await api.getHelpDiff ('guide', 'ja', '2026-10-01', '2026-10-02.10')
expect (forward?.from.title).toBe ('旧ホーム')
expect (forward?.to.title).toBe ('ホーム第十版')
expect (forward?.lines).toEqual (expect.arrayContaining ([
{ type: 'removed', content: '# 旧ホーム\n' },
{ type: 'added', content: '# ホーム第十版\n' },
{ type: 'unchanged', content: '\n' }]))
const reverse = await api.getHelpDiff ('guide', 'ja', '2026-10-02.10', '2026-10-01')
expect (reverse?.lines).toEqual (expect.arrayContaining ([
{ type: 'added', content: '# 旧ホーム\n' },
{ type: 'removed', content: '# ホーム第十版\n' }]))
})
it.each ([['missing', 'ja', '2026-10-01', '2026-10-02.10'],
['guide', 'ja', '2026-10-01', '2026-10-02.11'],
['guide', 'en', '2026-10-01', '2026-10-02']]) (
'rejects invalid diff editions for %s/%s without reading sources',
async (id, language, from, to) => {
const { api, raw } = createHelpFixture ()
expect (await api.getHelpDiff (id, language, from, to)).toBeNull ()
Object.values (raw).forEach (load => expect (load).not.toHaveBeenCalled ())
})
})
+237
ファイルの表示
@@ -0,0 +1,237 @@
import type { ComponentType } from 'react'
type ArticleLanguage = { currentEdition: string; publishedEditions: string[] }
export type ArticleMeta = {
identifier: string
canonicalLanguage: string
languages: Record<string, ArticleLanguage> }
export type HelpEditionText = {
title: string
body: string }
export type HelpSearchEntry = HelpEditionText & {
identifier: string
edition: string }
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
export type HelpEditionSource = HelpEditionText & { source: string }
export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & {
pred: string | null
change: 'new' | 'update'
date: string }
export type HelpTitleIndex = Record<string, string[]>
export type HelpDiffLine = {
type: 'added' | 'removed' | 'unchanged'
content: string }
export type HelpDiff = {
from: HelpEditionSource
to: HelpEditionSource
lines: HelpDiffLine[] }
export type HelpEditionIdentifier = { date: string; revision: number | null }
// Fix the locale and primary strength; ICU versions still differ from MySQL UCA 9.0.
const helpTitleCollator = new Intl.Collator ('en', {
usage: 'sort', sensitivity: 'base', ignorePunctuation: false, numeric: false })
export const compareHelpTitles = (a: string, b: string): number =>
helpTitleCollator.compare (a, b)
export const normaliseHelpSearchText = (value: string): string =>
value.normalize ('NFKC').toLowerCase ().replace (/[ァ-ヶヽヾ]/g,
character => String.fromCodePoint (character.codePointAt (0)! - 0x60))
export const parseHelpEdition = (edition: string): HelpEditionIdentifier | null => {
const matched = edition.match (/^(\d{4}-\d{2}-\d{2})(?:\.([1-9]\d*))?$/)
if (matched == null)
return null
const date = new Date (matched[1])
const revision = matched[2] == null ? null : Number (matched[2])
if (Number.isNaN (date.getTime ()) || date.toISOString ().slice (0, 10) !== matched[1]
|| (revision != null && !(Number.isSafeInteger (revision))))
return null
return { date: matched[1], revision }
}
export const compareHelpEditions = (a: string, b: string): number => {
const before = parseHelpEdition (a)
const after = parseHelpEdition (b)
if (before == null || after == null)
return a.localeCompare (b)
return before.date.localeCompare (after.date)
|| (before.revision ?? 1) - (after.revision ?? 1)
}
export const helpEditionDate = (edition: string): Date | null => {
const parsed = parseHelpEdition (edition)
return parsed == null ? null : new Date (`${ parsed.date }T00:00:00`)
}
export const getHelpArticle = async (identifier: string): Promise<ArticleMeta | null> => {
const { default: articles } = await import ('virtual:help/articles')
return Object.hasOwn (articles, identifier) ? articles[identifier] : null
}
export const getHelpEditions = async (
identifier: string,
language: string,
): Promise<string[]> => {
const article = await getHelpArticle (identifier)
return article?.languages[language]?.publishedEditions ?? []
}
export const getHelpPublishedEditions = async (
identifier: string,
language: string,
): Promise<string[]> => await getHelpEditions (identifier, language)
export const getHelpEdition = async (
identifier: string,
language: string,
edition: string,
): Promise<ComponentType | null> => {
const { components } = await import ('virtual:help/loaders')
const loader = components[`${ identifier }/${ language }/${ edition }`]
if (!(loader))
return null
return (await loader ()).default
}
export const getHelpEditionSource = async (
identifier: string,
language: string,
edition: string,
): Promise<HelpEditionSource | null> => {
const { sources } = await import ('virtual:help/loaders')
const loader = sources[`${ identifier }/${ language }/${ edition }`]
if (!(loader))
return null
const [source, text] = await Promise.all ([
loader (), getHelpEditionText (identifier, language, edition)])
return text == null ? null : { source: source.default, ...text }
}
export const getHelpEditionText = async (
identifier: string,
language: string,
edition: string,
): Promise<HelpEditionText | null> => {
const { texts } = await import ('virtual:help/loaders')
const loader = texts[`${ identifier }/${ language }/${ edition }`]
return loader == null ? null : (await loader ()).default
}
export const getHelpSearchEntries = async (
language: string,
): Promise<HelpSearchEntry[]> => {
const { default: search } = await import ('virtual:help/search')
return search[language] ?? []
}
export const getHelpEditionContent = async (
identifier: string,
language: string,
edition: string,
): Promise<HelpEditionContent | null> => {
const [content, text] = await Promise.all ([
getHelpEdition (identifier, language, edition),
getHelpEditionText (identifier, language, edition)])
return content != null && text != null ? { content, ...text } : null
}
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
const { default: changes } = await import ('virtual:help/changes')
return changes[language] ?? []
}
export const getHelpTitleIndex = async (language: string): Promise<HelpTitleIndex> => {
const { default: titles } = await import ('virtual:help/titles')
return titles[language] ?? { }
}
const diffHelpLines = (from: string, to: string): HelpDiffLine[] => {
const before = from.match (/[^\n]*\n|[^\n]+$/g) ?? []
const after = to.match (/[^\n]*\n|[^\n]+$/g) ?? []
const lengths = Array.from ({ length: before.length + 1 },
() => new Uint32Array (after.length + 1))
for (let i = before.length - 1; i >= 0; --i)
{
for (let j = after.length - 1; j >= 0; --j)
{
lengths[i][j] =
before[i] === after[j]
? lengths[i + 1][j + 1] + 1
: Math.max (lengths[i + 1][j], lengths[i][j + 1])
}
}
const lines: HelpDiffLine[] = []
let i = 0
let j = 0
while (i < before.length || j < after.length)
{
if (i < before.length && j < after.length && before[i] === after[j])
{
lines.push ({ type: 'unchanged', content: before[i] })
++i
++j
}
else if (i < before.length
&& (j === after.length || lengths[i + 1][j] >= lengths[i][j + 1]))
{
lines.push ({ type: 'removed', content: before[i] })
++i
}
else
{
lines.push ({ type: 'added', content: after[j] })
++j
}
}
return lines
}
export const getHelpDiff = async (
identifier: string,
language: string,
from: string,
to: string,
): Promise<HelpDiff | null> => {
const editions = await getHelpPublishedEditions (identifier, language)
if (!(editions.includes (from)) || !(editions.includes (to)))
return null
const [before, after] = await Promise.all ([
getHelpEditionSource (identifier, language, from),
getHelpEditionSource (identifier, language, to)])
if (before == null || after == null)
return null
return { from: before, to: after, lines: diffHelpLines (before.source, after.source) }
}
+126
ファイルの表示
@@ -0,0 +1,126 @@
import { QueryClient } from '@tanstack/react-query'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PrefetchLink from '@/components/PrefetchLink'
import * as prefetchers from '@/lib/prefetchers'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import HelpDiffPage from '@/pages/help/HelpDiffPage'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
const settings = vi.hoisted (() => ({ linkPreload: 'intent' }))
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
useClientBehaviourSettings: () => settings }))
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
const Location = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}</output>
}
describe ('Help prefetch integration', () => {
beforeEach (() => {
helpFixture.reset ()
settings.linkPreload = 'intent'
vi.clearAllMocks ()
})
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)
expect (prefetchers.routePrefetchers.filter (entry => entry.test (url))).toHaveLength (1)
})
it ('loads and reuses the same MDX module for repeated prefetch and display', async () => {
const { queryClient } = renderWithProviders (
<>
<PrefetchLink to="/help/guide">Help</PrefetchLink>
<Routes>
<Route path="/" element={null}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
</Routes>
</>)
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: 'ホーム第十版' })
expect (loader).toHaveBeenCalledTimes (1)
})
it ('prefetches the requested identifier and edition independently', async () => {
const client = new QueryClient ()
await prefetchers.prefetchForURL (client, '/help/guide?edition=2026-10-02.3')
await prefetchers.prefetchForURL (client, `/help/${encodeURIComponent ('設定 手順')}`)
expect (helpFixture.modules['../../help/guide/editions/ja/2026-10-02.3.mdx'])
.toHaveBeenCalledTimes (1)
expect (helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx'])
.toHaveBeenCalledTimes (1)
expect (helpFixture.modules['../../help/guide/editions/en/2026-10-02.mdx'])
.not.toHaveBeenCalled ()
})
it ('loads history editions without treating changes as an article', async () => {
const article = vi.spyOn (helpFixture.api, 'getHelpArticle')
await prefetchers.prefetchForURL (new QueryClient (), '/help/changes')
expect (article).not.toHaveBeenCalledWith ('changes')
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 ('reuses two prefetched raw editions when displaying their diff', async () => {
const path = '/help/guide/diff?from=2026-10-01&to=2026-10-02.10'
const { queryClient } = renderWithProviders (
<>
<PrefetchLink to={path}>diff</PrefetchLink>
<Routes>
<Route path="/" element={null}/>
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
</Routes>
</>)
await Promise.all ([prefetchers.prefetchForURL (queryClient, path),
prefetchers.prefetchForURL (queryClient, path)])
fireEvent.click (screen.getByRole ('link', { name: 'diff' }))
expect (await screen.findByRole ('link', { name: '旧ホーム (2026-10-01)' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-01')
expect (screen.getByText ('+ # ホーム第十版')).toBeInTheDocument ()
Object.entries (helpFixture.raw).forEach (([name, load]) => {
const expected = /\/ja\/(2026-10-01|2026-10-02\.10)\.mdx$/.test (name) ? 1 : 0
expect (load).toHaveBeenCalledTimes (expected)
})
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
})
it.each (['intent', 'off']) ('respects %s intent settings and still prefetches clicks',
async mode => {
settings.linkPreload = mode
const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue ()
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
const link = screen.getByRole ('link', { name: 'Help' })
fireEvent.mouseEnter (link)
fireEvent.touchStart (link)
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 2 : 0)
fireEvent.click (link)
await waitFor (() => expect (screen.getByTestId ('location'))
.toHaveTextContent ('/help/guide'))
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 3 : 1)
})
it ('continues navigation after prefetch failure', async () => {
vi.spyOn (console, 'error').mockImplementation (() => {})
vi.spyOn (prefetchers, 'prefetchForURL').mockRejectedValue (new Error ('offline'))
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
await waitFor (() => expect (screen.getByTestId ('location'))
.toHaveTextContent ('/help/guide'))
})
})
+70 -1
ファイルの表示
@@ -1,8 +1,13 @@
import { QueryClient } from '@tanstack/react-query' import { QueryClient } from '@tanstack/react-query'
import { match } from 'path-to-regexp' import { match } from 'path-to-regexp'
import { getHelpArticle,
getHelpChanges,
getHelpDiff,
getHelpEditionContent,
getHelpSearchEntries } from '@/lib/help'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiPage, import { fetchWikiPage,
fetchWikiPageByTitle, fetchWikiPageByTitle,
@@ -15,12 +20,69 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
const mPost = match<{ id: string }> ('/posts/:id') const mPost = match<{ id: string }> ('/posts/:id')
const mWiki = match<{ title: string }> ('/wiki/:title') const mWiki = match<{ title: string }> ('/wiki/:title')
const mTag = match<{ id: string }> ('/tags/:id') const mTag = match<{ id: string }> ('/tags/:id')
const mHelp = match<{ identifier: string }> ('/help/:identifier')
const mHelpDiff = match<{ identifier: string }> ('/help/:identifier/diff')
const boolFromQuery = (value: string | null): boolean => const boolFromQuery = (value: string | null): boolean =>
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ()) ['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
const prefetchHelpIndex: Prefetcher = async qc => {
await qc.prefetchQuery ({
queryKey: helpKeys.index ('ja'),
queryFn: () => getHelpSearchEntries ('ja'),
staleTime: Infinity })
}
const prefetchHelpShow: Prefetcher = async (qc, url) => {
const matched = mHelp (url.pathname)
if (!(matched))
return
const { identifier } = matched.params
const language = 'ja'
const article = await qc.fetchQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition
if (edition == null)
return
await qc.prefetchQuery ({
queryKey: helpKeys.edition (identifier, language, edition),
queryFn: () => getHelpEditionContent (identifier, language, edition),
staleTime: Infinity })
}
const prefetchHelpChanges: Prefetcher = async qc => {
await qc.prefetchQuery ({
queryKey: helpKeys.changes ('ja'),
queryFn: () => getHelpChanges ('ja'),
staleTime: Infinity })
}
const prefetchHelpDiff: Prefetcher = async (qc, url) => {
const matched = mHelpDiff (url.pathname)
const fromValues = url.searchParams.getAll ('from')
const toValues = url.searchParams.getAll ('to')
const [from] = fromValues
const [to] = toValues
if (!(matched) || fromValues.length !== 1 || toValues.length !== 1 || !(from) || !(to))
return
const { identifier } = matched.params
await qc.prefetchQuery ({
queryKey: helpKeys.diff (identifier, 'ja', from, to),
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
staleTime: Infinity })
}
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
const title = url.searchParams.get ('title') ?? '' const title = url.searchParams.get ('title') ?? ''
@@ -223,6 +285,13 @@ const prefetchTagChanges: Prefetcher = async (qc, url) => {
export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
{ test: u => /^\/help\/?$/.test (u.pathname), run: prefetchHelpIndex },
{ test: u => /^\/help\/changes\/?$/.test (u.pathname), run: prefetchHelpChanges },
{ test: u => Boolean (mHelpDiff (u.pathname)), run: prefetchHelpDiff },
{ test: u => {
const matched = mHelp (u.pathname)
return Boolean (matched && matched.params.identifier !== 'changes')
}, run: prefetchHelpShow },
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
run: prefetchPostsIndex }, run: prefetchPostsIndex },
{ test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname)) { test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname))
+11
ファイルの表示
@@ -61,3 +61,14 @@ export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const, index: (p: { title?: string }) => ['wiki', 'index', p] as const,
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const } show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }
export const helpKeys = {
root: ['help'] as const,
index: (language: string) => ['help', 'index', language] as const,
titles: (language: string) => ['help', 'titles', language] as const,
article: (identifier: string) => ['help', 'article', identifier] as const,
edition: (identifier: string, language: string, edition: string) =>
['help', 'edition', identifier, language, edition] as const,
diff: (identifier: string, language: string, from: string, to: string) =>
['help', 'diff', identifier, language, from, to] as const,
changes: (language: string) => ['help', 'changes', language] as const }
+16 -2
ファイルの表示
@@ -72,11 +72,25 @@ describe ('tags API functions', () => {
) )
}) })
it ('returns null when tag fetches fail', async () => { it ('returns null for a missing named tag while retaining the ID helper contract', async () => {
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
api.apiGet.mockRejectedValueOnce (new Error ('missing')) api.apiGet.mockRejectedValueOnce (new Error ('missing'))
api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
await expect (fetchTag ('1')).resolves.toBeNull () await expect (fetchTag ('1')).resolves.toBeNull ()
await expect (fetchTagByName ('unknown')).resolves.toBeNull () await expect (fetchTagByName ('unknown')).resolves.toBeNull ()
}) })
it.each ([403, 500]) ('propagates named tag HTTP %s', async status => {
const error = { isAxiosError: true, response: { status } }
api.apiGet.mockRejectedValueOnce (error)
await expect (fetchTagByName ('ホーム')).rejects.toBe (error)
})
it ('propagates named tag network failures and encodes the name', async () => {
const error = new Error ('offline')
api.apiGet.mockRejectedValueOnce (error)
await expect (fetchTagByName ('名前/#%')).rejects.toBe (error)
expect (api.apiGet).toHaveBeenLastCalledWith (
`/tags/name/${ encodeURIComponent ('名前/#%') }`)
})
}) })
+6 -2
ファイルの表示
@@ -1,3 +1,5 @@
import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { Deerjikist, import type { Deerjikist,
@@ -68,11 +70,13 @@ export const fetchExternalTag = async (id: string): Promise<Tag | null> => {
export const fetchTagByName = async (name: string): Promise<Tag | null> => { export const fetchTagByName = async (name: string): Promise<Tag | null> => {
try try
{ {
return await apiGet (`/tags/name/${ name }`) return await apiGet (`/tags/name/${ encodeURIComponent (name) }`)
} }
catch catch (error)
{ {
if (isAxiosError (error) && error.response?.status === 404)
return null return null
throw error
} }
} }
+18 -11
ファイルの表示
@@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({ apiGet: vi.fn () }))
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
@@ -23,26 +21,35 @@ describe ('wiki API functions', () => {
expect (api.apiGet).toHaveBeenNthCalledWith ( expect (api.apiGet).toHaveBeenNthCalledWith (
1, 1,
'/wiki', '/wiki',
{ params: { title: '虹' } }, { params: { title: '虹' } })
)
expect (api.apiGet).toHaveBeenNthCalledWith ( expect (api.apiGet).toHaveBeenNthCalledWith (
2, 2,
'/wiki/1', '/wiki/1',
{ params: { version: '3' } }, { params: { version: '3' } })
)
}) })
it ('encodes title path segments and returns null on misses', async () => { it ('encodes title path segments and returns null on misses', async () => {
api.apiGet.mockResolvedValueOnce ({ id: 2 }) api.apiGet.mockResolvedValueOnce ({ id: 2 })
api.apiGet.mockRejectedValueOnce (new Error ('missing')) api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
await fetchWikiPageByTitle ('a/b c', { version: undefined }) await fetchWikiPageByTitle ('a/b c', { version: undefined })
await expect (fetchWikiPageByTitle ('missing', {})).resolves.toBeNull () await expect (fetchWikiPageByTitle ('missing', { })).resolves.toBeNull ()
expect (api.apiGet).toHaveBeenNthCalledWith ( expect (api.apiGet).toHaveBeenNthCalledWith (
1, 1,
'/wiki/title/a%2Fb%20c', '/wiki/title/a%2Fb%20c',
{ params: { version: undefined } }, { params: { version: undefined } })
) })
it.each ([403, 500]) ('preserves HTTP %s errors', async status => {
const error = { isAxiosError: true, response: { status } }
api.apiGet.mockRejectedValueOnce (error)
await expect (fetchWikiPageByTitle ('ホーム', { })).rejects.toBe (error)
})
it ('preserves network failures for callers', async () => {
const error = new Error ('network failure')
api.apiGet.mockRejectedValueOnce (error)
await expect (fetchWikiPageByTitle ('ホーム', { })).rejects.toBe (error)
}) })
}) })
+5 -1
ファイルの表示
@@ -1,3 +1,5 @@
import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { WikiPage } from '@/types' import type { WikiPage } from '@/types'
@@ -21,8 +23,10 @@ export const fetchWikiPageByTitle = async (
{ {
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } }) return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
} }
catch catch (error)
{ {
if (isAxiosError (error) && error.response?.status === 404)
return null return null
throw error
} }
} }
+77
ファイルの表示
@@ -0,0 +1,77 @@
import { fireEvent, screen, within } from '@testing-library/react'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { Route, Routes } from 'react-router-dom'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import { compileMdx, transformMdx } from '@/test/compileMdx'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
const prefetch = vi.hoisted (() => vi.fn (async () => {}))
vi.hoisted (() => {
vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor)
})
afterAll (() => vi.unstubAllGlobals ())
vi.mock ('@/lib/prefetchers', () => ({ prefetchForURL: prefetch }))
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
const tableSource = [
'# 表のある記事', '', '| 項目 | 説明 |', '| --- | --- |',
'| [ヘルプ](/help/guide) | **強調** |', '| 通常 | 本文 |'].join ('\n')
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 (<Content/>)
const table = screen.getByRole ('table')
expect (table.querySelector ('thead')).not.toBeNull ()
expect (table.querySelector ('tbody')).not.toBeNull ()
expect (within (table).getAllByRole ('columnheader')).toHaveLength (2)
expect (within (table).getAllByRole ('cell')).toHaveLength (4)
expect (within (table).getByRole ('link', { name: 'ヘルプ' }))
.toHaveAttribute ('href', '/help/guide')
expect (table.querySelector ('strong')).toHaveTextContent ('強調')
})
it ('retains headings, paragraphs and lists', async () => {
const Content = await compileMdx ('# 見出し\n\n本文\n\n- 第一\n- 第二')
renderWithProviders (<Content/>)
expect (screen.getByRole ('heading', { level: 1 })).toHaveTextContent ('見出し')
expect (screen.getByText ('本文').tagName).toBe ('P')
expect (screen.getAllByRole ('listitem')).toHaveLength (2)
})
it ('keeps tables inside the Help dark-mode typography container', async () => {
const Content = await compileMdx (tableSource)
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx']
.mockResolvedValue ({ default: Content })
renderWithProviders (
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
{ route: '/help/guide' })
const table = await screen.findByRole ('table')
expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert')
})
it ('preserves long cell contents when rendering a wide table', async () => {
const cell = 'wide'.repeat (100)
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 => {
expect (await transformMdx (tableSource, suffix)).toBeNull ()
})
it ('uses the common PrefetchLink interaction for internal MDX links', async () => {
prefetch.mockClear ()
const Content = await compileMdx ('[内部リンク](/help/guide)')
renderWithProviders (<Content/>)
fireEvent.mouseEnter (screen.getByRole ('link', { name: '内部リンク' }))
expect (prefetch).toHaveBeenCalledWith (expect.anything (),
new URL ('/help/guide', window.location.origin).href)
})
})
+49 -1
ファイルの表示
@@ -1,4 +1,52 @@
import { useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import type { MDXComponents } from 'mdx/types' import type { MDXComponents } from 'mdx/types'
import type { ComponentProps } from 'react'
export const useMDXComponents = (): MDXComponents => ({ }) const MDXLink = (
{ href, children, ...props }: ComponentProps<'a'>,
) => {
const location = useLocation ()
const url = useMemo (() => {
try
{
const base = new URL (location.pathname + location.search, window.location.origin)
return href ? new URL (href, base) : null
}
catch
{
return null
}
}, [href, location.pathname, location.search])
const internal = url != null
&& url.origin === window.location.origin
&& (url.protocol === 'http:' || url.protocol === 'https:')
const anchor = href?.startsWith ('#')
|| (url?.pathname === location.pathname
&& url.search === location.search && Boolean (url.hash))
const external = url != null && !(internal)
&& (url.protocol === 'http:' || url.protocol === 'https:')
if (internal && !(anchor) && props.download == null)
{
return (
<PrefetchLink to={url.pathname + url.search + url.hash} {...props}>
{children}
</PrefetchLink>)
}
return (
<a href={href}
{...props}
target={external ? props.target ?? '_blank' : props.target}
rel={props.rel ?? 'noopener noreferrer'}>
{children}
</a>)
}
export const useMDXComponents = (): MDXComponents => ({ a: MDXLink })
+55
ファイルの表示
@@ -0,0 +1,55 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
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')
})
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn (), apiPost: async () => ({}),
isApiError: () => false }))
vi.mock ('@/components/TopNav', () => ({ default: () => null }))
vi.mock ('@/components/DevModeWatermark', () => ({ default: () => null }))
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
useClientBehaviourSettings: () => ({ animation: 'off', linkPreload: 'off' }) }))
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 (
<QueryClientProvider client={client}>
<HelmetProvider><App/></HelmetProvider>
</QueryClientProvider>)
const links = await screen.findAllByRole ('link')
const editions = links.map (link => link.getAttribute ('href'))
.filter (href => href?.startsWith ('/help/') && href.includes ('edition='))
expect (editions).toEqual ([
`/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`,
'/help/guide?edition=2026-10-02.10', '/help/guide?edition=2026-10-02.3',
'/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 (container).not.toHaveTextContent ('Invalid Date')
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
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')
})
})
+119
ファイルの表示
@@ -0,0 +1,119 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { Helmet } from 'react-helmet-async'
import { useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { getHelpChanges, helpEditionDate } from '@/lib/help'
import { helpKeys } from '@/lib/queryKeys'
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
import { dateString } from '@/lib/utils'
import type { FC } from 'react'
const HelpChangesPage: FC = () => {
const [searchParams, setSearchParams] = useSearchParams ()
const identifier = searchParams.get ('identifier') ?? ''
const pageValue = searchParams.get ('page') ?? '1'
const pageNumber = Number (pageValue)
const requestedPage =
(/^[1-9]\d*$/.test (pageValue) && Number.isSafeInteger (pageNumber))
? pageNumber : 1
const limit = LIST_LIMIT_OPTIONS.find (
value => String (value) === searchParams.get ('limit')) ?? 20
const { data: changes = [], isLoading, error } = useQuery ({
queryKey: helpKeys.changes ('ja'),
queryFn: () => getHelpChanges ('ja'),
staleTime: Infinity })
const results = useMemo (() => identifier
? changes.filter (entry => entry.identifier === identifier)
: changes, [changes, identifier])
const totalPages = Math.ceil (results.length / limit)
const page = requestedPage <= Math.max (1, totalPages) ? requestedPage : 1
const pageResults = results.slice ((page - 1) * limit, page * limit)
const handleLimit = (value: string) => {
const next = new URLSearchParams (searchParams)
next.set ('limit', value)
next.set ('page', '1')
setSearchParams (next)
}
return (
<MainArea className="min-w-0 max-w-full">
<Helmet>
<title>{`ヘルプ改定履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>ヘルプ改定履歴</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>件数</span>
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground">
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>{value}</option>))}
</select>
</label>
</div>
{isLoading
? <p className="mt-4">読み込み中...</p>
: (error
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
: (
<div className="mt-4">
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="table-auto w-full min-w-[900px] whitespace-nowrap
border-collapse">
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">差分</th>
<th className="p-2 text-left">タイトル</th>
<th className="p-2 text-left">有効開始年月日</th>
</tr>
</thead>
<tbody>
{pageResults.map (entry => {
const path = `/help/${ encodeURIComponent (entry.identifier) }`
const diffQuery = new URLSearchParams ({
from: entry.pred ?? '', to: entry.edition })
const diffTo = `${ path }/diff?${ diffQuery.toString () }`
const updatedAt = helpEditionDate (entry.edition)
return (
<tr key={`${ entry.identifier }/${ entry.edition }`}
className="even:bg-gray-100 text-gray-900
dark:even:bg-gray-700 dark:text-gray-100">
<td className="p-2">
{entry.pred != null && (
<PrefetchLink to={diffTo}>
差分
</PrefetchLink>)}
</td>
<td className="p-2">
<PrefetchLink to={`${ path }?edition=${ entry.edition }`}>
{entry.title}
</PrefetchLink>
</td>
<td className="p-2">
{updatedAt != null && dateString (updatedAt, 'hour')}
</td>
</tr>)
})}
</tbody>
</table>
</div>
{totalPages > 0 && <Pagination page={page} totalPages={totalPages}/>}
</div>))}
</MainArea>)
}
export default HelpChangesPage
+128
ファイルの表示
@@ -0,0 +1,128 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
import type { ReactElement } from 'react'
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
const Navigation = () => {
const navigate = useNavigate ()
const location = useLocation ()
return (
<>
<output data-testid="location">{location.pathname}{location.search}</output>
<button onClick={() => navigate ('/help/設定%20手順')}>switch article</button>
<button onClick={() => navigate ('/help/guide?edition=2026-10-02.3')}>
switch edition
</button>
</>)
}
const renderDetail = (route = '/help/guide') => renderWithProviders (
<>
<Navigation/>
<Routes>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
</Routes>
</>, { route })
describe ('Help details', () => {
beforeEach (() => helpFixture.reset ())
it.each (['2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']) (
'displays a date without inventing a time for edition %s', async edition => {
const { container } = renderDetail (`/help/guide?edition=${ edition }`)
await screen.findByRole ('heading')
expect (container).toHaveTextContent ('10月2日')
expect (container).not.toHaveTextContent ('Invalid Date')
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
const revision = edition.split ('.')[1]
if (revision != null)
expect (container).toHaveTextContent (` .${ revision }`)
expect (helpFixture.modules[`../../help/guide/editions/ja/${ edition }.mdx`])
.toHaveBeenCalled ()
})
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 ('ホーム第十版 ヘルプ'))
})
it ('uses the historical H1 in both page and document titles', async () => {
renderDetail ('/help/guide?edition=2026-10-01')
expect (await screen.findByRole ('heading', { name: '旧ホーム' })).toBeInTheDocument ()
expect (screen.getByText ('過去限定本文')).toBeInTheDocument ()
await waitFor (() => expect (document.title).toContain ('旧ホーム ヘルプ'))
expect (screen.getByText ('(最古)')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '新 >' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02')
})
it ('navigates .2, .3 and .10 in numeric edition order', async () => {
renderDetail ('/help/guide?edition=2026-10-02.3')
await screen.findByRole ('heading', { name: 'ホーム第三版' })
expect (screen.getByRole ('link', { name: '< 古' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02.2')
const next = screen.getByRole ('link', { name: '新 >' })
expect (next).toHaveAttribute ('href', '/help/guide?edition=2026-10-02.10')
fireEvent.click (next)
await screen.findByRole ('heading', { name: 'ホーム第十版' })
expect (screen.getByText ('(最新)')).toBeInTheDocument ()
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
})
it.each (['/help/missing', '/help/guide?edition=missing',
'/help/guide?edition=2026-02-30', '/help/guide?edition=2026-10-02.2.extra',
'/help/guide?edition=2026-10-02.11']) (
'shows NotFound without redirecting %s', async route => {
renderDetail (route)
expect (await screen.findByText ('404')).toBeInTheDocument ()
expect (screen.getByTestId ('location')).toHaveTextContent (route)
})
it ('hides the old body and title immediately while another article loads', async () => {
let finish!: (value: { default: () => ReactElement }) => void
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
finish = resolve
})
helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx']
.mockReturnValueOnce (pending)
renderDetail ()
await screen.findByRole ('heading', { name: 'ホーム第十版' })
fireEvent.click (screen.getByRole ('button', { name: 'switch article' }))
expect (screen.queryByText ('タグを検索する 第十版本文')).not.toBeInTheDocument ()
await waitFor (() => expect (document.title).not.toContain ('ホーム'))
await act (async () => finish ({ default: () => <h1>設定</h1> }))
await screen.findByRole ('heading', { name: '設定' })
await waitFor (() => expect (document.title).toContain ('設定 ヘルプ'))
})
it ('ignores a late response for the previous edition', async () => {
let finish!: (value: { default: () => ReactElement }) => void
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
finish = resolve
})
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: () => <h1>遅延した旧本文</h1> }))
expect (screen.queryByText ('遅延した旧本文')).not.toBeInTheDocument ()
expect (screen.getByRole ('heading', { name: 'ホーム第三版' })).toBeInTheDocument ()
await waitFor (() => expect (document.title).toContain ('ホーム第三版 ヘルプ'))
})
})
+106
ファイルの表示
@@ -0,0 +1,106 @@
import { useQuery } from '@tanstack/react-query'
import { Helmet } from 'react-helmet-async'
import { useLocation, useParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { getHelpArticle,
getHelpEditionContent,
helpEditionDate,
parseHelpEdition } from '@/lib/help'
import { dateString } from '@/lib/utils'
import { helpKeys } from '@/lib/queryKeys'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
const HelpDetailPage: FC = () => {
const { identifier = '' } = useParams ()
const location = useLocation ()
// Locale 対応が入るまでは ja 固定
const language = 'ja'
const { data: article, isLoading: articleLoading } = useQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const query = new URLSearchParams (location.search)
const requestedEdition = query.get ('edition')
const languageMeta = article?.languages[language]
const edition = requestedEdition ?? languageMeta?.currentEdition
const editionInfo = parseHelpEdition (edition ?? '')
const editionDate = helpEditionDate (edition ?? '')
const editions = languageMeta?.publishedEditions ?? []
const index = edition ? editions.indexOf (edition) : -1
const pred = index > 0 ? editions[index - 1] : undefined
const succ =
index >= 0 && index < editions.length - 1
? editions[index + 1]
: undefined
const { data: current, isSuccess, isError } = useQuery ({
enabled: edition != null && editionInfo != null && editions.includes (edition),
queryKey: helpKeys.edition (identifier, language, edition ?? ''),
queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''),
staleTime: Infinity })
if (!articleLoading
&& (article == null
|| languageMeta == null
|| edition == null
|| editionInfo == null
|| !(editions.includes (edition))))
return <NotFound/>
if (isError || (isSuccess && current == null))
return <NotFound/>
const Content = current?.content
return (
<MainArea>
<Helmet>
<title>{current?.title || identifier} ヘルプ | {SITE_TITLE}</title>
</Helmet>
{requestedEdition && (
<div className="text-sm flex gap-3 items-center justify-center
border border-gray-700 rounded px-2 py-1 mb-4">
{pred
? (
<PrefetchLink
to={`/help/${ encodeURIComponent (identifier) }?edition=${ pred }`}>
&lt; 古
</PrefetchLink>)
: '(最古)'}
<span>
{editionDate != null && dateString (editionDate, 'hour')}
{editionInfo?.revision != null && ` .${ editionInfo.revision }`}
</span>
{succ
? (
<PrefetchLink
to={`/help/${ encodeURIComponent (identifier) }?edition=${ succ }`}>
新 &gt;
</PrefetchLink>)
: '(最新)'}
</div>)}
<article className="prose dark:prose-invert mx-auto p-4">
{Content ? <Content/> : <div>Loading...</div>}
</article>
</MainArea>)
}
export default HelpDetailPage
+81
ファイルの表示
@@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query'
import { Helmet } from 'react-helmet-async'
import { useParams, useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { getHelpDiff } from '@/lib/help'
import { helpKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
const markers = { added: '+ ', removed: '- ', unchanged: ' ' }
const HelpDiffPage: FC = () => {
const { identifier = '' } = useParams ()
const [searchParams] = useSearchParams ()
const from = searchParams.get ('from') ?? ''
const to = searchParams.get ('to') ?? ''
const validParams = Boolean (identifier && from && to)
&& searchParams.getAll ('from').length === 1
&& searchParams.getAll ('to').length === 1
const { data: diff, isLoading, isError } = useQuery ({
enabled: validParams,
queryKey: helpKeys.diff (identifier, 'ja', from, to),
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
staleTime: Infinity })
if (!(validParams) || isError || (!(isLoading) && diff == null))
return <NotFound/>
const title = `${ diff?.to.title ?? identifier } (${ from } → ${ to })`
const path = `/help/${ encodeURIComponent (identifier) }`
return (
<MainArea className="min-w-0 max-w-full">
<Helmet>
<title>{`ヘルプ 差分: ${ title } | ${ SITE_TITLE }`}</title>
</Helmet>
<PageTitle>{title}</PageTitle>
{diff
? (
<>
<div className="flex flex-wrap items-center gap-3">
<PrefetchLink to={`${ path }?edition=${ encodeURIComponent (from) }`}>
{diff.from.title} ({from})
</PrefetchLink>
<span>→</span>
<PrefetchLink to={`${ path }?edition=${ encodeURIComponent (to) }`}>
{diff.to.title} ({to})
</PrefetchLink>
</div>
<pre className="mt-4 overflow-x-auto whitespace-pre">
<code>
{diff.lines.map ((line, index) => {
const colour = cn (
'block text-gray-900 dark:text-gray-100',
line.type === 'added' && 'bg-green-200 dark:bg-green-800',
line.type === 'added' && 'text-green-950 dark:text-green-50',
line.type === 'removed' && 'bg-red-200 dark:bg-red-800',
line.type === 'removed' && 'text-red-950 dark:text-red-50')
return (
<span key={index} className={colour}>
{markers[line.type]}{line.content.replace (/\r?\n$/, '')}
</span>)
})}
</code>
</pre>
</>)
: <div>Loading...</div>}
</MainArea>)
}
export default HelpDiffPage
+130
ファイルの表示
@@ -0,0 +1,130 @@
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import HelpListPage from '@/pages/help/HelpListPage'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
describe ('Help search', () => {
beforeEach (() => helpFixture.reset ())
it ('initially lists each current article once with encoded detail links', async () => {
renderWithProviders (<HelpListPage/>, { route: '/help' })
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 ()
})
it.each ([
['ホーム', '', ['ホーム第十版']],
['', '検索', ['ホーム第十版']],
['', 'タグ', ['設定', 'ホーム第十版']],
['ホーム', '設定', []],
['ホーム', '検索', ['ホーム第十版']],
['旧ホーム', '', []],
['', '過去限定本文', []]]) (
'searches title %s and body %s together', async (title, body, expected) => {
const { container } = renderWithProviders (<HelpListPage/>)
await screen.findByText ('2 件')
fireEvent.change (screen.getByRole ('textbox', { name: 'タイトル' }), {
target: { value: title } })
fireEvent.change (screen.getByRole ('textbox', { name: '内容' }), {
target: { value: body } })
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
expect (Array.from (container.querySelectorAll ('tbody a'), link => link.textContent))
.toEqual (expected)
if (expected.length === 0)
expect (screen.getByText ('該当する記事がありません.')).toBeInTheDocument ()
})
it ('displays loading failures without stale results', async () => {
helpFixture.loads.search.mockImplementationOnce (() => {
throw new Error ('fixture loading failed')
})
renderWithProviders (<HelpListPage/>)
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument ()
})
it ('sorts edition suffixes numerically and displays their date without a time', async () => {
const editions = ['2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']
helpFixture.assets.search.default.ja = editions.map ((edition, index) => ({
identifier: `article-${ index }`, edition, title: `article-${ index }`, body: '' }))
const { container } = renderWithProviders (<HelpListPage/>, { route: '/help' })
await screen.findByText ('4 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link => link.textContent))
.toEqual (['article-3', 'article-2', 'article-1', 'article-0'])
const dates = Array.from (container.querySelectorAll ('tbody tr'), row =>
row.children[1].textContent)
expect (dates).toHaveLength (4)
dates.forEach (date => {
expect (date).toContain ('10月2日')
expect (date).not.toContain ('Invalid Date')
expect (date).not.toMatch (/\d{1,2}:\d{2}/)
})
})
it.each ([['ホーム', 'ほーむ', '', true], ['ほーむ', 'ホーム', '', true],
['ホーム', 'ホーム', '', true], ['ホーム', '', 'ホーム', true],
['ホーム', 'ほーむ', 'ホーム', true], ['ホーム', 'ほーむ', '不一致', false]] as const) (
'searches title %s with %s and body with %s', async (title, query, text, found) => {
const entry = { identifier: 'guide', edition: '2026-10-02.10',
title, body: 'ほーむの案内' }
helpFixture.assets.search.default.ja = [entry]
const params = new URLSearchParams ({ title: query, text })
renderWithProviders (<HelpListPage/>, { route: `/help?${ params }` })
await screen.findByText (`${ found ? 1 : 0 } 件`)
if (found)
expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/help/guide')
else
expect (screen.queryByRole ('link', { name: title })).not.toBeInTheDocument ()
expect (entry).toEqual ({ identifier: 'guide', edition: '2026-10-02.10',
title, body: 'ほーむの案内' })
})
// ai_ci equality is documented by MySQL; these are not captured DB sort results.
it.each (['asc', 'desc']) ('orders different titles before identifiers in order %s',
async direction => {
helpFixture.assets.search.default.ja = [
{ identifier: 'alpha', edition: '2026-10-02', title: 'Zebra', body: '' },
{ identifier: 'beta', edition: '2026-10-02', title: 'Apple', body: '' }]
const { container } = renderWithProviders (<HelpListPage/>, {
route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件')
const expected = direction === 'asc'
? ['/help/beta', '/help/alpha'] : ['/help/alpha', '/help/beta']
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
link.getAttribute ('href'))).toEqual (expected)
})
it.each (['asc', 'desc']) ('uses identifier ties for MySQL ai_ci title order %s',
async direction => {
helpFixture.assets.search.default.ja = [
{ identifier: 'alpha', edition: '2026-10-02', title: 'Éclair', body: '' },
{ identifier: 'beta', edition: '2026-10-02', title: 'eclair', body: '' }]
const { container } = renderWithProviders (<HelpListPage/>, {
route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
})
it.each (['asc', 'desc']) ('ignores title case before identifier ties in order %s',
async direction => {
helpFixture.assets.search.default.ja = [
{ identifier: 'alpha', edition: '2026-10-02', title: 'home', body: '' },
{ identifier: 'beta', edition: '2026-10-02', title: 'HOME', body: '' }]
const { container } = renderWithProviders (<HelpListPage/>, {
route: `/help?order=title:${ direction }` })
await screen.findByText ('2 件')
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
})
})
+212
ファイルの表示
@@ -0,0 +1,212 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { compareHelpEditions,
compareHelpTitles,
getHelpSearchEntries,
helpEditionDate,
normaliseHelpSearchText } from '@/lib/help'
import { helpKeys } from '@/lib/queryKeys'
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
import { dateString, inputClass } from '@/lib/utils'
import type { FormEvent, FC } from 'react'
type SearchConditions = { title: string; text: string }
type HelpOrderField = 'title' | 'updated_at'
type HelpOrder = `${ HelpOrderField }:${ 'asc' | 'desc' }`
const defaultDirection: Record<HelpOrderField, 'asc' | 'desc'> = {
title: 'asc', updated_at: 'desc' }
const HelpSearchPage: FC = () => {
const [searchParams, setSearchParams] = useSearchParams ()
const pageValue = searchParams.get ('page') ?? '1'
const pageNumber = Number (pageValue)
const requestedPage =
(/^[1-9]\d*$/.test (pageValue) && Number.isSafeInteger (pageNumber))
? pageNumber : 1
const limit = LIST_LIMIT_OPTIONS.find (
value => String (value) === searchParams.get ('limit')) ?? 20
const requestedOrder = searchParams.get ('order') ?? 'updated_at:desc'
const order: HelpOrder =
['title:asc', 'title:desc', 'updated_at:asc', 'updated_at:desc'].includes (requestedOrder)
? requestedOrder as HelpOrder
: 'updated_at:desc'
const conditions = useMemo<SearchConditions> (() => ({
title: searchParams.get ('title') ?? '',
text: searchParams.get ('text') ?? '' }), [searchParams])
const [title, setTitle] = useState (conditions.title)
const [text, setText] = useState (conditions.text)
const { data: entries = [], isLoading: loading, error } = useQuery ({
queryKey: helpKeys.index ('ja'),
queryFn: () => getHelpSearchEntries ('ja'),
staleTime: Infinity })
useEffect (() => {
setTitle (conditions.title)
setText (conditions.text)
}, [conditions.title, conditions.text])
const results = useMemo (() => {
const titleQuery = normaliseHelpSearchText (conditions.title.trim ())
const textQuery = normaliseHelpSearchText (conditions.text.trim ())
const [field, direction] = order.split (':')
return (
entries.filter (entry => (
(!(titleQuery)
|| normaliseHelpSearchText (entry.title).includes (titleQuery))
&& (!(textQuery)
|| normaliseHelpSearchText (entry.body).includes (textQuery))))
.sort ((a, b) => {
const comparison = field === 'title'
? compareHelpTitles (a.title, b.title)
: compareHelpEditions (a.edition, b.edition)
return (direction === 'asc' ? comparison : -comparison)
|| a.identifier.localeCompare (b.identifier)
}))
}, [entries, conditions, order])
const totalPages = Math.ceil (results.length / limit)
const page = requestedPage <= Math.max (1, totalPages) ? requestedPage : 1
const pageResults = results.slice ((page - 1) * limit, page * limit)
const handleLimit = (value: string) => {
const next = new URLSearchParams (searchParams)
next.set ('limit', value)
next.set ('page', '1')
setSearchParams (next)
}
const handleSearch = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
const next = new URLSearchParams (searchParams)
if (title)
next.set ('title', title)
else
next.delete ('title')
if (text)
next.set ('text', text)
else
next.delete ('text')
next.set ('page', '1')
setSearchParams (next)
}
return (
<MainArea className="min-w-0 max-w-full">
<Helmet>
<title>{`ヘルプ | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>ヘルプ検索</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span>件数</span>
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground">
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>{value}</option>))}
</select>
</label>
</div>
<form onSubmit={handleSearch} className="space-y-2">
<FormField label="タイトル">
{({ invalid }) => (
<input type="text"
aria-label="タイトル"
value={title}
onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="内容">
{({ invalid }) => (
<input type="text"
aria-label="内容"
value={text}
onChange={e => setText (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<div className="py-3">
<button type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded">
検索
</button>
</div>
</form>
</div>
{loading
? <p className="mt-4">読み込み中...</p>
: (error
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
: (
<div className="mt-4">
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="table-auto w-full min-w-[640px] whitespace-nowrap
border-collapse text-gray-900 dark:text-gray-100">
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">
<SortHeader<HelpOrderField>
by="title"
label="タイトル"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
<th className="p-2 text-left">
<SortHeader<HelpOrderField>
by="updated_at"
label="有効開始年月日"
currentOrder={order}
defaultDirection={defaultDirection}/>
</th>
</tr>
</thead>
<tbody>
{pageResults.map (entry => {
const updatedAt = helpEditionDate (entry.edition)
return (
<tr key={entry.identifier}
className="even:bg-gray-100 text-gray-900
dark:even:bg-gray-700 dark:text-gray-100">
<td className="p-2">
<PrefetchLink
to={`/help/${encodeURIComponent (entry.identifier)}`}>
{entry.title}
</PrefetchLink>
</td>
<td className="p-2">
{updatedAt != null && dateString (updatedAt, 'hour')}
</td>
</tr>)
})}
</tbody>
</table>
</div>
{totalPages > 0 && <Pagination page={page} totalPages={totalPages}/>}
{results.length === 0 && <p className="mt-4">該当する記事がありません.</p>}
</div>))}
</MainArea>)
}
export default HelpSearchPage
+141 -4
ファイルの表示
@@ -1,14 +1,19 @@
import { screen } from '@testing-library/react' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes, useLocation } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiPost } from '@/lib/api'
import { getHelpTitleIndex } from '@/lib/help'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildTag, buildWikiPage } from '@/test/factories' import WikiNewPage from '@/pages/wiki/WikiNewPage'
import { buildTag, buildUser, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const wikiApi = vi.hoisted (() => ({ const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (), fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (), fetchWikiPageByTitle: vi.fn (),
fetchWikiPages: vi.fn (),
})) }))
const tagsApi = vi.hoisted (() => ({ const tagsApi = vi.hoisted (() => ({
@@ -22,8 +27,140 @@ const postsApi = vi.hoisted (() => ({
vi.mock ('@/lib/wiki', () => wikiApi) vi.mock ('@/lib/wiki', () => wikiApi)
vi.mock ('@/lib/tags', () => tagsApi) vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/help', () => ({ getHelpTitleIndex: vi.fn () }))
vi.mock ('@/lib/api', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/api')> (), apiPost: vi.fn () }))
vi.mock ('@/components/ui/use-toast', () => ({ toast: vi.fn () }))
vi.mock ('react-markdown-editor-lite', () => ({
default: ({ value, onChange }: {
value: string
onChange: (event: { text: string }) => void }) => (
<textarea aria-label="本文エディタ" value={value}
onChange={event => onChange ({ text: event.target.value })}/>) }))
const Location = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}{location.search}</output>
}
describe ('WikiDetailPage', () => { describe ('WikiDetailPage', () => {
beforeEach (() => {
wikiApi.fetchWikiPageByTitle.mockReset ().mockResolvedValue (null)
wikiApi.fetchWikiPages.mockReset ().mockResolvedValue ([])
tagsApi.fetchTagByName.mockReset ().mockResolvedValue (null)
postsApi.fetchPosts.mockReset ().mockResolvedValue ({ posts: [], count: 0 })
vi.mocked (getHelpTitleIndex).mockReset ().mockResolvedValue ({ })
vi.mocked (apiPost).mockReset ()
})
it ('offers creation for an existing tag after the Help lookup completes', async () => {
let finish!: (index: Record<string, string[]>) => void
const pending = new Promise<Record<string, string[]>> (resolve => {
finish = resolve
})
vi.mocked (getHelpTitleIndex).mockReturnValueOnce (pending)
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ', hasWiki: false }))
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
await waitFor (() => expect (getHelpTitleIndex).toHaveBeenCalled ())
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
await act (async () => finish ({ }))
expect (await screen.findByRole ('link', { name: '新規作成してください' }))
.toHaveAttribute ('href', `/wiki/new?title=${ encodeURIComponent ('既存タグ') }`)
})
it ('creates a tag description through the existing form and displays its content', async () => {
const page = buildWikiPage ({ title: '既存タグ', body: '説明文',
resourceType: 'tag_description' })
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: false }))
vi.mocked (apiPost).mockResolvedValueOnce (page)
const path = `/wiki/${ encodeURIComponent (page.title) }`
const { container, queryClient } = renderWithProviders (
<>
<Location/>
<Routes>
<Route path="/wiki/new" element={<WikiNewPage user={buildUser ({ role: 'member' })}/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>
</>, { route: path })
fireEvent.click (await screen.findByRole ('link', { name: '新規作成してください' }))
await screen.findByRole ('heading', { name: '新規 Wiki ページ' })
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toBeNull ()
queryClient.setQueryData (wikiKeys.index ({ }), [])
wikiApi.fetchWikiPageByTitle.mockResolvedValue (page)
wikiApi.fetchWikiPages.mockResolvedValue ([page])
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: page.title, hasWiki: true }))
expect (screen.getAllByRole ('textbox')[0]).toHaveValue (page.title)
fireEvent.change (screen.getByRole ('textbox', { name: '本文エディタ' }), {
target: { value: page.body } })
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
await screen.findByRole ('heading', { name: page.title })
expect (screen.getByTestId ('location')).toHaveTextContent (path)
expect (screen.queryByRole ('textbox', { name: '本文エディタ' })).not.toBeInTheDocument ()
expect (within (container.querySelector ('article')!).getByText (page.body))
.toBeInTheDocument ()
const form = vi.mocked (apiPost).mock.calls[0][1] as FormData
expect (form.get ('title')).toBe (page.title)
expect (form.get ('body')).toBe (page.body)
expect (apiPost).toHaveBeenCalledWith ('/wiki', form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toEqual (page)
await waitFor (() => expect (queryClient.getQueryData (wikiKeys.index ({ })))
.toEqual ([page]))
await waitFor (() => expect (queryClient.getQueryData (tagsKeys.show (page.title)))
.toMatchObject ({ hasWiki: true }))
expect (getHelpTitleIndex).toHaveBeenCalledTimes (1)
})
it ('keeps failed saves on the form without changing cached Wiki or tag state', async () => {
const title = '既存タグ'
const tag = buildTag ({ name: title, hasWiki: false })
const path = `/wiki/new?title=${ encodeURIComponent (title) }`
vi.mocked (apiPost).mockRejectedValueOnce (new Error ('save failed'))
const { queryClient } = renderWithProviders (
<><Location/><WikiNewPage user={buildUser ({ role: 'member' })}/></>, { route: path })
queryClient.setQueryData (wikiKeys.show (title, { }), null)
queryClient.setQueryData (wikiKeys.index ({ }), [])
queryClient.setQueryData (tagsKeys.show (title), tag)
fireEvent.change (screen.getByRole ('textbox', { name: '本文エディタ' }), {
target: { value: '保存しない本文' } })
await act (async () => fireEvent.click (screen.getByRole ('button', { name: '追加' })))
expect (apiPost).toHaveBeenCalledTimes (1)
expect (screen.getByTestId ('location')).toHaveTextContent (path)
expect (screen.getByRole ('heading', { name: '新規 Wiki ページ' })).toBeInTheDocument ()
expect (queryClient.getQueryData (wikiKeys.show (title, { }))).toBeNull ()
expect (queryClient.getQueryData (tagsKeys.show (title))).toEqual (tag)
expect (queryClient.getQueryState (wikiKeys.index ({ }))?.isInvalidated).toBe (false)
})
it ('shows NotFound when neither a tag nor a Help title exists', async () => {
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: '/wiki/missing' })
expect (await screen.findByText ('404')).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
})
it.each (['403', '500', 'offline']) ('keeps Wiki error %s distinct from missing content',
async message => {
wikiApi.fetchWikiPageByTitle.mockRejectedValue (new Error (message))
tagsApi.fetchTagByName.mockResolvedValue (buildTag ({ name: '既存タグ' }))
renderWithProviders (
<Routes><Route path="/wiki/:title" element={<WikiDetailPage/>}/></Routes>,
{ route: `/wiki/${ encodeURIComponent ('既存タグ') }` })
expect (await screen.findByRole ('alert')).toHaveTextContent (message)
expect (getHelpTitleIndex).not.toHaveBeenCalled ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
})
it ('renders deprecated state outside the wiki title link', async () => { it ('renders deprecated state outside the wiki title link', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({ wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
title: '旧タグ', title: '旧タグ',
+44 -7
ファイルの表示
@@ -1,5 +1,3 @@
import type { FC } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
@@ -13,10 +11,14 @@ import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { getHelpTitleIndex } from '@/lib/help'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags' import { fetchTagByName } from '@/lib/tags'
import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki' import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
@@ -33,15 +35,33 @@ const WikiDetailPage: FC = () => {
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const version = query.get ('version') || undefined const version = query.get ('version') || undefined
const { data: wikiPage, isLoading: loading } = useQuery ({ const { data: wikiPage, isLoading: loading, isSuccess, isFetching, error } = useQuery ({
enabled: Boolean (title) && !(/^\d+$/.test (title)), enabled: Boolean (title) && !(/^\d+$/.test (title)),
queryKey: wikiKeys.show (title, { version }), queryKey: wikiKeys.show (title, { version }),
queryFn: () => fetchWikiPageByTitle (title, { version }) }) queryFn: () => fetchWikiPageByTitle (title, { version }) })
const missingWiki = isSuccess && !(isFetching) && wikiPage == null
const { data: titleIndex, isPending: lookupPending,
isFetching: lookupFetching, error: lookupError } = useQuery ({
enabled: missingWiki,
queryKey: helpKeys.titles ('ja'),
queryFn: () => getHelpTitleIndex ('ja'),
staleTime: Infinity })
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
const identifiers = useMemo (() => (
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
useEffect (() => {
if (missingWiki && !(lookupFetching) && lookupError == null && identifiers.length === 1)
navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true })
}, [missingWiki, lookupFetching, lookupError, identifiers, navigate])
const effectiveTitle = wikiPage?.title ?? title const effectiveTitle = wikiPage?.title ?? title
const deprecated = wikiPage?.deprecatedAt != null const deprecated = wikiPage?.deprecatedAt != null
const { data: tag } = useQuery ({ const { data: tag, isPending: tagPending,
isFetching: tagFetching, error: tagError } = useQuery ({
enabled: Boolean (effectiveTitle), enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) }) queryFn: () => fetchTagByName (effectiveTitle) })
@@ -73,19 +93,36 @@ const WikiDetailPage: FC = () => {
if (!(/^\d+$/.test (title))) if (!(/^\d+$/.test (title)))
return return
let active = true
void (async () => { void (async () => {
try try
{ {
const data = await fetchWikiPage (title, { }) const data = await fetchWikiPage (title, { })
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) if (active)
navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true })
} }
catch catch
{ {
; ;
} }
}) () }) ()
return () => {
active = false
}
}, [title, navigate]) }, [title, navigate])
const displayError = error ?? (missingWiki ? lookupError ?? tagError : null)
if (displayError != null)
return <MainArea><p role="alert">{displayError.message}</p></MainArea>
const waiting = loading || (isFetching && wikiPage == null)
|| (missingWiki && (lookupPending || lookupFetching || tagPending
|| (tagFetching && tag == null) || identifiers.length === 1))
if (missingWiki && !(waiting) && (tag == null || version != null))
return <NotFound/>
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
@@ -119,7 +156,7 @@ const WikiDetailPage: FC = () => {
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{deprecated && <span>(廃止)</span>} {deprecated && <span>(廃止)</span>}
</h1> </h1>
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} {waiting ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
{(!(version) && posts.length > 0) && ( {(!(version) && posts.length > 0) && (
<div className="not-prose"> <div className="not-prose">
+144
ファイルの表示
@@ -0,0 +1,144 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiGet } from '@/lib/api'
import * as help from '@/lib/help'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildWikiPage } from '@/test/factories'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
vi.mock ('@/lib/tags', () => ({ fetchTagByName: async () => null }))
vi.mock ('@/lib/posts', () => ({ fetchPosts: async () => ({ posts: [], count: 0 }) }))
const Location = () => {
const location = useLocation ()
const navigate = useNavigate ()
return (
<>
<output data-testid="location">{location.pathname}{location.search}</output>
<button onClick={() => navigate (`/wiki/${ encodeURIComponent ('該当なし') }`)}>
switch title
</button>
</>)
}
const renderWiki = (title: string) => {
const route = `/wiki/${encodeURIComponent (title)}`
const { queryClient } = renderWithProviders (
<>
<Location/>
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/404" element={<div>unexpected redirect</div>}/>
</Routes>
</>, { route })
return { route, queryClient }
}
describe ('Legacy Wiki to Help compatibility', () => {
beforeEach (() => {
helpFixture.reset ()
vi.mocked (apiGet).mockReset ()
vi.mocked (apiGet).mockImplementation (async path => {
if (path === '/wiki')
return []
throw { isAxiosError: true, response: { status: 404 } }
})
})
it ('keeps a successful Wiki result even when Help has the same title', async () => {
vi.mocked (apiGet).mockResolvedValueOnce (buildWikiPage ({
title: 'ホーム', body: 'Wiki の現行本文' }))
const { route } = renderWiki ('ホーム')
await screen.findByText ('Wiki の現行本文')
expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
})
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 ()
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, 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 () => {
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)
})
it ('waits for title lookup and does not redirect a later Wiki URL using an old title',
async () => {
let finish!: (index: Record<string, string[]>) => void
const pending = new Promise<Record<string, string[]>> (resolve => {
finish = resolve
})
const lookup = vi.spyOn (help, 'getHelpTitleIndex').mockReturnValueOnce (pending)
renderWiki ('ホーム')
await waitFor (() => expect (lookup).toHaveBeenCalled ())
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: 'switch title' }))
await waitFor (() => expect (apiGet).toHaveBeenCalledWith (
`/wiki/title/${ encodeURIComponent ('該当なし') }`, expect.anything ()))
await act (async () => finish ({ 'ホーム': ['guide'] }))
await screen.findByText ('404')
expect (screen.getByTestId ('location'))
.toHaveTextContent (`/wiki/${ encodeURIComponent ('該当なし') }`)
})
it ('does not treat a Wiki network failure as a missing Wiki', async () => {
vi.mocked (apiGet).mockRejectedValueOnce (new Error ('offline'))
const { route } = renderWiki ('ホーム')
expect (await screen.findByRole ('alert')).toHaveTextContent ('offline')
expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
.not.toBeInTheDocument ()
})
it.each ([403, 500]) ('does not redirect HTTP %s to Help', async status => {
vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } })
const { route, queryClient } = renderWiki ('ホーム')
await waitFor (() => {
expect (apiGet).toHaveBeenCalled ()
expect (queryClient.isFetching ()).toBe (0)
})
expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
})
})
+11 -4
ファイルの表示
@@ -1,10 +1,10 @@
import type { FC } from 'react' import { useQueryClient } from '@tanstack/react-query'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { useState } from 'react' import { useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import MdEditor from 'react-markdown-editor-lite' import MdEditor from 'react-markdown-editor-lite'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import 'react-markdown-editor-lite/lib/index.css'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
@@ -12,12 +12,13 @@ import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api' import { apiPost } from '@/lib/api'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import Forbidden from '@/pages/Forbidden' import Forbidden from '@/pages/Forbidden'
import 'react-markdown-editor-lite/lib/index.css' import type { FC } from 'react'
import type { User, WikiPage } from '@/types' import type { User, WikiPage } from '@/types'
@@ -38,6 +39,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const titleQuery = query.get ('title') ?? '' const titleQuery = query.get ('title') ?? ''
@@ -58,8 +60,13 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{ {
const data = await apiPost<WikiPage> ('/wiki', formData, const data = await apiPost<WikiPage> ('/wiki', formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }) { headers: { 'Content-Type': 'multipart/form-data' } })
await qc.cancelQueries ({ queryKey: wikiKeys.root })
qc.setQueryData (wikiKeys.show (data.title, { }), data)
await Promise.all ([
qc.invalidateQueries ({ queryKey: wikiKeys.root }),
qc.invalidateQueries ({ queryKey: tagsKeys.root })])
toast ({ title: '投稿成功!' }) toast ({ title: '投稿成功!' })
navigate (`/wiki/${ data.title }`) navigate (`/wiki/${ encodeURIComponent (data.title) }`)
} }
catch (e) catch (e)
{ {
+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}
+37
ファイルの表示
@@ -0,0 +1,37 @@
import * as jsxRuntime from 'react/jsx-runtime'
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
import { useMDXComponents } from '@/mdx-components'
import viteConfig from '../../vite.config'
import type { ComponentType } from 'react'
type Transform = (source: string, id: string) => Promise<{ code: string } | null>
export const transformMdx = async (source: string, suffix = '.mdx') => {
const plugins = viteConfig.plugins?.flat () as { name: string; transform: Transform }[]
const plugin = plugins.find (entry => entry.name === '@mdx-js/rollup')
if (plugin == null)
throw new Error ('The application MDX compiler is not registered')
return plugin.transform.call (plugin, source, `/test/help-table${suffix}`)
}
export const compileMdx = async (source: string): Promise<ComponentType> => {
const result = await transformMdx (source)
if (result == null)
throw new Error ('MDX was not compiled')
const code = transpileModule (result.code, {
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
const exports = { } as { default: ComponentType }
const requireFixture = (name: string) => {
if (name === 'react/jsx-runtime')
return jsxRuntime
if (name === '@/mdx-components')
return { useMDXComponents }
throw new Error (`Unexpected MDX dependency: ${name}`)
}
new Function ('require', 'exports', code) (requireFixture, exports)
return exports.default
}
+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,
+126
ファイルの表示
@@ -0,0 +1,126 @@
import { createElement } from 'react'
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
import { vi } from 'vitest'
import source from '@/lib/help.ts?raw'
import type { ComponentType } from 'react'
import type * as Help from '@/lib/help'
export const editionRows = [
['guide', 'ja', '2026-10-01', '旧ホーム', '過去限定本文'],
['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.11', '未公開ホーム', '未公開限定本文'],
['guide', 'en', '2026-10-02', 'Home', 'English body'],
['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const
const compiled = transpileModule (source, {
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
// Supply generated virtual modules, executing the actual runtime Help module.
export const createHelpFixture = () => {
const articles: Record<string, Help.ArticleMeta> = {
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 (published.map (([id, language, edition, title, body]) => [
`../../help/${id}/editions/${language}/${edition}.mdx`,
vi.fn (async (): Promise<{ default: ComponentType }> => ({
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<string, { promise?: Promise<{ default: ComponentType }> }> = { }
const modules = Object.fromEntries (Object.entries (moduleLoads).map (([path, load]) => {
caches[path] = { }
return [path, vi.fn (() => caches[path].promise ??= load ())]
}))
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<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 requireFixture = (name: string) => {
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', 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 }
}
export const helpFixture = createHelpFixture ()
+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
ベンダーファイル
+28
ファイルの表示
@@ -1 +1,29 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module 'virtual:help/articles' {
const articles: Record<string, import ('./lib/help').ArticleMeta>
export default articles
}
declare module 'virtual:help/search' {
const search: Record<string, import ('./lib/help').HelpSearchEntry[]>
export default search
}
declare module 'virtual:help/changes' {
const changes: Record<string, import ('./lib/help').HelpChange[]>
export default changes
}
declare module 'virtual:help/titles' {
const titles: Record<string, import ('./lib/help').HelpTitleIndex>
export default titles
}
declare module 'virtual:help/loaders' {
export const components: Record<string,
() => Promise<{ default: import ('react').ComponentType }>>
export const sources: Record<string, () => Promise<{ default: string }>>
export const texts: Record<string,
() => Promise<{ default: import ('./lib/help').HelpEditionText }>>
}
+18 -2
ファイルの表示
@@ -1,12 +1,28 @@
import mdx from '@mdx-js/rollup' import mdx from '@mdx-js/rollup'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
import remarkGFM from 'remark-gfm'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { helpAssets } from './scripts/help-assets'
const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components',
remarkPlugins: [remarkGFM] })
const originalTransform = mdxPlugin.transform
if (typeof originalTransform === 'function')
{
mdxPlugin.transform =
function (code, id)
{
return (/[?&](raw|url)(?:&|$)/.test (id)
? null
: originalTransform.call (this, code, id))
}
}
// https://vite.dev/config/
export default defineConfig ({ export default defineConfig ({
plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()], plugins: [helpAssets (), mdxPlugin, react ()],
resolve: { alias: { '@': path.resolve (__dirname, './src') } }, resolve: { alias: { '@': path.resolve (__dirname, './src') } },
server: { host: true, server: { host: true,
port: 5173, port: 5173,