コミットを比較

..
10 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 4f7c12b719 #425 2026-10-11 03:15:59 +09:00
みてるぞ aec65c36c6 #425 2026-10-11 03:10:35 +09:00
みてるぞ 02451e4caa #425 2026-10-11 01:32:46 +09:00
みてるぞ 20b5abbd2c #425 2026-10-10 13:42:24 +09:00
みてるぞ 7840e15d1a #425 2026-10-10 11:14:01 +09:00
みてるぞ 2052e14b18 #425 2026-10-09 22:25:59 +09:00
みてるぞ 890ffb57e1 #425 2026-10-09 20:38:14 +09:00
みてるぞ 08f3b6d716 #425 2026-10-09 20:27:51 +09:00
みてるぞ 343f33e1f9 #425 2026-10-08 23:11:32 +09:00
みてるぞ 9ca713d6e4 #425 2026-10-08 23:06:04 +09:00
99個のファイルの変更、1446行の追加、4215行の削除
+1 -4
ファイルの表示
@@ -70,10 +70,7 @@ 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 = wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.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, :tag_descriptions]) .includes(linked_tags: { tag_names: :wiki_page })
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, :tag_descriptions] }]) { tag_names: :wiki_page }] }])
.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, :tag_descriptions] }]) { tag_names: :wiki_page }] }])
.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, :tag_descriptions] }]) { tag_names: :wiki_page }] }])
.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_descriptions, parents: :tag_descriptions) .includes(:tag_names, :materials, tag_names: :wiki_page)
.where(id: all_parent_tag_ids) .where(id: all_parent_tag_ids)
.index_by(&:id) .index_by(&:id)
+41 -19
ファイルの表示
@@ -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_descriptions, parents: :tag_descriptions) .includes(:tag_names, :materials, tag_names: :wiki_page)
.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_descriptions, parents: :tag_descriptions) .includes(:tag_names, :materials, tag_names: :wiki_page)
.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_descriptions, parents: :tag_descriptions) .includes(:tag_names, :materials, tag_names: :wiki_page)
.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_descriptions, parents: :tag_descriptions) .includes(:tag_names, :materials, tag_names: :wiki_page)
.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_descriptions, parents: :tag_descriptions) .includes(:tag_names, tag_names: :wiki_page)
.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_descriptions, parents: :tag_descriptions) .includes(:tag_names, tag_names: :wiki_page)
.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_descriptions, parents: :tag_descriptions) .includes(:tag_names, tag_names: :wiki_page)
.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_descriptions, parents: :tag_descriptions) .includes(:tag_names, :materials, tag_names: :wiki_page)
.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
@@ -770,15 +770,31 @@ class TagsController < ApplicationController
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end end
current_tag_name&.update!(primary_flg: false, auto_generated: false) current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id)
promoted_tag_name.update!(tag:, primary_flg: true, auto_generated: false) current_tag_name.association(:wiki_page).reset
tag.association(:tag_names).reset
current_tag_name.update!(canonical_id: promoted_tag_name.id,
primary_flg: false, auto_generated: false)
promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true, auto_generated: false)
TagName.where(tag:,
language_code: current_tag_name.language_code,
primary_flg: false)
.where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name|
alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
end
tag.update!(tag_name_id: promoted_tag_name.id)
current_tag_name.association(:wiki_page).reset
current_tag_name.association(:tag).reset
current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
return unless old_owner_tag && old_owner_tag != tag return unless old_owner_tag && old_owner_tag != tag
record_tag_version!(old_owner_tag.reload, event_type: :update, record_tag_version!(old_owner_tag.reload, event_type: :update,
created_by_user: current_user, created_by_user: current_user,
language_code: promoted_tag_name.language_code) language_code: current_tag_name.language_code)
end end
def update_aliases! locale, tag, alias_names def update_aliases! locale, tag, alias_names
@@ -786,15 +802,20 @@ class TagsController < ApplicationController
affected_tags = [tag] affected_tags = [tag]
primary_tag_name = TagName.find_by!(tag:,
language_code: locale.language_code,
primary_flg: true)
current_aliases = tag.tag_names.where(language_code: locale.language_code, current_aliases = tag.tag_names.where(language_code: locale.language_code,
primary_flg: false).to_a primary_flg: false).to_a
requested_aliases = TagName.where( current_aliases.each do |alias_tag_name|
language_code: locale.language_code, name: alias_names) next if alias_names.include?(alias_tag_name.name)
.includes(:tag).index_by(&:name)
affected_tags << alias_tag_name.tag
end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = requested_aliases[alias_name] alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name)
if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id
alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.') alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.')
raise ActiveRecord::RecordInvalid, alias_tag_name raise ActiveRecord::RecordInvalid, alias_tag_name
@@ -810,15 +831,16 @@ class TagsController < ApplicationController
current_aliases.each do |alias_tag_name| current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name) next if alias_names.include?(alias_tag_name.name)
alias_tag_name.destroy! alias_tag_name.update!(canonical_id: nil, tag_id: nil)
end end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = requested_aliases[alias_name] || TagName.new( alias_tag_name = TagName.find_or_initialize_by(
language_code: locale.language_code, language_code: locale.language_code,
name: alias_name) name: alias_name)
alias_tag_name.script_code = locale.script_code alias_tag_name.script_code = locale.script_code
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false) alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false,
canonical_id: primary_tag_name.id)
end end
affected_tags.compact.uniq.each do |affected_tag| affected_tags.compact.uniq.each do |affected_tag|
+156 -45
ファイルの表示
@@ -1,16 +1,16 @@
class WikiPagesController < ApplicationController class WikiPagesController < ApplicationController
# 互換性のための Facade API rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
rescue_from TagDescriptionOperation::Conflict, with: :render_wiki_conflict
def index def index
# 互換性のための化石リソース
title = params[:title].to_s.strip title = params[:title].to_s.strip
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)
descriptions = descriptions.order(updated_at: :desc, id: :asc) tag_groups = TagGroup.all
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,13 +19,12 @@ 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.sort_by! do |row| json.concat(WikiPageRepr.base(pages))
[-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
@@ -38,7 +37,11 @@ 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
head :not_found page = WikiPage.joins(:tag_name)
.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
@@ -55,14 +58,20 @@ 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
head :not_found page =
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 :not_found head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
end end
def exists_by_title def exists_by_title
@@ -73,7 +82,8 @@ 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)
head :not_found exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
head exists ? :no_content : :not_found
end end
def diff def diff
@@ -102,7 +112,15 @@ class WikiPagesController < ApplicationController
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
'deprecated_at' => nil } 'deprecated_at' => nil }
else else
return head :not_found page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
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
@@ -134,26 +152,34 @@ class WikiPagesController < ApplicationController
if title.start_with?(TagGroupRepr::PREFIX) if title.start_with?(TagGroupRepr::PREFIX)
name = title.delete_prefix(TagGroupRepr::PREFIX) name = title.delete_prefix(TagGroupRepr::PREFIX)
parsed = TagGroupMarkdownParser.parse(body:, group: nil) parsed = TagGroupMarkdownParser.parse(body:, group: nil)
group = TagGroupOperation.create!( group = TagGroupOperation.create!(name:, **parsed, actor: current_user,
name:, **parsed.except(:compatibility_body), actor: current_user) require_compatibility: true)
version = current_group_version(group) version = current_group_version(group)
return render_group_representation(TagGroupRepr.one(group), version, return render_group_representation(TagGroupRepr.one(group), version,
body: parsed.fetch(:compatibility_body), body: parsed.fetch(:compatibility_body),
status: :created) status: :created)
end end
description = page = if title.include?(':')
if title.include?(':') ApplicationRecord.transaction do
return render_unprocessable_entity field: :title tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
else name.script_code = 'Jpan'
tag_name = TagName.find_by(language_code: 'ja', name: title) name.primary_flg = true
return render_unprocessable_entity field: :title unless tag_name&.tag name.auto_generated = false
TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user: current_user, message:)
end end
render json: WikiPageRepr.tag_description(description), status: :created Wiki::Commit.create_legacy_content!(
tag_name:, body:, created_by_user: current_user, message:)
end
else
tag_name = TagName.find_by(language_code: 'ja', name: title)
return render_unprocessable_entity field: :title unless tag_name&.tag
TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user: current_user, message:)
end
render json: WikiPageRepr.base(page), 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
@@ -178,38 +204,49 @@ class WikiPagesController < ApplicationController
name = title.delete_prefix(TagGroupRepr::PREFIX) name = title.delete_prefix(TagGroupRepr::PREFIX)
parsed = TagGroupMarkdownParser.parse(body:, group:) parsed = TagGroupMarkdownParser.parse(body:, group:)
TagGroupOperation.update!( TagGroupOperation.update!(group:, name:, **parsed, actor: current_user,
group:, name:, **parsed.except(:compatibility_body), actor: current_user) require_compatibility: true)
return head :ok return head :ok
end end
return render_unprocessable_entity field: :title if title.include?(':') page = WikiPage.find_by(id: params[:id])
description = TagDescription.find_by(id: params[:id]) if legacy_group_page?(page)
unless description return render_unprocessable_entity field: :title
return render_unprocessable_entity field: :title if group
return head :not_found
end end
tag = description.tag if group && !legacy_update_identified?(page, title, group)
tag_name = tag.tag_name('ja') return render_unprocessable_entity field: :title
return render_unprocessable_entity field: :title unless tag_name end
return render_unprocessable_entity field: :title if tag_name.name.include?(':') 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
tag.lock! page.lock!
old_title = tag_name.name
if title != old_title old_title = page.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
tag_name.update!(name: title) if title != old_title page.tag_name.update!(name: title) if title != old_title
message = params[:message].presence message = params[:message].presence
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user, description = TagDescription.find_by(id: page.id)
message:, base_revision_id:) description_page = description && (
tag&.id == description.tag_id ||
(tag.nil? && !old_title.include?(':')))
if description && !description_page
Wiki::Commit.legacy_content!(page:, body:, created_user: current_user,
message:, base_revision_id:)
else
Wiki::Commit.content!(page:, body:, created_user: current_user,
message:, base_revision_id:)
end
if title != old_title if tag && 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
@@ -236,7 +273,23 @@ 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
json = [] pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
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')))
@@ -249,6 +302,26 @@ class WikiPagesController < ApplicationController
private private
def legacy_group_page? page
return false unless page
return true if page.title.start_with?(TagGroupRepr::PREFIX)
prefix = ApplicationRecord.sanitize_sql_like(TagGroupRepr::PREFIX)
page.wiki_versions.where('title LIKE ?', "#{ prefix }%").exists?
end
def legacy_update_identified? page, title, group
return false unless page
return true if page.title == title
return false if title == group.name
namespace = page.title.split(':', 2)
return true if namespace.size == 2 && title.start_with?("#{ namespace.first }:")
base = params[:base_revision_id].presence
base && page.wiki_revisions.exists?(id: base)
end
def render_tag_group group def render_tag_group group
version = if params[:version].present? version = if params[:version].present?
group_version(group, params[:version]) group_version(group, params[:version])
@@ -278,7 +351,6 @@ 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,
@@ -335,6 +407,27 @@ class WikiPagesController < ApplicationController
end end
end end
def render_wiki_page_or_404 page
return head :not_found unless page
rev = find_revision(page)
return head :not_found unless rev
if rev.redirect?
return (
redirect_to wiki_page_by_title_path(title: rev.redirect_page.title),
status: :moved_permanently)
end
body = rev.body
revision_id = rev.id
pred = page.pred_revision_id(revision_id)
succ = page.succ_revision_id(revision_id)
updated_at = rev.created_at
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
end
def render_wiki_page_by_tag_description description def render_wiki_page_by_tag_description description
versions = description_versions(description) versions = description_versions(description)
historical = params[:version].present? historical = params[:version].present?
@@ -360,6 +453,16 @@ 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))
@@ -389,6 +492,14 @@ class WikiPagesController < ApplicationController
end end
end end
def find_revision page
if params[:version].present?
page.wiki_revisions.find_by(id: params[:version])
else
page.current_revision
end
end
def render_wiki_conflict err def render_wiki_conflict err
render json: { error: 'conflict', message: err.message }, status: :conflict render json: { error: 'conflict', message: err.message }, status: :conflict
end end
-2
ファイルの表示
@@ -1,6 +1,4 @@
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 }
+3 -2
ファイルの表示
@@ -13,13 +13,14 @@ class Locale < ApplicationRecord
def generate_tag_names! def generate_tag_names!
ApplicationRecord.transaction do ApplicationRecord.transaction do
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id) tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
Tag.where.not(id: tag_ids).includes(:tag_names).find_each do Tag.where.not(id: tag_ids).find_each do
TagName.create!(tag_id: _1.id, TagName.create!(tag_id: _1.id,
language_code:, language_code:,
name: TagName.generate_name(self, _1, _1.tag_name('ja')), name: TagName.generate_name(self, _1, _1.tag_name('ja')),
script_code:, script_code:,
primary_flg: true, primary_flg: true,
auto_generated: true) auto_generated: true,
canonical_id: nil)
end end
end end
end end
+20 -21
ファイルの表示
@@ -2,8 +2,6 @@ require 'set'
class Tag < ApplicationRecord class Tag < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
class NicoTagNormalisationError < ArgumentError class NicoTagNormalisationError < ArgumentError
; ;
end end
@@ -53,7 +51,6 @@ 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
@@ -91,6 +88,7 @@ class Tag < ApplicationRecord
end end
end end
def name(language_code) = tag_name(language_code)&.name def name(language_code) = tag_name(language_code)&.name
def wiki_page = tag_name('ja')&.wiki_page
def system? def system?
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES) tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
@@ -98,13 +96,7 @@ class Tag < ApplicationRecord
def deprecated? = deprecated_at? def deprecated? = deprecated_at?
def has_description? language_code: def has_wiki = wiki_page.present?
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
@@ -259,13 +251,16 @@ class Tag < ApplicationRecord
name = name.to_s.strip name = name.to_s.strip
ApplicationRecord.transaction do ApplicationRecord.transaction do
tn = TagName.find_by(language_code:, name:) tn = TagName.find_or_create_by!(language_code:, name:) do
next tn.tag if tn _1.script_code = locale.script_code
_1.primary_flg = true
end
tag = Tag.create!(category:) tag = tn.tag
tn = TagName.create!(tag:, language_code:, name:, next tag if tag
script_code: locale.script_code, primary_flg: true,
auto_generated: false) tag = Tag.create!(tag_name_id: tn.id, category:)
tn.update!(tag:, primary_flg: true, canonical_id: nil, auto_generated: false)
generate_tag_names!(locale, tag, tn) generate_tag_names!(locale, tag, tn)
tag.association(:tag_names).reset tag.association(:tag_names).reset
@@ -299,17 +294,20 @@ class Tag < ApplicationRecord
end end
end end
if source_tag.tag_descriptions.exists? source_tag_name = source_tag.tag_name('ja')
source_tag.errors.add(:base, :invalid)
raise ActiveRecord::RecordInvalid, source_tag if source_tag_name.wiki_page.present?
raise ActiveRecord::RecordInvalid.new(source_tag_name)
end end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false, source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false,
auto_generated: false,
updated_at: Time.current) updated_at: Time.current)
source_tag.destroy! source_tag.destroy!
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
TagVersioning.record!(target_tag, event_type: :update, created_by_user:) TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end end
@@ -369,7 +367,8 @@ class Tag < ApplicationRecord
name: TagName.generate_name(locale, tag, base_tag_name), name: TagName.generate_name(locale, tag, base_tag_name),
script_code:, script_code:,
primary_flg: true, primary_flg: true,
auto_generated: true) auto_generated: true,
canonical_id: nil)
end end
end end
end end
-2
ファイルの表示
@@ -1,5 +1,3 @@
class TagDescription < ApplicationRecord class TagDescription < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
belongs_to :tag belongs_to :tag
end end
-2
ファイルの表示
@@ -1,6 +1,4 @@
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
+13 -7
ファイルの表示
@@ -1,12 +1,14 @@
class TagName < ApplicationRecord class TagName < ApplicationRecord
belongs_to :language, foreign_key: :language_code, primary_key: :code belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :tag belongs_to :tag, optional: true
has_one :wiki_page
validates :name, presence: true, validates :name, presence: true,
length: { maximum: 255 }, length: { maximum: 255 },
uniqueness: { scope: :language_code } uniqueness: { scope: :language_code }
validate :alias_name_must_not_have_prefix validate :alias_name_must_not_have_prefix
validate :alias_must_not_have_wiki_page
validate :name_must_be_sanitised validate :name_must_be_sanitised
validate :name_mustnt_start_with_nico validate :name_mustnt_start_with_nico
validate :generated_name_must_be_reserved validate :generated_name_must_be_reserved
@@ -19,16 +21,14 @@ class TagName < ApplicationRecord
tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name) tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name)
primary_names = TagName.where(
language_code: locale.language_code, tag_id: tns.values.map(&:tag_id),
primary_flg: true).pluck(:tag_id, :name).to_h
names.map { |name| names.map { |name|
tag_name = tns[name] tag_name = tns[name]
if !tag_name || tag_name.primary? if !tag_name || tag_name.primary? || tag_name.tag_id.nil?
name name
else else
primary_names[tag_name.tag_id] || name TagName.find_by(language_code: locale.language_code,
tag_id: tag_name.tag_id,
primary_flg: true)&.name || name
end end
}.uniq }.uniq
end end
@@ -53,6 +53,12 @@ class TagName < ApplicationRecord
end end
end end
def alias_must_not_have_wiki_page
if !(primary?) && wiki_page
errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.'
end
end
def name_must_be_sanitised def name_must_be_sanitised
if name? && name != TagNameSanitisationRule.sanitise(name) if name? && name != TagNameSanitisationRule.sanitise(name)
errors.add :name, '名前に使用できない文字が含まれてゐます.' errors.add :name, '名前に使用できない文字が含まれてゐます.'
+7 -1
ファイルの表示
@@ -33,7 +33,13 @@ class TagNameSanitisationRule < ApplicationRecord
existing_tag = existing_tn.tag existing_tag = existing_tn.tag
source_tag = tn.tag source_tag = tn.tag
Tag.merge_tags!(existing_tag, source_tag) if existing_tag != source_tag if existing_tag
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
elsif source_tag
tn.update_columns(tag_id: nil, updated_at: Time.current)
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current)
end
tn.destroy! tn.destroy!
next next
+4 -4
ファイルの表示
@@ -13,10 +13,10 @@ class User < ApplicationRecord
has_many :ip_addresses, through: :user_ips has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy has_many :user_post_views, dependent: :destroy
has_many :viewed_posts, through: :user_post_views, source: :post has_many :viewed_posts, through: :user_post_views, source: :post
has_many :created_tag_descriptions, class_name: 'TagDescription', has_many :created_wiki_pages,
foreign_key: :created_by_user_id, dependent: :nullify class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify
has_many :updated_tag_descriptions, class_name: 'TagDescription', has_many :updated_wiki_pages,
foreign_key: :updated_by_user_id, dependent: :nullify class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
def viewed?(post) = user_post_views.exists?(post_id: post.id) def viewed?(post) = user_post_views.exists?(post_id: post.id)
+15
ファイルの表示
@@ -0,0 +1,15 @@
class WikiLine < ApplicationRecord
has_many :wiki_revision_lines, dependent: :restrict_with_exception
validates :sha256, presence: true, uniqueness: true, length: { is: 64 }
validates :body, presence: true
def self.upsert_by_body! body
sha = Digest::SHA256.hexdigest(body)
now = Time.current
upsert(sha256: sha, body:, created_at: now, updated_at: now)
find_by!(sha256: sha)
end
end
+58
ファイルの表示
@@ -0,0 +1,58 @@
require 'set'
class WikiPage < ApplicationRecord
include MyDiscard
has_many :wiki_revisions, dependent: :destroy
belongs_to :created_user, class_name: 'User'
belongs_to :updated_user, class_name: 'User'
has_many :redirected_from_revisions,
class_name: 'WikiRevision',
foreign_key: :redirect_page_id,
dependent: :nullify
has_many :wiki_versions
attribute :version_no, :integer, default: 1
belongs_to :tag_name
validates :tag_name, presence: true
validates :body, presence: true
def title = tag_name.name
def deprecated_at = tag_name.tag&.deprecated_at
def title= val
(self.tag_name ||= build_tag_name).name = val
end
def current_revision = wiki_revisions.order(id: :desc).first
def resolve_redirect limit: 10
page = self
visited = Set.new
limit.times do
return page if visited.include?(page.id)
visited.add(page.id)
rev = page.current_revision
return page if !(rev&.redirect?) || !(rev.redirect_page)
page = rev.redirect_page
end
page
end
def pred_revision_id revision_id
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
end
def succ_revision_id revision_id
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
end
+55
ファイルの表示
@@ -0,0 +1,55 @@
class WikiRevision < ApplicationRecord
belongs_to :wiki_page
belongs_to :base_revision, class_name: 'WikiRevision', optional: true
belongs_to :created_user, class_name: 'User'
belongs_to :redirect_page, class_name: 'WikiPage', optional: true
has_many :wiki_revision_lines, dependent: :delete_all
has_many :wiki_lines, through: :wiki_revision_lines
enum :kind, content: 0, redirect: 1
validates :kind, presence: true
validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :tree_sha256, length: { is: 64 }, allow_nil: true
validate :kind_consistency
def body
return unless content?
wiki_revision_lines
.includes(:wiki_line)
.order(:position)
.map { |rev| rev.wiki_line.body }
.join("\n")
end
private
def kind_consistency
if content?
if tree_sha256.blank?
errors.add(:tree_sha256, '種類がページの場合は必須です.')
end
if redirect_page_id.present?
errors.add(:redirect_page_id, '種類がページの場合は空である必要があります.')
end
end
if redirect?
if redirect_page_id.blank?
errors.add(:redirect_page_id, '種類がリダイレクトの場合は必須です.')
end
if tree_sha256.present?
errors.add(:tree_sha256, '種類がリダイレクトの場合は空である必要があります.')
end
if lines_count.to_i > 0
errors.add(:lines_count, '種類がリダイレクトの場合は 0 である必要があります.')
end
end
end
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
class WikiRevisionLine < ApplicationRecord
belongs_to :wiki_revision
belongs_to :wiki_line
validates :position, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :position, uniqueness: { scope: :wiki_revision_id }
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
class WikiVersion < ApplicationRecord
include VersionRecord
belongs_to :wiki_page
validates :title, presence: true
validates :body, presence: true
end
+4 -11
ファイルの表示
@@ -3,28 +3,21 @@
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: [:material_id, :has_deerjikists] }.freeze methods: [:has_wiki, :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('has_wiki' => tag.has_description?(language_code:), tag.as_json(BASE).merge(name: name_for(tag, 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('has_wiki' => tag.has_description?(language_code:), tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: [])
name: name_for(tag, language_code), aliases: [], parents: [])
end end
def many tags, language_code: 'ja' def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) }
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)
+11 -2
ファイルの表示
@@ -2,12 +2,17 @@
module WikiPageRepr module WikiPageRepr
BASE = { methods: [:title, :deprecated_at] }.freeze
module_function module_function
def base wiki_page
wiki_page.as_json(BASE)
end
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,
@@ -21,6 +26,10 @@ module WikiPageRepr
end end
def tag_group tag_group def tag_group tag_group
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group') TagGroupRepr.tag_group(tag_group)
end
def many wiki_pages
wiki_pages.map { |p| base(p) }
end end
end end
-56
ファイルの表示
@@ -1,56 +0,0 @@
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
+29 -45
ファイルの表示
@@ -1,20 +1,18 @@
class TagDescriptionOperation class TagDescriptionOperation
LANGUAGE_CODE = 'ja' LANGUAGE_CODE = 'ja'
class Conflict < StandardError
;
end
def self.create!(tag:, body:, created_by_user:, message: nil) def self.create!(tag:, body:, created_by_user:, message: nil)
ApplicationRecord.transaction do ApplicationRecord.transaction do
tag.lock! tag.lock!
raise ActiveRecord::RecordNotUnique if TagDescription.exists?( raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
tag_id: tag.id, language_code: LANGUAGE_CODE) tag_id: tag.id, language_code: LANGUAGE_CODE)
description = TagDescription.new(tag:, language_code: LANGUAGE_CODE) page = Wiki::CompatibilityWriter.create!(
body = normalise_body!(description, body) tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:)
record_version!(description:, body:, user: created_by_user) revision = page.wiki_revisions.order(id: :desc).first
description record_version!(tag:, revision:, body:, user: created_by_user,
description: nil)
page
end end
end end
@@ -24,47 +22,33 @@ 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)
body = normalise_body!(description, body) page = WikiPage.lock.find(description.id)
latest = TagDescriptionVersion.find_by( revision = Wiki::CompatibilityWriter.update!(
tag_id: tag.id, language_code: LANGUAGE_CODE, page:, body:, updated_by_user:, message:,
version_no: description.version_no) base_revision_id:)
if base_revision_id.present? && latest&.id != base_revision_id.to_i next unless revision
raise Conflict, '競合が発生してゐます.'
end
next if latest && normalise_body(description.body) == body
record_version!(description:, body:, user: updated_by_user) record_version!(tag:, revision:, body: revision.body,
user: updated_by_user, description:)
revision
end end
end end
def self.record_version! description:, body:, user: def self.record_version! tag:, revision:, body:, user:, description:
creating = description.new_record? version_no = description ? description.version_no + 1 : 1
version_no = creating ? 1 : description.version_no + 1 TagDescriptionVersion.create!(
version = TagDescriptionVersion.create!( id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE,
tag_id: description.tag_id, language_code: description.language_code, version_no:, event_type: description ? 'update' : 'create', body:,
version_no:, event_type: creating ? 'create' : 'update', body:, created_by_user_id: user.id, created_at: revision.created_at)
source_language_code: description.source_language_code, if description
source_version_no: description.source_version_no, description.update!(body:, version_no:, updated_by_user_id: user.id)
created_by_user_id: user.id, created_at: Time.current) else
description.created_by_user_id = user.id if creating TagDescription.create!(
description.update!(body:, version_no:, updated_by_user_id: user.id) id: revision.wiki_page_id, tag_id: tag.id,
version language_code: LANGUAGE_CODE, version_no:, body:,
created_by_user_id: user.id, updated_by_user_id: user.id)
end
end end
private_class_method :record_version! private_class_method :record_version!
def self.normalise_body body
body.to_s.gsub(/\r\n?/, "\n")
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
.sub(/\n+\z/, '')
end
private_class_method :normalise_body
def self.normalise_body! description, body
normalised = normalise_body(body)
return normalised if normalised.present?
description.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, description
end
private_class_method :normalise_body!
end end
+70 -7
ファイルの表示
@@ -1,8 +1,10 @@
class TagGroupOperation class TagGroupOperation
MAX_PLACEMENT_DEPTH = 5 MAX_PLACEMENT_DEPTH = 5
PREFIX = TagGroupRepr::PREFIX
def self.create!(name:, description:, members:, placements:, actor:) def self.create!(name:, description:, members:, placements:, actor:,
ApplicationRecord.transaction do require_compatibility: false, compatibility_body: nil)
group, sync_issue = ApplicationRecord.transaction do
group = TagGroup.new(version_no: 1, created_by_user: actor, group = TagGroup.new(version_no: 1, created_by_user: actor,
updated_by_user: actor) updated_by_user: actor)
attributes = prepare!(group:, name:, description:, members:, placements:) attributes = prepare!(group:, name:, description:, members:, placements:)
@@ -11,15 +13,21 @@ class TagGroupOperation
group.save! group.save!
persist_structure!(group:, attributes:, actor:) persist_structure!(group:, attributes:, actor:)
persist_version!(group:, attributes:, actor:, event_type: 'create') persist_version!(group:, attributes:, actor:, event_type: 'create')
group issue = sync_compatibility!(group:, actor:, old_name: nil,
require_compatibility:, compatibility_body:)
[group, issue]
end end
report_sync_issue(group, actor, sync_issue)
group
end end
def self.update!(group:, name:, description:, members:, placements:, actor:) def self.update!(group:, name:, description:, members:, placements:, actor:,
ApplicationRecord.transaction do require_compatibility: false, compatibility_body: nil)
group, sync_issue = ApplicationRecord.transaction do
group.lock! group.lock!
old_name = group.name
attributes = prepare!(group:, name:, description:, members:, placements:) attributes = prepare!(group:, name:, description:, members:, placements:)
next group if unchanged?(group, attributes) next [group, nil] if unchanged?(group, attributes)
group.update!(name: attributes.fetch(:name), group.update!(name: attributes.fetch(:name),
description: attributes.fetch(:description), description: attributes.fetch(:description),
@@ -27,8 +35,12 @@ class TagGroupOperation
updated_by_user: actor) updated_by_user: actor)
persist_structure!(group:, attributes:, actor:) persist_structure!(group:, attributes:, actor:)
persist_version!(group:, attributes:, actor:, event_type: 'update') persist_version!(group:, attributes:, actor:, event_type: 'update')
group issue = sync_compatibility!(group:, actor:, old_name:,
require_compatibility:, compatibility_body:)
[group, issue]
end end
report_sync_issue(group, actor, sync_issue)
group
end end
def self.prepare!(group:, name:, description:, members:, placements:) def self.prepare!(group:, name:, description:, members:, placements:)
@@ -301,6 +313,57 @@ class TagGroupOperation
end end
private_class_method :persist_version! private_class_method :persist_version!
def self.sync_compatibility!(group:, actor:, old_name:,
require_compatibility:, compatibility_body:)
representation = TagGroupRepr.one(group)
body = begin
TagGroupMarkdownRenderer.render(representation)
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
if compatibility_body.present?
compatibility_body
elsif require_compatibility
invalid!(group, :base, error.message)
else
return error.message
end
end
invalid!(group, :base, '本文は必須です.') if body.blank?
title = "#{ PREFIX }#{ group.name }"
old_title = old_name && "#{ PREFIX }#{ old_name }"
page = if old_title
WikiPage.joins(:tag_name).find_by(tag_names: { name: old_title })
else
nil
end
page ||= WikiPage.joins(:tag_name).find_by(tag_names: { name: title })
if page
tag_name = page.tag_name
tag_name.update!(name: title) if tag_name.name != title
Wiki::Commit.legacy_content!(page:, body:, created_user: actor, message: nil,
base_revision_id: nil)
else
tag_name = TagName.find_or_initialize_by(language_code: 'ja', name: title)
tag_name.script_code = 'Jpan'
tag_name.primary_flg = true
tag_name.auto_generated = false
tag_name.save!
Wiki::Commit.create_legacy_content!(tag_name:, body:, created_by_user: actor)
end
nil
end
private_class_method :sync_compatibility!
def self.report_sync_issue(group, actor, reason)
return unless reason
payload = { status: 'unsupported_structure', tag_group_id: group.id,
version_no: group.version_no, user_id: actor&.id, reason: }
Rails.logger.warn("tag_group_wiki_sync_skipped #{ payload.to_json }")
end
private_class_method :report_sync_issue
def self.placement_references(nodes) def self.placement_references(nodes)
nodes.flat_map do |node| nodes.flat_map do |node|
own = if node.key?('tag_id') own = if node.key?('tag_id')
+160
ファイルの表示
@@ -0,0 +1,160 @@
require 'digest'
module Wiki
class Commit
class Conflict < StandardError
;
end
def self.create_content! tag_name:, body:, created_by_user:, message: nil
if tag_name.tag && tag_name.language_code == 'ja'
return TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user:, message:)
end
create_legacy_content!(tag_name:, body:, created_by_user:, message:)
end
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
normalised = normalise_body(body)
page = WikiPage.new(tag_name:,
body: normalised,
created_user: created_by_user,
updated_user: created_by_user)
if normalised.blank?
page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, page
end
ActiveRecord::Base.transaction do
page.save!
new(page:, created_user: created_by_user).content!(
body: normalised,
message:,
base_revision_id: nil)
page
end
end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
description = TagDescription.find_by(id: page.id)
if description
return TagDescriptionOperation.update!(
tag: description.tag, body:, updated_by_user: created_user,
message:, base_revision_id:)
end
legacy_content!(page:, body:, created_user:, message:, base_revision_id:)
end
def self.legacy_content! page:, body:, created_user:, message: nil,
base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:)
end
def self.redirect! page:, redirect_page:, created_user:, message: nil, base_revision_id: nil
new(page:, created_user:).redirect!(redirect_page:, message:, base_revision_id:)
end
def initialize page:, created_user:
@page = page
@created_user = created_user
end
def content! body:, message:, base_revision_id:
normalised = self.class.normalise_body(body)
if normalised.blank?
@page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, @page
end
lines = split_lines(normalised)
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
tree_sha = Digest::SHA256.hexdigest(line_shas.join(','))
ActiveRecord::Base.transaction do
@page.lock!
if base_revision_id.present?
current_id = @page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Conflict,
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
@page.update!(body: normalised)
WikiVersionRecorder.record!(
page: @page,
event_type: @page.wiki_versions.exists? ? :update : :create,
reason: message,
created_by_user: @created_user)
rev = WikiRevision.create!(
wiki_page: @page,
base_revision_id:,
created_user: @created_user,
kind: :content,
redirect_page_id: nil,
message:,
lines_count: lines.length,
tree_sha256: tree_sha)
rows = line_ids.each_with_index.map do |line_id, pos|
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end
WikiRevisionLine.insert_all!(rows) if rows.any?
rev
end
end
def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.'
def self.normalise_body body
s = body.to_s
s.gsub!(/\r\n?/, "\n")
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
s.gsub(/\n+$/, '')
end
private
def split_lines(body) = body.split("\n")
def upsert_lines! lines, line_shas
now = Time.current
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
missing_by_sha = { }
line_shas.each_with_index do |sha, i|
next if id_by_sha.key?(sha)
next if missing_by_sha.key?(sha)
missing_by_sha[sha] = {
sha256: sha,
body: lines[i],
created_at: now,
updated_at: now }
end
if missing_by_sha.any?
WikiLine.upsert_all(missing_by_sha.values)
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
end
id_by_sha
end
end
end
+33
ファイルの表示
@@ -0,0 +1,33 @@
module Wiki
class CompatibilityWriter
def self.create! tag_name:, body:, created_by_user:, message: nil
Commit.create_legacy_content!(
tag_name:, body:, created_by_user:, message:)
end
def self.update! page:, body:, updated_by_user:, message: nil,
base_revision_id: nil
normalised = Commit.normalise_body(body)
if normalised.blank?
return Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
if base_revision_id.present?
current_id = page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Commit::Conflict,
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end
return if Commit.normalise_body(page.body) == normalised
Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
end
end
+21
ファイルの表示
@@ -0,0 +1,21 @@
class WikiVersionRecorder < VersionRecorder
def self.record! page:, event_type:, reason: nil, created_by_user:
new(page:, event_type:, reason:, created_by_user:).record!
end
def initialize page:, event_type:, reason: nil, created_by_user:
@reason = reason
super(record: page, event_type:, created_by_user:)
end
private
def version_class = WikiVersion
def version_association = :wiki_versions
def record_key = :wiki_page
def snapshot_attributes = {
title: @record.title,
body: @record.body,
reason: @reason }
end
-9
ファイルの表示
@@ -1,9 +0,0 @@
class DropWikiPages < ActiveRecord::Migration[8.0]
def change
drop_table :wiki_versions
drop_table :wiki_revision_lines
drop_table :wiki_lines
drop_table :wiki_revisions
drop_table :wiki_pages
end
end
-11
ファイルの表示
@@ -1,11 +0,0 @@
class RemoveTagNameIdFromTags < ActiveRecord::Migration[8.0]
def up
remove_foreign_key :tags, column: :tag_name_id
remove_foreign_key :tag_names, column: :canonical_id
remove_column :tags, :tag_name_id
remove_column :tag_names, :canonical_id
end
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
-15
ファイルの表示
@@ -1,15 +0,0 @@
class DeleteNameForWikiFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
tag_id IS NULL
SQL
change_column_null :tag_names, :tag_id, false
end
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
生成ファイル
+89 -2
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -578,15 +578,17 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
end end
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false t.bigint "tag_id"
t.string "language_code", limit: 16, null: false t.string "language_code", limit: 16, null: false
t.string "name", null: false t.string "name", null: false
t.string "script_code", limit: 4, null: false t.string "script_code", limit: 4, null: false
t.boolean "primary_flg", null: false t.boolean "primary_flg", null: false
t.boolean "auto_generated", default: false, null: false t.boolean "auto_generated", default: false, null: false
t.bigint "canonical_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)" t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)"
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true
t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true
t.index ["script_code"], name: "fk_rails_dd783b3d1c" t.index ["script_code"], name: "fk_rails_dd783b3d1c"
@@ -623,6 +625,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
end end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false t.string "category", default: "general", null: false
t.datetime "deprecated_at" t.datetime "deprecated_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -630,6 +633,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end end
@@ -769,6 +773,76 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.index ["banned_at"], name: "index_users_on_banned_at" t.index ["banned_at"], name: "index_users_on_banned_at"
end end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["sha256"], name: "index_wiki_lines_on_sha256", unique: true
end
create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.text "body", null: false
t.bigint "created_user_id", null: false
t.bigint "updated_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_revision_id", null: false
t.integer "position", null: false
t.bigint "wiki_line_id", null: false
t.index ["wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_line_id"
t.index ["wiki_revision_id", "position"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_position", unique: true
t.index ["wiki_revision_id", "wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_wiki_line_id"
t.index ["wiki_revision_id"], name: "index_wiki_revision_lines_on_wiki_revision_id"
end
create_table "wiki_revisions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.bigint "base_revision_id"
t.bigint "created_user_id", null: false
t.integer "kind", default: 0, null: false
t.bigint "redirect_page_id"
t.string "message"
t.integer "lines_count", default: 0, null: false
t.string "tree_sha256", limit: 64
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["base_revision_id"], name: "index_wiki_revisions_on_base_revision_id"
t.index ["created_user_id"], name: "index_wiki_revisions_on_created_user_id"
t.index ["kind"], name: "index_wiki_revisions_on_kind"
t.index ["redirect_page_id"], name: "index_wiki_revisions_on_redirect_page_id"
t.index ["tree_sha256"], name: "index_wiki_revisions_on_tree_sha256"
t.index ["wiki_page_id", "id"], name: "index_wiki_revisions_on_wiki_page_id_and_id"
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
end
create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "title", null: false
t.text "body", null: false
t.text "reason"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions" add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
@@ -841,10 +915,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "tag_implications", "tags", column: "parent_tag_id" add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code" add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code" add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code"
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_names", "tags" add_foreign_key "tag_names", "tags"
add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id" add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "users", column: "created_by_user_id" add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts" add_foreign_key "theatre_programmes", "posts"
@@ -869,4 +945,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users" add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users" add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
add_foreign_key "wiki_revision_lines", "wiki_lines"
add_foreign_key "wiki_revision_lines", "wiki_revisions"
add_foreign_key "wiki_revisions", "users", column: "created_user_id"
add_foreign_key "wiki_revisions", "wiki_pages"
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
add_foreign_key "wiki_versions", "users", column: "created_by_user_id"
add_foreign_key "wiki_versions", "wiki_pages"
end end
-51
ファイルの表示
@@ -1,51 +0,0 @@
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_legacy_content!( page = Wiki::Commit.create_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.legacy_content!( revisions << Wiki::Commit.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
ファイルの表示
@@ -1,84 +0,0 @@
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
ファイルの表示
@@ -1,50 +0,0 @@
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,47 +1,6 @@
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,7 +178,6 @@ 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
@@ -206,56 +205,6 @@ 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
+6 -7
ファイルの表示
@@ -1,17 +1,16 @@
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
seed_wiki_page( Wiki::Commit.create_content!(
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.legacy_content!(page:, body: 'old-second', created_user: user) second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user)
third = Wiki::Commit.legacy_content!(page:, body: 'old-third', created_user: user) third = Wiki::Commit.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) }
@@ -243,8 +242,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
end end
describe 'mixed changes' do describe 'mixed changes' do
it 'excludes unmigrated history and migrated legacy-only revisions' do it 'includes unmigrated history and ignores migrated legacy-only revisions' do
legacy_page = seed_wiki_page( legacy_page = Wiki::Commit.create_content!(
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
@@ -257,7 +256,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).not_to include(include( expect(json).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
+55 -62
ファイルの表示
@@ -1,7 +1,6 @@
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) }
@@ -32,7 +31,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
headers: headers headers: headers
end end
it 'creates a description with matching Wiki content and histories' do it 'creates both current records and histories with a shared revision ID' 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)
@@ -40,28 +39,17 @@ 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)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
expect(description).to have_attributes(body: 'initial', version_no: 1)
page = WikiPage.find_by!(tag_name:)
expect(page.body).to eq(description.body)
expect(page.current_revision.body).to eq(description.body)
expect(page.wiki_versions.sole.body).to eq(description.body)
expect(json.fetch('id')).to eq(description.id)
expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja'))
.to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id)
end
it 'reuses an existing compatibility page and synchronises its histories' do page = WikiPage.find(json.fetch('id'))
page = Wiki::Commit.create_legacy_content!( description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
tag_name:, body: 'legacy', created_by_user: user) version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')
expect { create_description } expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1)
.to change(WikiPage, :count).by(0) expect(version).to have_attributes(
.and change(WikiRevision, :count).by(1) id: page.current_revision.id, body: 'initial', version_no: 1,
.and change(WikiVersion, :count).by(1) event_type: 'create', created_by_user_id: user.id)
expect(response).to have_http_status(:created) expect(page.body).to eq(description.body)
description = TagDescription.find_by!(tag_id: tag.id) expect(page.current_revision.body).to eq(version.body)
expect(page.reload.body).to eq(description.body) expect(page.wiki_versions.last.body).to eq(version.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|
@@ -77,19 +65,48 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
end end
end end
[false, true].each do |linked| it 'creates a namespaced legacy Wiki resource without a Tag' do
it "rejects namespaced creation with Tag present: #{ linked }" do expect do
name = create(:tag_name, name: 'Help:frozen') post '/wiki', params: { title: 'Help:description_write', body: 'legacy' },
create(:tag, primary_tag_name: name) if linked headers: headers
before_state = stored_state end.to change(WikiPage, :count).by(1)
post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers .and change(WikiRevision, :count).by(1)
expect(response.status).to be_between(400, 499) .and change(WikiVersion, :count).by(1)
expect(stored_state).to eq(before_state) .and change(TagDescription, :count).by(0)
end .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
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 new description creation when #{ model.name } persistence fails" do it "rolls back both systems 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 }
@@ -99,32 +116,10 @@ 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
seed_wiki_page( Wiki::Commit.create_content!(
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 }
@@ -143,8 +138,7 @@ 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(WikiPage, :count).by(0) .to change(WikiRevision, :count).by(1)
.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)
@@ -163,8 +157,7 @@ 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(WikiPage, :count).by(0) .to change(WikiRevision, :count).by(1)
.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)
@@ -184,10 +177,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: 'description_renamed') update_description(body: 'before', title: 'Help:description_renamed')
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(tag_name.reload.name).to eq('description_renamed') expect(tag_name.reload.name).to eq('Help: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)
+2 -11
ファイルの表示
@@ -206,13 +206,7 @@ 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 a description with synchronised Wiki content and histories' do it 'creates both current resources and histories with matching compatibility IDs' 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)
@@ -221,7 +215,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_by!(tag_name: tag.tag_name('ja')) page = WikiPage.find(description.id)
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(
@@ -233,9 +227,6 @@ 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 }"
+22 -27
ファイルの表示
@@ -1,7 +1,6 @@
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! }
@@ -70,36 +69,34 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end end
end end
it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do
help = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
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 'excludes Help when its ID collides with a new group' do it 'keeps Help readable when its ID collides with a new group' do
help = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
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('タググループ:別 ID重複') expect(json.map { _1.fetch('title') }).to include(help.title, 'タググループ:別 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(:not_found) expect(response).to have_http_status(:ok)
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(
'タググループ:別 ID重複') help.title, 'タググループ:別 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
@@ -125,7 +122,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 = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
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)
@@ -139,8 +136,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).not_to include(help.title) expect(titles.index('新タグ')).to be < titles.index(help.title)
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約') expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約')
end end
it 'renders nullable group editors without raising' do it 'renders nullable group editors without raising' do
@@ -169,16 +166,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 group history but excludes all legacy-only revisions' do it 'includes Help and group history but excludes legacy-only migrated 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 = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
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).not_to include(include( expect(json).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
@@ -217,7 +214,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
seed_wiki_page( Wiki::Commit.create_legacy_content!(
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
@@ -242,12 +239,13 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
colliding_description_version colliding_description_version
end end
it 'lists both new resources sharing a numeric ID without legacy Help' do it 'lists all three resources without deduplicating their numeric ID' 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
@@ -261,6 +259,7 @@ 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
@@ -286,8 +285,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end end
end end
it 'resolves new resources by title while excluding colliding legacy Help' do it 'resolves colliding group, Help and description IDs by title' do
help = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
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!(
@@ -295,15 +294,11 @@ 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', '新タグ' => 'description' } { 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => '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
+6 -9
ファイルの表示
@@ -33,11 +33,8 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
end end
end end
it 'creates the group, snapshot and matching compatibility histories' do it 'creates the group, first snapshot and compatibility Wiki history' do
expect { create_group } 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' => 'タググループ:新規')
@@ -46,9 +43,10 @@ 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).to eq('- 洗澡鹿') expect(page.body.strip).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
@@ -295,9 +293,8 @@ 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
tables = TagGroupRecords::GROUP_TABLES (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] .each do |table|
tables.each do |table|
it "rolls back all writes after a real #{ table } write fails" do it "rolls back all writes after a real #{ table } write fails" do
expect_atomic_group_write(table) { write_group(operation, headers) } expect_atomic_group_write(table) { write_group(operation, headers) }
end end
+19 -21
ファイルの表示
@@ -1,7 +1,6 @@
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
@@ -25,7 +24,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
end end
def create_help body def create_help body
seed_wiki_page( Wiki::Commit.create_legacy_content!(
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
@@ -71,16 +70,17 @@ RSpec.describe 'Tag group write integrity', type: :request do
expect(page.body).to eq('- 伊地知ニジカ') expect(page.body).to eq('- 伊地知ニジカ')
end end
it 'rejects a standalone Help update despite a colliding group ID' do it 'updates Help with a colliding group ID without altering the group' 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)
expect_no_writes { put_wiki(id, title: 'Help:改名', body: 'Help after') } put_wiki(id, title: 'Help:改名', body: 'Help after')
expect(page.reload.body).to eq('Help body') expect(response).to have_http_status(:ok)
expect(page.title).to eq('Help:既存') expect(page.reload.body).to eq('Help after')
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,8 +98,7 @@ 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)
description = TagDescription.find_by!(tag_id: tag.id) id = seed_group(name: '更新', id: page.id, members:, placements:)
id = seed_group(name: '更新', id: description.id, members:, placements:)
group_before = TagGroup.find(id).attributes group_before = TagGroup.find(id).attributes
put_wiki(id, title: '洗澡鹿', body: 'description after') put_wiki(id, title: '洗澡鹿', body: 'description after')
@@ -116,19 +115,18 @@ 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')
expect_no_writes { put_wiki(id, title: 'Help:既存', body: 'Help after') } put_wiki(id, title: 'Help:既存', body: 'Help after')
expect(page.reload.body).to eq('Help before') expect(response).to have_http_status(:ok)
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)
put_wiki(id, title: '洗澡鹿', body: 'description after') expect_no_writes { 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 before') expect(page.reload.body).to eq('Help after')
expect(description.reload.body).to eq('description after') expect(description.reload.body).to eq('description')
end end
end end
@@ -138,14 +136,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)
expect(TagGroup.find(id).name).to eq('改名') page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' })
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
+12 -22
ファイルの表示
@@ -391,17 +391,6 @@ 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)
@@ -429,18 +418,19 @@ RSpec.describe 'Tag group native API', type: :request do
end end
end end
it 'assigns the next group ID from the existing maximum' do it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do
child_id inserts = []
expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1 subscriber = lambda do |*args|
native_write(:create) sql = args.last.fetch(:sql)
expect(response).to have_http_status(:created) inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i)
expect(json.fetch('id')).to eq(expected_id)
end
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
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
native_write(:create)
end
expect(response).to have_http_status(:created)
expect(inserts.size).to eq(1)
columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') }
expect(columns).not_to include('id')
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
ファイルの表示
@@ -1,101 +0,0 @@
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
+1 -2
ファイルの表示
@@ -1,7 +1,6 @@
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
@@ -14,7 +13,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
seed_wiki_page( Wiki::Commit.create_content!(
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,22 +38,6 @@ 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)
+8 -7
ファイルの表示
@@ -1,7 +1,6 @@
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
@@ -10,8 +9,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 =
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'), tag_name: create(:tag_name, name: 'wiki_conflict_request'),
body: 'first', body: 'first',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
@@ -26,10 +25,12 @@ 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: { title: 'wiki_conflict_request', params: {
body: 'third', title: 'wiki_conflict_request',
message: 'stale', body: 'third',
base_revision_id: stale_id }, message: 'stale',
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
ファイルの表示
@@ -1,63 +0,0 @@
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
+46 -18
ファイルの表示
@@ -2,7 +2,6 @@ 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
@@ -10,22 +9,49 @@ 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
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: create(:tag, primary_name: title).tag_name('ja'), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
message:) message:)
end end
describe 'POST /wiki' do describe 'POST /wiki' do
it 'rejects frozen creation without partial page or history' do it 'creates wiki_page, wiki_revision, and wiki_version atomically' do
expect do expect {
post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' }, post '/wiki',
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) }
.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 = 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
@@ -45,19 +71,21 @@ 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 'rejects frozen creation without modifying an existing page or history' do it 'returns 422 and creates no partial page when title already exists' do
create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first') create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first')
expect do expect {
post '/wiki', post '/wiki',
params: { title: 'Help:wiki_history_duplicate_title', params: {
body: 'second', message: 'duplicate' }, title: 'Help:wiki_history_duplicate_title',
body: 'second',
message: 'duplicate',
},
headers: auth_headers(user) headers: auth_headers(user)
end.to change(WikiPage, :count).by(0) }
.and change(WikiRevision, :count).by(0) .not_to change(WikiPage, :count)
.and change(WikiVersion, :count).by(0)
expect(response.status).to be_between(400, 499) expect(response).to have_http_status(:unprocessable_entity)
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
ファイルの表示
@@ -1,42 +0,0 @@
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
+97 -20
ファイルの表示
@@ -4,7 +4,6 @@ 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
@@ -19,7 +18,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
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: tn, tag_name: tn,
body: 'init', body: 'init',
created_by_user: user, created_by_user: user,
@@ -109,14 +108,94 @@ RSpec.describe 'Wiki API', type: :request do
end end
end end
['Help:TestPage', 'ヘルプ:ホーム'].each do |title| context 'when success' do
it "rejects frozen creation of #{ title } without history writes" do it 'creates wiki_page and first content revision' do
expect do expect do
post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member) post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' },
end.to change(WikiPage, :count).by(0) headers: auth_headers(member)
.and change(WikiRevision, :count).by(0) end
.and change(WikiVersion, :count).by(0) .to change(WikiPage, :count).by(1)
expect(response.status).to be_between(400, 499) .and change(WikiRevision, :count).by(1)
.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
@@ -129,10 +208,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, primary_name: 'TestPage').tag_name('ja') } let!(:test_tag_name) { create(:tag_name, name: 'TestPage') }
let!(:page) do let!(:page) do
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: test_tag_name, tag_name: test_tag_name,
body: "a\nb", body: "a\nb",
created_by_user: member, created_by_user: member,
@@ -170,8 +249,7 @@ 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', params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
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)
@@ -206,7 +284,7 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: member) created_by_user: member)
linked_page = linked_page =
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: linked_tag_name, tag_name: linked_tag_name,
body: 'before', body: 'before',
created_by_user: member, created_by_user: member,
@@ -280,14 +358,13 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do describe 'GET /wiki/search' do
before do before do
search_tag = create(:tag, primary_name: 'spec_wiki_title_2') Wiki::Commit.create_content!(
seed_wiki_page( tag_name: create(:tag_name, name: 'spec_wiki_title_2'),
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')
seed_wiki_page( Wiki::Commit.create_content!(
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,
@@ -439,7 +516,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 =
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: target_tag_name, tag_name: target_tag_name,
body: 'target', body: 'target',
created_by_user: user, created_by_user: user,
@@ -462,7 +539,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 =
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: linked_tag_name, tag_name: linked_tag_name,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+11 -10
ファイルの表示
@@ -1,7 +1,6 @@
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
@@ -9,7 +8,7 @@ RSpec.describe 'Wiki title collision', type: :request do
end end
def create_wiki_page title:, body: def create_wiki_page title:, body:
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
@@ -44,18 +43,20 @@ 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 'rejects frozen creation with an existing title without adding history' do it 'returns 422 when creating wiki with existing title' do
create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists') create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists')
expect do expect {
post '/wiki', post '/wiki',
params: { title: 'Help:wiki_collision_create', body: 'new body', params: {
message: 'duplicate create' }, title: 'Help:wiki_collision_create',
body: 'new body',
message: 'duplicate create',
},
headers: auth_headers(user) headers: auth_headers(user)
end.to change(WikiPage, :count).by(0) }
.and change(WikiRevision, :count).by(0) .not_to change(WikiPage, :count)
.and change(WikiVersion, :count).by(0)
expect(response.status).to be_between(400, 499) expect(response).to have_http_status(:unprocessable_entity)
end end
end end
+85 -48
ファイルの表示
@@ -1,78 +1,115 @@
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
seed_wiki_page(tag_name:, body: 'before', created_by_user: user) Wiki::Commit.create_content!(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.legacy_content!(page:, body: 'after', created_user: user, Wiki::Commit.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 'rejects standalone updates even after a Tag is linked to the legacy page' do it 'keeps a Tag-linked legacy page in the legacy domain when its title changes' 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(0) end.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
.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(:unprocessable_entity)
expect(page.reload.attributes).to eq(original)
expect(page.current_revision).to have_attributes(message: 'legacy edit')
end
include TagGroupRecords
[:id, :title].each do |lookup|
[nil, :historical].each do |edition|
it "rejects legacy #{ lookup } #{ edition || 'current' } without legacy SELECTs" do
path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
params = edition ? { version: first_revision.id } : { }
without_group_legacy_reads { get path, params: }
expect(response).to have_http_status(:not_found)
end
end
it "does not recognise legacy #{ lookup } existence" do
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(json).to eq([])
end
end
it 'returns empty scoped history without legacy SELECTs' do
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to eq([]) expect(page.reload).to have_attributes(body: 'changed legacy body')
expect(page.title).to eq('legacy_tag_renamed')
expect(page.current_revision).to have_attributes(message: 'legacy rename')
end end
[false, true].each do |explicit| it 'shows legacy current and historical content by ID' do
it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do get "/wiki/#{ page.id }"
params = { from: first_revision.id } expect(response).to have_http_status(:ok)
params[:to] = last_revision.id if explicit expect(json).to include('body' => 'after', 'revision_id' => last_revision.id,
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: } '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
get "/wiki/title/#{ tag_name.name }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id)
get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id }
expect(response).to have_http_status(:ok)
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) expect(response).to have_http_status(:not_found)
end 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)
end
end
it 'retains legacy history metadata and ordering' do
get '/wiki/changes', params: { id: page.id }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('revision_id') }).to eq(
[last_revision.id, first_revision.id])
expect(json.first).to include(
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3),
'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil },
'user' => { 'id' => user.id, 'name' => user.name })
end end
end end
-143
ファイルの表示
@@ -1,143 +0,0 @@
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
ファイルの表示
@@ -1,88 +0,0 @@
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
+22 -34
ファイルの表示
@@ -2,42 +2,41 @@ 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'
seed_wiki_page( described_class.create_content!(
tag_name: create(:tag, primary_name: title).tag_name('ja'), tag_name: create(:tag_name, name: title),
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 'rejects standalone Help creation without adding page or history' do it 'creates page, revision, and version with normalised body' do
name = create(:tag_name, name: 'Help:frozen_service') expect {
counts = [WikiPage.count, WikiRevision.count, WikiVersion.count] described_class.create_content!(
expect do tag_name: create(:tag_name, name: 'commit_integrity_create'),
described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user) body: "a\r\nb\r\n\r\n",
end.to raise_error(ActiveRecord::RecordInvalid) created_by_user: user,
expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts) message: 'init')
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')
expect(page.wiki_versions.sole.body).to eq('initial') page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' })
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, primary_name: 'commit_integrity_blank').tag_name('ja') tag_name = create(:tag_name, name: 'commit_integrity_blank')
expect { expect {
described_class.create_content!( described_class.create_content!(
@@ -53,17 +52,6 @@ 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
@@ -92,7 +80,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 =
seed_wiki_page( described_class.create_content!(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+4 -5
ファイルの表示
@@ -1,13 +1,12 @@
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, primary_name: title).tag_name('ja') tag_name = create(:tag_name, name: title)
seed_wiki_page( Wiki::Commit.create_content!(
tag_name:, tag_name:,
body:, body:,
created_by_user: user, created_by_user: user,
@@ -85,7 +84,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 =
seed_wiki_page( described_class.create_content!(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
@@ -112,7 +111,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 =
seed_wiki_page( described_class.create_content!(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+1 -2
ファイルの表示
@@ -1,11 +1,10 @@
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'
seed_wiki_page( Wiki::Commit.create_content!(
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
ファイルの表示
@@ -1,20 +0,0 @@
# 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,5 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/help
生成ファイル
+43 -4
ファイルの表示
@@ -38,7 +38,6 @@
"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": {
@@ -2061,6 +2060,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2075,6 +2077,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2089,6 +2094,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2103,6 +2111,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2117,6 +2128,9 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2131,6 +2145,9 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2145,6 +2162,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2159,6 +2179,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2173,6 +2196,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2187,6 +2213,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2201,6 +2230,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2215,6 +2247,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2229,6 +2264,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -9076,9 +9114,10 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.9.1", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
-1
ファイルの表示
@@ -42,7 +42,6 @@
"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
ファイルの表示
@@ -1,112 +0,0 @@
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
ファイルの表示
@@ -1,192 +0,0 @@
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' })
})
} }
}
+80 -56
ファイルの表示
@@ -15,6 +15,7 @@ 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,
@@ -24,6 +25,7 @@ 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'
@@ -32,15 +34,12 @@ 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'
@@ -61,62 +60,90 @@ 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'
const RouteTransitionWrapper = ({ animationMode, user, setUser }: { const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode animationMode: ClientAnimationMode
user: User | null user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => { setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation () const location = useLocation ()
const routes = (
<Routes location={location}>
<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="/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="/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>)
if (animationMode === 'off') if (animationMode === 'off')
return routes {
return (
<Routes location={location}>
<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>)
}
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{routes} <Routes location={location}>
<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>)
} }
@@ -139,9 +166,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],
) )
@@ -258,12 +285,10 @@ 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/>
@@ -271,5 +296,4 @@ const App: FC = () => (
<RouterProvider router={router}/> <RouterProvider router={router}/>
</>) </>)
export default App export default App
+28 -113
ファイルの表示
@@ -20,7 +20,6 @@ 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 = (
@@ -41,9 +40,6 @@ 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' },
@@ -75,7 +71,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', subMenu: [ { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/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' },
@@ -86,13 +82,6 @@ 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' }] },
@@ -115,8 +104,6 @@ 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
@@ -141,8 +128,6 @@ 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 ())
@@ -209,81 +194,15 @@ const TopNav: FC<Props> = ({ user }) => {
const dir = dirRef.current const dir = dirRef.current
useLayoutEffect (() => { useLayoutEffect (() => {
const layout = layoutRef.current const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
const bar = barRef.current const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
const primary = bar?.querySelector<HTMLElement> ('.top-nav-primary')
if (layout == null || bar == null || primary == null)
return
const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
let raf = 0
let active = true
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)
addEventListener ('resize', onResize)
return () => { return () => {
active = false
cancelAnimationFrame (raf) cancelAnimationFrame (raf)
observer?.disconnect () removeEventListener ('resize', onResize)
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)
@@ -296,22 +215,19 @@ 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" <>
data-desktop={desktop} data-density={density}> <nav className="top-nav-root px-3 flex justify-between items-center w-full">
<nav ref={barRef} <div className="flex items-center gap-2 h-12">
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 shrink-0 whitespace-nowrap font-bold" className="top-nav-brand-link mx-4 text-xl font-bold"
onClick={() => { onClick={() => {
scroll (0, 0) scroll (0, 0)
}}> }}>
ぼざクリ タグ広場 ぼざクリ タグ広場
</PrefetchLink> </PrefetchLink>
<div ref={navRef} <div ref={navRef} className="relative hidden md:flex h-12 items-center">
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',
@@ -323,7 +239,6 @@ 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 }` })}
@@ -337,7 +252,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', className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}> (i === openItemIdx) && 'font-bold')}>
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
@@ -352,8 +267,7 @@ 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', className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
'shrink-0 whitespace-nowrap',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}> (openItemIdx < 0 || moreVsbl) && 'font-bold')}>
その他 &raquo; その他 &raquo;
</PrefetchLink> </PrefetchLink>
@@ -364,8 +278,7 @@ const TopNav: FC<Props> = ({ user }) => {
<button <button
type="button" type="button"
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4"
ml-auto border-0 bg-transparent pr-4"
onClick={() => { onClick={() => {
setMenuOpen (!(menuOpen)) setMenuOpen (!(menuOpen))
}}> }}>
@@ -378,8 +291,7 @@ const TopNav: FC<Props> = ({ user }) => {
key="submenu-shell" key="submenu-shell"
{...(animationsOff ? { } {...(animationsOff ? { }
: { layout: true })} : { layout: true })}
className="top-nav-desktop-submenu relative z-20 shrink-0 className="relative z-20 hidden md:block overflow-hidden top-nav-submenu"
overflow-hidden top-nav-submenu"
animate={{ height: submenuHeight }} animate={{ height: submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
@@ -393,8 +305,7 @@ 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
@@ -404,7 +315,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 shrink-0 items-center px-3 font-bold w-28"> className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2> <h2>{item.name}</h2>
</motion.div> </motion.div>
{item.subMenu {item.subMenu
@@ -452,8 +363,7 @@ 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) => (
@@ -482,8 +392,7 @@ 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"
@@ -523,7 +432,7 @@ const TopNav: FC<Props> = ({ user }) => {
? ( ? (
menuOpen && ( menuOpen && (
<div <div
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col', className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'items-start')}> 'items-start')}>
<Separator/> <Separator/>
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
@@ -567,6 +476,9 @@ 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'))}>
@@ -580,7 +492,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', className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'items-start')} 'items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 }, height: 0 },
@@ -646,6 +558,9 @@ 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'))}>
@@ -655,7 +570,7 @@ const TopNav: FC<Props> = ({ user }) => {
<Separator/> <Separator/>
</motion.div>)} </motion.div>)}
</AnimatePresence>)} </AnimatePresence>)}
</div>) </>)
} }
export default TopNav export default TopNav
+3 -3
ファイルの表示
@@ -15,8 +15,8 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
return return
const className = cn ((sp const className = cn ((sp
? 'top-nav-user-mobile w-full min-h-[40px] flex items-center pl-8' ? 'md:hidden w-full min-h-[40px] flex items-center pl-8'
: 'top-nav-user-desktop shrink-0 whitespace-nowrap ml-auto'), : 'hidden md:block ml-auto pr-4'),
'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')
@@ -30,4 +30,4 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
</>) </>)
} }
export default TopNavUser export default TopNavUser
+1 -233
ファイルの表示
@@ -197,244 +197,12 @@ 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-layout .top-nav-brand-link .top-nav-root
{
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
ファイルの表示
@@ -1,130 +0,0 @@
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
ファイルの表示
@@ -1,237 +0,0 @@
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
ファイルの表示
@@ -1,126 +0,0 @@
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'))
})
})
+1 -70
ファイルの表示
@@ -1,13 +1,8 @@
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 { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { 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,
@@ -20,69 +15,12 @@ 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') ?? ''
@@ -285,13 +223,6 @@ 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,14 +61,3 @@ 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 }
+2 -16
ファイルの表示
@@ -72,25 +72,11 @@ describe ('tags API functions', () => {
) )
}) })
it ('returns null for a missing named tag while retaining the ID helper contract', async () => { it ('returns null when tag fetches fail', 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 ('名前/#%') }`)
})
}) })
+3 -7
ファイルの表示
@@ -1,5 +1,3 @@
import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { Deerjikist, import type { Deerjikist,
@@ -70,13 +68,11 @@ 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/${ encodeURIComponent (name) }`) return await apiGet (`/tags/name/${ name }`)
} }
catch (error) catch
{ {
if (isAxiosError (error) && error.response?.status === 404) return null
return null
throw error
} }
} }
+17 -24
ファイルの表示
@@ -2,7 +2,9 @@ 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 (() => ({ apiGet: vi.fn () })) const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
@@ -19,37 +21,28 @@ describe ('wiki API functions', () => {
await fetchWikiPage ('1', { version: '3' }) await fetchWikiPage ('1', { version: '3' })
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 ({ isAxiosError: true, response: { status: 404 } }) api.apiGet.mockRejectedValueOnce (new Error ('missing'))
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)
}) })
}) })
+2 -6
ファイルの表示
@@ -1,5 +1,3 @@
import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { WikiPage } from '@/types' import type { WikiPage } from '@/types'
@@ -23,10 +21,8 @@ export const fetchWikiPageByTitle = async (
{ {
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } }) return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
} }
catch (error) catch
{ {
if (isAxiosError (error) && error.response?.status === 404) return null
return null
throw error
} }
} }
-77
ファイルの表示
@@ -1,77 +0,0 @@
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)
})
})
+1 -49
ファイルの表示
@@ -1,52 +1,4 @@
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'
const MDXLink = ( export const useMDXComponents = (): MDXComponents => ({ })
{ 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
ファイルの表示
@@ -1,55 +0,0 @@
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
ファイルの表示
@@ -1,119 +0,0 @@
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
ファイルの表示
@@ -1,128 +0,0 @@
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
ファイルの表示
@@ -1,106 +0,0 @@
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
ファイルの表示
@@ -1,81 +0,0 @@
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
ファイルの表示
@@ -1,130 +0,0 @@
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
ファイルの表示
@@ -1,212 +0,0 @@
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
+4 -141
ファイルの表示
@@ -1,19 +1,14 @@
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { screen } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { 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 WikiNewPage from '@/pages/wiki/WikiNewPage' import { buildTag, buildWikiPage } from '@/test/factories'
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 (() => ({
@@ -27,140 +22,8 @@ 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: '旧タグ',
+7 -44
ファイルの表示
@@ -1,3 +1,5 @@
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'
@@ -11,14 +13,10 @@ 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 { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { 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'
@@ -35,33 +33,15 @@ 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, isSuccess, isFetching, error } = useQuery ({ const { data: wikiPage, isLoading: loading } = 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, isPending: tagPending, const { data: tag } = useQuery ({
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) })
@@ -93,36 +73,19 @@ 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, { })
if (active) navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
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>
@@ -156,7 +119,7 @@ const WikiDetailPage: FC = () => {
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{deprecated && <span>(廃止)</span>} {deprecated && <span>(廃止)</span>}
</h1> </h1>
{waiting ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} {loading ? <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
ファイルの表示
@@ -1,144 +0,0 @@
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 ()
})
})
+4 -11
ファイルの表示
@@ -1,10 +1,10 @@
import { useQueryClient } from '@tanstack/react-query' import type { FC } from 'react'
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,13 +12,12 @@ 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 type { FC } from 'react' import 'react-markdown-editor-lite/lib/index.css'
import type { User, WikiPage } from '@/types' import type { User, WikiPage } from '@/types'
@@ -39,7 +38,6 @@ 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') ?? ''
@@ -60,13 +58,8 @@ 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/${ encodeURIComponent (data.title) }`) navigate (`/wiki/${ data.title }`)
} }
catch (e) catch (e)
{ {
+37 -33
ファイルの表示
@@ -1,58 +1,62 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { 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'
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () })) const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('Wiki resource identity', () => { describe ('WikiSearchPage', () => {
beforeEach (() => vi.mocked (apiGet).mockReset ())
it ('loads initial wiki pages and renders links', async () => { it ('loads initial wiki pages and renders links', async () => {
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })]) api.apiGet.mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
renderWithProviders (<WikiSearchPage/>) renderWithProviders (<WikiSearchPage/>)
expect (await screen.findByRole ('link', { name: '虹夏' }))
.toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F') expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute (
expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } }) 'href',
'/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 () => {
vi.mocked (apiGet).mockResolvedValueOnce ([]) api.apiGet
.mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })]) .mockResolvedValueOnce ([])
.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 (
'/wiki', { params: { title: '検索' } })) await waitFor (() => {
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 () => {
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ api.apiGet.mockResolvedValueOnce ([
title: '旧タグ', deprecatedAt: '2026-06-01T00:00:00.000Z' })]) buildWikiPage ({
renderWithProviders (<WikiSearchPage/>) title: '旧タグ',
expect (await screen.findByRole ('link', { name: '旧タグ' })).toBeInTheDocument () deprecatedAt: '2026-06-01T00:00:00.000Z',
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/>)
await screen.findByRole ('link', { name: '説明' }) const link = await screen.findByRole ('link', { name: '旧タグ' })
expect (screen.getByRole ('link', { name: 'タググループ:一覧' })) const marker = screen.getByText ('(廃止)')
.toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`)
await waitFor (() => { expect (link).toBeInTheDocument ()
expect (errors.mock.calls.some (args => args.some (value => expect (marker.closest ('a')).toBeNull ()
String (value).includes ('same key')))).toBe (false)
})
}) })
}) })
+1 -2
ファイルの表示
@@ -81,8 +81,7 @@ const WikiSearchPage: FC = () => {
</thead> </thead>
<tbody> <tbody>
{results.map (page => ( {results.map (page => (
<tr key={`${ page.resourceType }:${ page.id }`} <tr key={page.id} className="even:bg-gray-100 dark:even:bg-gray-700">
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
ファイルの表示
@@ -1,37 +0,0 @@
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,7 +59,6 @@ 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
ファイルの表示
@@ -1,126 +0,0 @@
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,7 +452,6 @@ 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,29 +1 @@
/// <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 }>>
}
+2 -18
ファイルの表示
@@ -1,28 +1,12 @@
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: [helpAssets (), mdxPlugin, react ()], plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()],
resolve: { alias: { '@': path.resolve (__dirname, './src') } }, resolve: { alias: { '@': path.resolve (__dirname, './src') } },
server: { host: true, server: { host: true,
port: 5173, port: 5173,