コミットを比較
16
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
398f4f6b82 | ||
|
|
5246ac5c61 | ||
|
|
7cf2978f5f | ||
|
|
11e6f73287 | ||
|
|
2e98ed02e3 | ||
|
|
7c25dedac2 | ||
|
|
1a4cbe36fb | ||
|
|
4813a3a09e | ||
|
|
e117e20346 | ||
|
|
4164c3c3f7 | ||
|
|
4c891604b5 | ||
|
|
88e691165a | ||
|
|
18415d7f55 | ||
|
|
534a66f90d | ||
|
|
a219cc350b | ||
|
|
592932b5a5 |
@@ -70,7 +70,10 @@ class MaterialsController < ApplicationController
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless material
|
||||
|
||||
wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.body
|
||||
wiki_page_body =
|
||||
TagDescription
|
||||
.find_by(tag_id: material.tag_id, language_code: locale.language_code)
|
||||
&.body
|
||||
|
||||
render json: MaterialRepr.base(material, host: request.base_url,
|
||||
language_code: locale.language_code).merge(wiki_page_body:)
|
||||
|
||||
@@ -26,7 +26,7 @@ class NicoTagsController < ApplicationController
|
||||
ExternalTag
|
||||
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
|
||||
'ON post_tag_max.external_tag_id = external_tags.id')
|
||||
.includes(linked_tags: { tag_names: :wiki_page })
|
||||
.includes(linked_tags: [:tag_names, :tag_descriptions])
|
||||
if name
|
||||
q = q.where(('external_tags.name LIKE ? ' +
|
||||
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
|
||||
|
||||
@@ -53,7 +53,7 @@ class PostsController < ApplicationController
|
||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
||||
.preload(:external_tags, :uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
|
||||
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
||||
@@ -111,7 +111,7 @@ class PostsController < ApplicationController
|
||||
filtered_posts(locale)
|
||||
.preload(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
.order('RAND()')
|
||||
.first
|
||||
@@ -198,7 +198,7 @@ class PostsController < ApplicationController
|
||||
Post
|
||||
.includes(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless post
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
class TagGroupsController < ApplicationController
|
||||
def index
|
||||
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
|
||||
render json: TagGroupRepr.many(groups.to_a)
|
||||
end
|
||||
|
||||
def show
|
||||
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
render json: TagGroupRepr.one(group)
|
||||
end
|
||||
|
||||
def create
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
group = TagGroupOperation.create!(**write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(group), status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
group = TagGroup.find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
TagGroupOperation.update!(group:, **write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(group.reload)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_attributes
|
||||
{ name: params[:name], description: params[:description],
|
||||
members: params[:members], placements: params[:placements] }
|
||||
end
|
||||
end
|
||||
@@ -51,7 +51,7 @@ class TagVersionsController < ApplicationController
|
||||
|
||||
tags_by_id =
|
||||
Tag
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.where(id: all_parent_tag_ids)
|
||||
.index_by(&:id)
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ class TagsController < ApplicationController
|
||||
tags_by_id =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||
id: tag_ids)
|
||||
.index_by(&:id)
|
||||
@@ -238,7 +238,7 @@ class TagsController < ApplicationController
|
||||
base =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||
deprecated_at: nil)
|
||||
base = base.where('tags.post_count > 0') if present_only
|
||||
@@ -282,7 +282,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(id: params[:id],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||
@@ -302,7 +302,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||
|
||||
@@ -320,7 +320,7 @@ class TagsController < ApplicationController
|
||||
|
||||
tag =
|
||||
Tag.joins(:tag_names)
|
||||
.includes(:tag_names, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(id: params[:id],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
@@ -340,7 +340,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
|
||||
@@ -359,7 +359,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(id: params[:id],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
@@ -424,7 +424,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
|
||||
|
||||
@@ -3,24 +3,29 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
def index
|
||||
# 互換性のための化石リソース
|
||||
# TODO: ヘルプおよびタグ・グループ追加後に,
|
||||
# TODO: タグ説明とヘルプ、タグ・グループから取得する作りに変更する.
|
||||
#
|
||||
title = params[:title].to_s.strip
|
||||
descriptions = TagDescription.where(language_code: 'ja')
|
||||
.includes(tag: :tag_names)
|
||||
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
|
||||
|
||||
title = params[:title].to_s.strip
|
||||
descriptions =
|
||||
TagDescription.where(language_code: 'ja')
|
||||
.includes(tag: :tag_names)
|
||||
|
||||
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
||||
tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
|
||||
if title.present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||
descriptions = descriptions.joins(tag: :tag_names)
|
||||
descriptions =
|
||||
descriptions.joins(tag: :tag_names)
|
||||
.where(tag_names: { language_code: 'ja', primary_flg: true })
|
||||
.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||
tag_groups = tag_groups.where(
|
||||
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
|
||||
end
|
||||
|
||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
||||
json.concat(WikiPageRepr.base(pages))
|
||||
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||
json.sort_by! do |row|
|
||||
[-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')]
|
||||
end
|
||||
json = json.first(20) if title.present?
|
||||
render json:
|
||||
end
|
||||
@@ -30,39 +35,45 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(id: params[:id])
|
||||
tag_group = TagGroup.find_by(id: params[:id])
|
||||
return render_tag_group tag_group if tag_group
|
||||
|
||||
render_wiki_page_or_404 page
|
||||
render_wiki_page_or_404 nil
|
||||
end
|
||||
|
||||
def show_by_title
|
||||
title = params[:title].to_s.strip
|
||||
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||
tag_group = TagGroup.find_by(name:)
|
||||
return render_tag_group tag_group if tag_group
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
tag_description = tag_description_by_title(title)
|
||||
|
||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(tag_name: { name: title })
|
||||
|
||||
render_wiki_page_or_404 page
|
||||
render_wiki_page_or_404 nil
|
||||
end
|
||||
|
||||
def exists
|
||||
return head :no_content if TagDescription.exists?(id: params[:id])
|
||||
return head :no_content if TagGroup.exists?(id: params[:id])
|
||||
|
||||
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def exists_by_title
|
||||
title = params[:title].to_s.strip
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX))
|
||||
return head exists ? :no_content : :not_found
|
||||
end
|
||||
return head :no_content if tag_description_by_title(title)
|
||||
|
||||
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
||||
head exists ? :no_content : :not_found
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def diff
|
||||
@@ -79,29 +90,25 @@ class WikiPagesController < ApplicationController
|
||||
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
|
||||
resource = WikiPageRepr.tag_description(description)
|
||||
else
|
||||
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('差分を表示できない版です.')
|
||||
tag_group = TagGroup.find_by(id:)
|
||||
if tag_group
|
||||
from_rev = from && group_version(tag_group, from)
|
||||
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
|
||||
return head :not_found if (from && !from_rev) || !to_rev
|
||||
|
||||
from_body = from_rev && render_group_version(from_rev)
|
||||
to_body = render_group_version(to_rev)
|
||||
resource = { 'id' => tag_group.id,
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||
'deprecated_at' => nil }
|
||||
else
|
||||
return head :not_found
|
||||
end
|
||||
resource = WikiPageRepr.base(page)
|
||||
end
|
||||
|
||||
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
|
||||
diff_json = diffs.map { |change|
|
||||
case change.action
|
||||
when ?=
|
||||
{ type: 'context', content: change.old_element }
|
||||
when ?!
|
||||
[{ type: 'removed', content: change.old_element },
|
||||
{ type: 'added', content: change.new_element }]
|
||||
when ?+
|
||||
{ type: 'added', content: change.new_element }
|
||||
when ?-
|
||||
{ type: 'removed', content: change.old_element }
|
||||
end
|
||||
}.flatten.compact
|
||||
from_body ||= from_rev&.body
|
||||
to_body ||= to_rev.body
|
||||
diff_json = wiki_diff(from_body, to_body)
|
||||
|
||||
render json: { wiki_page_id: resource.fetch('id'),
|
||||
title: resource.fetch('title'),
|
||||
@@ -109,6 +116,8 @@ class WikiPagesController < ApplicationController
|
||||
older_revision_id: from_rev&.id,
|
||||
newer_revision_id: to_rev.id,
|
||||
diff: diff_json }
|
||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -122,17 +131,20 @@ class WikiPagesController < ApplicationController
|
||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
page = if title.include?(':')
|
||||
ApplicationRecord.transaction do
|
||||
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
|
||||
name.script_code = 'Jpan'
|
||||
name.primary_flg = true
|
||||
name.auto_generated = false
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||
parsed = TagGroupMarkdownParser.parse(body:, group: nil)
|
||||
group = TagGroupOperation.create!(name:, **parsed, actor: current_user,
|
||||
require_compatibility: true)
|
||||
version = current_group_version(group)
|
||||
return render_group_representation(TagGroupRepr.one(group), version,
|
||||
body: parsed.fetch(:compatibility_body),
|
||||
status: :created)
|
||||
end
|
||||
|
||||
Wiki::Commit.create_legacy_content!(
|
||||
tag_name:, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
page =
|
||||
if title.include?(':')
|
||||
return render_unprocessable_entity field: :title
|
||||
else
|
||||
tag_name = TagName.find_by(language_code: 'ja', name: title)
|
||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||
@@ -141,11 +153,14 @@ class WikiPagesController < ApplicationController
|
||||
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
|
||||
render json: WikiPageRepr.base(page), status: :created
|
||||
description = TagDescription.find_by!(tag_id: page.tag_name.tag_id, language_code: 'ja')
|
||||
render json: WikiPageRepr.tag_description(description), status: :created
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_validation_error e.record
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
render_record_not_unique
|
||||
rescue TagGroupMarkdownParser::InvalidDocument => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -158,36 +173,57 @@ class WikiPagesController < ApplicationController
|
||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
page = WikiPage.find(params[:id])
|
||||
group = TagGroup.find_by(id: params[:id])
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
return head :not_found unless group
|
||||
|
||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||
parsed = TagGroupMarkdownParser.parse(body:, group:)
|
||||
TagGroupOperation.update!(group:, name:, **parsed, actor: current_user,
|
||||
require_compatibility: true)
|
||||
return head :ok
|
||||
end
|
||||
|
||||
return render_unprocessable_entity field: :title if title.include?(':')
|
||||
description = TagDescription.find_by(id: params[:id])
|
||||
unless description
|
||||
return render_unprocessable_entity field: :title if group || WikiPage.exists?(id: params[:id])
|
||||
return head :not_found
|
||||
end
|
||||
tag = description.tag
|
||||
tag_name = TagDescriptionOperation.compatibility_tag_name(tag)
|
||||
if tag_name.name.include?(':') || (group && title != tag_name.name)
|
||||
return render_unprocessable_entity field: :title
|
||||
end
|
||||
|
||||
base_revision_id = params[:base_revision_id].presence
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
page.lock!
|
||||
|
||||
old_title = page.title
|
||||
|
||||
tag = page.tag_name.tag
|
||||
|
||||
if tag && title != old_title
|
||||
tag.lock!
|
||||
old_title = tag_name.name
|
||||
if title != old_title
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||
end
|
||||
|
||||
page.tag_name.update!(name: title) if title != old_title
|
||||
tag_name.update!(name: title) if title != old_title
|
||||
|
||||
message = params[:message].presence
|
||||
Wiki::Commit.content!(page:,
|
||||
body:,
|
||||
created_user: current_user,
|
||||
message:,
|
||||
base_revision_id:)
|
||||
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
|
||||
if tag && title != old_title
|
||||
if title != old_title
|
||||
tag.reload
|
||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
end
|
||||
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_validation_error e.record
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
render_record_not_unique
|
||||
rescue TagGroupMarkdownParser::InvalidDocument => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def changes
|
||||
@@ -197,32 +233,130 @@ class WikiPagesController < ApplicationController
|
||||
return render json: description_changes(TagDescription.where(id: description.id))
|
||||
end
|
||||
|
||||
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 }
|
||||
}
|
||||
tag_group = id && TagGroup.find_by(id:)
|
||||
if tag_group
|
||||
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
||||
end
|
||||
|
||||
json = []
|
||||
unless id
|
||||
json.concat(tag_group_changes(TagGroupVersion.all))
|
||||
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
||||
json.sort_by! { |row| -row[:revision_id] }
|
||||
json = json.each_with_index.sort_by do |row, index|
|
||||
[-row.fetch(:timestamp).to_f, index]
|
||||
end.map(&:first)
|
||||
end
|
||||
render json: json.first(200)
|
||||
end
|
||||
|
||||
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
|
||||
version = if params[:version].present?
|
||||
group_version(group, params[:version])
|
||||
else
|
||||
current_group_version(group)
|
||||
end
|
||||
return head :not_found unless version
|
||||
|
||||
representation = if params[:version].present?
|
||||
TagGroupRepr.historical(version)
|
||||
else
|
||||
TagGroupRepr.one(group)
|
||||
end
|
||||
render_group_representation(representation, version)
|
||||
end
|
||||
|
||||
def render_group_version version
|
||||
representation = TagGroupRepr.historical(version)
|
||||
TagGroupMarkdownRenderer.render(representation)
|
||||
end
|
||||
|
||||
def render_group_representation representation, version, body: nil, status: :ok
|
||||
body ||= TagGroupMarkdownRenderer.render(representation)
|
||||
versions = TagGroupVersion.where(tag_group_id: version.tag_group_id)
|
||||
pred = versions.find_by(version_no: version.version_no - 1)&.id
|
||||
succ = versions.find_by(version_no: version.version_no + 1)&.id
|
||||
updated_at = version.created_at
|
||||
render json: {
|
||||
'id' => version.tag_group_id,
|
||||
'resource_type' => 'tag_group',
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
||||
'deprecated_at' => nil,
|
||||
'body' => body,
|
||||
'revision_id' => version.id,
|
||||
'pred' => pred,
|
||||
'succ' => succ,
|
||||
'updated_at' => updated_at }, status:
|
||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def current_group_version group
|
||||
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||
end
|
||||
|
||||
def group_version group, version_id
|
||||
TagGroupVersion.find_by(tag_group_id: group.id, id: version_id)
|
||||
end
|
||||
|
||||
def wiki_diff from_body, to_body
|
||||
diffs = Diff::LCS.sdiff(from_body.to_s.lines, to_body.to_s.lines)
|
||||
diffs.map { |change|
|
||||
case change.action
|
||||
when ?=
|
||||
{ type: 'context', content: change.old_element }
|
||||
when ?!
|
||||
[{ type: 'removed', content: change.old_element },
|
||||
{ type: 'added', content: change.new_element }]
|
||||
when ?+
|
||||
{ type: 'added', content: change.new_element }
|
||||
when ?-
|
||||
{ type: 'removed', content: change.old_element }
|
||||
end
|
||||
}.flatten.compact
|
||||
end
|
||||
|
||||
def tag_group_changes versions
|
||||
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
|
||||
groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id)
|
||||
predecessors = TagGroupVersion.where(tag_group_id: groups.keys,
|
||||
version_no: rows.map { _1.version_no - 1 })
|
||||
.pluck(:tag_group_id, :version_no, :id)
|
||||
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
|
||||
rows.map do |version|
|
||||
group = groups.fetch(version.tag_group_id)
|
||||
user = version.created_by_user
|
||||
{ revision_id: version.id,
|
||||
pred: predecessors[[group.id, version.version_no - 1]],
|
||||
succ: nil,
|
||||
wiki_page: { id: group.id, title: "#{ TagGroupRepr::PREFIX }#{ version.name }",
|
||||
deprecated_at: nil },
|
||||
user: user && { id: user.id, name: user.name },
|
||||
kind: 'content', message: nil, timestamp: version.created_at }
|
||||
end
|
||||
end
|
||||
|
||||
def render_wiki_page_or_404 page
|
||||
return head :not_found unless page
|
||||
|
||||
@@ -269,13 +403,6 @@ class WikiPagesController < ApplicationController
|
||||
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
|
||||
end
|
||||
|
||||
def unmigrated_wiki_pages
|
||||
descriptions = TagDescription.where(language_code: 'ja')
|
||||
WikiPage.joins(:tag_name).where.not(id: descriptions.select(:id))
|
||||
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
||||
descriptions.select(:tag_id))
|
||||
end
|
||||
|
||||
def description_changes descriptions
|
||||
versions = TagDescriptionVersion.where(language_code: 'ja',
|
||||
tag_id: descriptions.select(:tag_id))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class ExternalTag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
enum :platform, nico: 'nico'
|
||||
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
||||
|
||||
|
||||
+10
-1
@@ -2,6 +2,8 @@ require 'set'
|
||||
|
||||
|
||||
class Tag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
class NicoTagNormalisationError < ArgumentError
|
||||
;
|
||||
end
|
||||
@@ -51,6 +53,7 @@ class Tag < ApplicationRecord
|
||||
has_many :tag_versions
|
||||
|
||||
has_many :tag_names
|
||||
has_many :tag_descriptions
|
||||
|
||||
attribute :version_no, :integer, default: 1
|
||||
|
||||
@@ -96,7 +99,13 @@ class Tag < ApplicationRecord
|
||||
|
||||
def deprecated? = deprecated_at?
|
||||
|
||||
def has_wiki = wiki_page.present?
|
||||
def has_description? language_code:
|
||||
if tag_descriptions.loaded?
|
||||
tag_descriptions.any? { _1.language_code == language_code }
|
||||
else
|
||||
tag_descriptions.exists?(language_code:)
|
||||
end
|
||||
end
|
||||
|
||||
def material_id = materials.first&.id
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class TagDescription < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
belongs_to :tag
|
||||
end
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
class TagGroup < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
|
||||
has_many :tag_group_tags, dependent: :delete_all
|
||||
has_many :tags, through: :tag_group_tags
|
||||
has_many :tag_group_groups, dependent: :delete_all
|
||||
has_many :child_tag_groups, through: :tag_group_groups
|
||||
has_many :placements, -> { order(:position) },
|
||||
class_name: 'TagGroupPlacement', dependent: :delete_all
|
||||
has_many :versions, -> { order(:version_no) },
|
||||
class_name: 'TagGroupVersion', dependent: :delete_all
|
||||
|
||||
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
|
||||
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class TagGroupGroup < ApplicationRecord
|
||||
self.primary_key = [:tag_group_id, :child_tag_group_id]
|
||||
|
||||
belongs_to :tag_group
|
||||
belongs_to :child_tag_group, class_name: 'TagGroup'
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class TagGroupPlacement < ApplicationRecord
|
||||
self.primary_key = [:tag_group_id, :position]
|
||||
|
||||
belongs_to :tag_group
|
||||
belongs_to :tag, optional: true
|
||||
belongs_to :child_tag_group, class_name: 'TagGroup', optional: true
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class TagGroupTag < ApplicationRecord
|
||||
self.primary_key = [:tag_group_id, :tag_id]
|
||||
|
||||
belongs_to :tag_group
|
||||
belongs_to :tag
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class TagGroupVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
belongs_to :tag_group
|
||||
|
||||
scope :ordered, -> { order(:version_no) }
|
||||
end
|
||||
@@ -0,0 +1,194 @@
|
||||
module TagGroupRepr
|
||||
PREFIX = 'タググループ:'
|
||||
|
||||
module_function
|
||||
|
||||
def many groups
|
||||
ids = groups.map(&:id)
|
||||
return [] if ids.empty?
|
||||
|
||||
tag_members = TagGroupTag.where(tag_group_id: ids)
|
||||
.group_by(&:tag_group_id)
|
||||
group_members = TagGroupGroup.where(tag_group_id: ids)
|
||||
.group_by(&:tag_group_id)
|
||||
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
|
||||
.group_by(&:tag_group_id)
|
||||
|
||||
target_ids = tag_members.values.flatten.map(&:tag_id) +
|
||||
group_members.values.flatten.map(&:child_tag_group_id) +
|
||||
placements.values.flatten.filter_map(&:tag_id)
|
||||
child_ids = group_members.values.flatten.map(&:child_tag_group_id) +
|
||||
placements.values.flatten.filter_map(&:child_tag_group_id)
|
||||
tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id)
|
||||
names = TagName.where(tag_id: target_ids.uniq, language_code: 'ja', primary_flg: true)
|
||||
.pluck(:tag_id, :name).to_h
|
||||
child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id)
|
||||
|
||||
groups.map do |group|
|
||||
group_json(group,
|
||||
tag_members.fetch(group.id, []),
|
||||
group_members.fetch(group.id, []),
|
||||
placements.fetch(group.id, []), tags, names, child_groups)
|
||||
end
|
||||
end
|
||||
|
||||
def one group
|
||||
many([group]).first
|
||||
end
|
||||
|
||||
def tag_group wiki_page
|
||||
{ 'id' => wiki_page.id,
|
||||
'title' => "#{ PREFIX }#{ wiki_page.name }",
|
||||
'deprecated_at' => nil,
|
||||
'created_at' => wiki_page.created_at,
|
||||
'updated_at' => wiki_page.updated_at }
|
||||
end
|
||||
|
||||
def current_version group
|
||||
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||
end
|
||||
|
||||
def historical version
|
||||
members = Array(version.members)
|
||||
placements = Array(version.placements)
|
||||
tag_references = members.filter_map { |member| member['tag_id'] } +
|
||||
collect_ids(placements, 'tag_id')
|
||||
group_references = members.filter_map { |member| member['tag_group_id'] } +
|
||||
collect_ids(placements, 'tag_group_id')
|
||||
tag_versions = historical_tag_versions(tag_references)
|
||||
group_versions = historical_group_versions(group_references)
|
||||
tag_version_numbers = members.filter_map do |member|
|
||||
next unless member['tag_id']
|
||||
|
||||
[member.fetch('tag_id'), member.fetch('version_no')]
|
||||
end.to_h
|
||||
group_version_numbers = members.filter_map do |member|
|
||||
next unless member['tag_group_id']
|
||||
|
||||
[member.fetch('tag_group_id'), member.fetch('version_no')]
|
||||
end.to_h
|
||||
historical_members = members.map do |member|
|
||||
historical_member(member, tag_versions, group_versions)
|
||||
end
|
||||
historical_placements = placements.map do |node|
|
||||
historical_placement(node, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers)
|
||||
end
|
||||
{ 'id' => version.tag_group_id,
|
||||
'name' => version.name,
|
||||
'description' => version.description,
|
||||
'version_no' => version.version_no,
|
||||
'members' => historical_members,
|
||||
'placements' => historical_placements,
|
||||
'created_at' => version.created_at,
|
||||
'updated_at' => version.created_at,
|
||||
'created_by_user' => user_json(version.created_by_user),
|
||||
'updated_by_user' => user_json(version.created_by_user) }
|
||||
end
|
||||
|
||||
def group_json group, tag_members, group_members, rows, tags, names, child_groups
|
||||
tree = placement_tree(rows, tags, names, child_groups)
|
||||
members = tag_members.sort_by(&:tag_id).map do |member|
|
||||
{ 'tag' => tag_json(tags.fetch(member.tag_id), names.fetch(member.tag_id)) }
|
||||
end
|
||||
members.concat(group_members.sort_by(&:child_tag_group_id).map do |member|
|
||||
{ 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) }
|
||||
end)
|
||||
{ 'id' => group.id,
|
||||
'name' => group.name,
|
||||
'description' => group.description,
|
||||
'version_no' => group.version_no,
|
||||
'members' => members,
|
||||
'placements' => tree,
|
||||
'created_at' => group.created_at,
|
||||
'updated_at' => group.updated_at,
|
||||
'created_by_user' => user_json(group.created_by_user),
|
||||
'updated_by_user' => user_json(group.updated_by_user) }
|
||||
end
|
||||
|
||||
def user_json user
|
||||
UserRepr.base(user) if user
|
||||
end
|
||||
|
||||
def placement_tree rows, tags, names, child_groups
|
||||
by_parent = rows.group_by(&:parent_position)
|
||||
build = lambda do |parent|
|
||||
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
|
||||
node = if row.tag_id
|
||||
{ 'tag' => tag_json(tags.fetch(row.tag_id), names.fetch(row.tag_id)) }
|
||||
elsif row.child_tag_group_id
|
||||
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
|
||||
else
|
||||
{ 'section_name' => row.section_name, 'description' => row.description }
|
||||
end
|
||||
node.merge('children' => build.call(row.position))
|
||||
end
|
||||
end
|
||||
build.call(nil)
|
||||
end
|
||||
|
||||
def tag_json tag, name
|
||||
{ 'id' => tag.id, 'name' => name, 'category' => tag.category }
|
||||
end
|
||||
|
||||
def group_json_reference group
|
||||
{ 'id' => group.id, 'name' => group.name }
|
||||
end
|
||||
|
||||
def collect_ids nodes, key
|
||||
nodes.flat_map do |node|
|
||||
[node[key], *collect_ids(Array(node['children']), key)].compact
|
||||
end
|
||||
end
|
||||
|
||||
def historical_tag_versions ids
|
||||
TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version|
|
||||
[version.tag_id, version.version_no]
|
||||
end
|
||||
end
|
||||
|
||||
def historical_group_versions ids
|
||||
TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version|
|
||||
[version.tag_group_id, version.version_no]
|
||||
end
|
||||
end
|
||||
|
||||
def historical_member member, tag_versions, group_versions
|
||||
if member['tag_id']
|
||||
id = member.fetch('tag_id')
|
||||
version = tag_versions.fetch([id, member.fetch('version_no')])
|
||||
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
|
||||
'category' => version.category } }
|
||||
else
|
||||
id = member.fetch('tag_group_id')
|
||||
version = group_versions.fetch([id, member.fetch('version_no')])
|
||||
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
|
||||
end
|
||||
end
|
||||
|
||||
def historical_placement(node, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers)
|
||||
result = if node['tag_id']
|
||||
id = node.fetch('tag_id')
|
||||
version_no = tag_version_numbers.fetch(id)
|
||||
version = tag_versions.fetch([id, version_no])
|
||||
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
|
||||
'category' => version.category } }
|
||||
elsif node['tag_group_id']
|
||||
id = node.fetch('tag_group_id')
|
||||
version_no = group_version_numbers.fetch(id)
|
||||
version = group_versions.fetch([id, version_no])
|
||||
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
|
||||
else
|
||||
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
|
||||
end
|
||||
result.merge('children' => Array(node['children']).map do |child|
|
||||
historical_placement(child, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers)
|
||||
end)
|
||||
end
|
||||
|
||||
def japanese_version_name version
|
||||
version.names.fetch('ja').fetch('name')
|
||||
end
|
||||
end
|
||||
@@ -3,21 +3,28 @@
|
||||
|
||||
module TagRepr
|
||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
||||
methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze
|
||||
methods: [:material_id, :has_deerjikists] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def base tag, language_code: 'ja'
|
||||
tag.as_json(BASE).merge(name: name_for(tag, language_code),
|
||||
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||
name: name_for(tag, language_code),
|
||||
aliases: tag.snapshot_aliases(language_code),
|
||||
parents: tag.parents.map { inline(_1, language_code:) })
|
||||
end
|
||||
|
||||
def inline tag, language_code: 'ja'
|
||||
tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: [])
|
||||
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||
name: name_for(tag, language_code), aliases: [], parents: [])
|
||||
end
|
||||
|
||||
def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) }
|
||||
def many tags, language_code: 'ja'
|
||||
records = tags.to_a
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records:, associations: [:tag_descriptions, { parents: :tag_descriptions }]).call
|
||||
records.map { |tag| base(tag, language_code:) }
|
||||
end
|
||||
|
||||
def name_for tag, language_code
|
||||
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
|
||||
|
||||
@@ -13,6 +13,7 @@ module WikiPageRepr
|
||||
def tag_description description
|
||||
tag = description.tag
|
||||
{ 'id' => description.id,
|
||||
'resource_type' => 'tag_description',
|
||||
'tag_name_id' => tag.tag_name('ja')&.id,
|
||||
'body' => description.body,
|
||||
'created_user_id' => description.created_by_user_id,
|
||||
@@ -25,6 +26,10 @@ module WikiPageRepr
|
||||
'deprecated_at' => tag.deprecated_at }
|
||||
end
|
||||
|
||||
def tag_group tag_group
|
||||
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||
end
|
||||
|
||||
def many wiki_pages
|
||||
wiki_pages.map { |p| base(p) }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
require 'digest'
|
||||
|
||||
|
||||
class SharedResourceIdAllocator
|
||||
SPACES = {
|
||||
'tags' => ['tags', 'external_tags'],
|
||||
'wiki' => ['tag_descriptions', 'tag_groups'] }.freeze
|
||||
LOCK_TIMEOUT = 10
|
||||
|
||||
def self.assign! record
|
||||
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
|
||||
raise ArgumentError, 'Unknown shared ID resource' unless tables
|
||||
|
||||
connection = record.class.connection
|
||||
transaction = record.class.current_transaction
|
||||
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' unless transaction.open?
|
||||
|
||||
database = connection.pool.db_config.database
|
||||
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
|
||||
quoted_name = connection.quote(name)
|
||||
acquired = record.class.uncached do
|
||||
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
|
||||
end
|
||||
unless acquired.to_i == 1
|
||||
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
|
||||
end
|
||||
|
||||
release = lambda do
|
||||
result = record.class.uncached do
|
||||
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
|
||||
end
|
||||
unless result.to_i == 1
|
||||
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
|
||||
end
|
||||
end
|
||||
begin
|
||||
transaction.after_commit(&release)
|
||||
transaction.after_rollback(&release)
|
||||
rescue StandardError
|
||||
release.call
|
||||
raise
|
||||
end
|
||||
|
||||
return if record.id.present?
|
||||
|
||||
# Locking reads see committed maxima even after earlier repeatable reads.
|
||||
maxima = record.class.uncached do
|
||||
tables.map do |table|
|
||||
quoted_table = connection.quote_table_name(table)
|
||||
connection.select_value(
|
||||
"SELECT id FROM #{ quoted_table } ORDER BY id DESC LIMIT 1 FOR UPDATE").to_i
|
||||
end
|
||||
end
|
||||
record.id = maxima.max + 1
|
||||
end
|
||||
end
|
||||
@@ -8,9 +8,9 @@ class TagDescriptionOperation
|
||||
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
||||
|
||||
page = Wiki::CompatibilityWriter.create!(
|
||||
tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:)
|
||||
tag_name: compatibility_tag_name(tag), body:, created_by_user:, message:)
|
||||
revision = page.wiki_revisions.order(id: :desc).first
|
||||
record_version!(tag:, revision:, body:, user: created_by_user,
|
||||
record_version!(tag:, revision:, body: revision.body, user: created_by_user,
|
||||
description: nil)
|
||||
page
|
||||
end
|
||||
@@ -22,7 +22,20 @@ class TagDescriptionOperation
|
||||
tag.lock!
|
||||
description = TagDescription.lock.find_by!(
|
||||
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
||||
page = WikiPage.lock.find(description.id)
|
||||
tag_name = compatibility_tag_name(tag)
|
||||
page = WikiPage.lock.find_by(tag_name_id: tag_name.id)
|
||||
unless page
|
||||
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: LANGUAGE_CODE)
|
||||
if base_revision_id.present? && versions.maximum(:id) != base_revision_id.to_i
|
||||
raise Wiki::Commit::Conflict, '競合が発生してゐます.'
|
||||
end
|
||||
page = Wiki::CompatibilityWriter.create!(
|
||||
tag_name:, body:, created_by_user: updated_by_user, message:)
|
||||
revision = page.current_revision
|
||||
record_version!(tag:, revision:, body: revision.body,
|
||||
user: updated_by_user, description:)
|
||||
next revision
|
||||
end
|
||||
revision = Wiki::CompatibilityWriter.update!(
|
||||
page:, body:, updated_by_user:, message:,
|
||||
base_revision_id:)
|
||||
@@ -34,6 +47,11 @@ class TagDescriptionOperation
|
||||
end
|
||||
end
|
||||
|
||||
def self.compatibility_tag_name tag
|
||||
tag.tag_name(LANGUAGE_CODE) || TagName.find_by!(id: tag.tag_name_id,
|
||||
language_code: LANGUAGE_CODE)
|
||||
end
|
||||
|
||||
def self.record_version! tag:, revision:, body:, user:, description:
|
||||
version_no = description ? description.version_no + 1 : 1
|
||||
TagDescriptionVersion.create!(
|
||||
@@ -44,7 +62,7 @@ class TagDescriptionOperation
|
||||
description.update!(body:, version_no:, updated_by_user_id: user.id)
|
||||
else
|
||||
TagDescription.create!(
|
||||
id: revision.wiki_page_id, tag_id: tag.id,
|
||||
tag_id: tag.id,
|
||||
language_code: LANGUAGE_CODE, version_no:, body:,
|
||||
created_by_user_id: user.id, updated_by_user_id: user.id)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
require 'kramdown'
|
||||
|
||||
class TagGroupMarkdownParser
|
||||
PREFIX = TagGroupRepr::PREFIX
|
||||
|
||||
class InvalidDocument < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.parse(body:, group: nil)
|
||||
new(body:, group:).parse
|
||||
end
|
||||
|
||||
def initialize(body:, group:)
|
||||
@body = body.to_s.gsub(/\r\n?/, "\n")
|
||||
@group = group
|
||||
end
|
||||
|
||||
def parse
|
||||
parse_body = @body.gsub(/([^\n])\n([\x23]{1,6}[ \t]+\S)/, "\\1\n\n\\2")
|
||||
root = Kramdown::Document.new(parse_body, input: 'GFM', auto_ids: false).root
|
||||
placements = []
|
||||
description_parts = []
|
||||
headings = []
|
||||
|
||||
root.children.each do |node|
|
||||
case node.type
|
||||
when :blank
|
||||
next
|
||||
when :header
|
||||
level = node.options.fetch(:level)
|
||||
name = inline_text(node).strip
|
||||
invalid!('Section 名は必須です.') if name.blank?
|
||||
|
||||
headings.pop while headings.any? && headings.last.fetch(:level) >= level
|
||||
parent = headings.last&.fetch(:node)
|
||||
section = { 'section_name' => name, 'description' => nil,
|
||||
'children' => [] }
|
||||
(parent ? parent.fetch('children') : placements) << section
|
||||
headings << { level:, node: section }
|
||||
when :p
|
||||
text = inline_markdown(node).strip
|
||||
next if text.empty?
|
||||
|
||||
if headings.any?
|
||||
section = headings.last.fetch(:node)
|
||||
section['description'] = [section['description'], text].compact.join("\n\n")
|
||||
else
|
||||
description_parts << text
|
||||
end
|
||||
when :ul
|
||||
invalid!('未対応のリスト項目があります.') if node.attr.any?
|
||||
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
|
||||
target.concat(node.children.map { parse_list_item(_1) })
|
||||
else
|
||||
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
end
|
||||
|
||||
description = description_parts.presence&.join("\n\n")
|
||||
{ description:, placements:, members: build_members(placements),
|
||||
compatibility_body: @body.sub(/\n+\z/, '') }
|
||||
rescue Kramdown::Error => error
|
||||
invalid!("Markdown を解析できません:#{ error.message }")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_list_item(item)
|
||||
invalid!('リスト項目ではありません.') unless item.type == :li
|
||||
invalid!('未対応のリスト項目があります.') if item.attr.any?
|
||||
|
||||
nested, own = item.children.partition { |child| child.type == :ul }
|
||||
invalid!('順序付きリストは使用できません.') if own.any? { _1.type == :ol }
|
||||
own.reject! { _1.type == :blank }
|
||||
invalid!('未対応のリスト項目があります.') unless own.all? do |child|
|
||||
[:p, :text].include?(child.type)
|
||||
end
|
||||
invalid!('リスト項目は一つの段落で指定してください.') if own.size != 1
|
||||
|
||||
label = own.map { inline_text(_1) }.join(' ').strip
|
||||
invalid!('空のリスト項目は使用できません.') if label.blank?
|
||||
|
||||
node = if label.start_with?(PREFIX)
|
||||
group_name = label.delete_prefix(PREFIX).strip
|
||||
child_group = TagGroup.find_by(name: group_name)
|
||||
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
|
||||
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
|
||||
{ 'tag_group_id' => child_group.id }
|
||||
elsif label.end_with?(':', ':')
|
||||
section_name = label.sub(/[::]\z/, '').strip
|
||||
invalid!('Section 名は必須です.') if section_name.blank?
|
||||
{ 'section_name' => section_name, 'description' => nil }
|
||||
else
|
||||
{ 'tag_id' => resolve_tag(label) }
|
||||
end
|
||||
|
||||
node['children'] = nested.flat_map do |list|
|
||||
invalid!('未対応の入れ子リストです.') unless list.type == :ul
|
||||
list.children.map { parse_list_item(_1) }
|
||||
end
|
||||
node
|
||||
end
|
||||
|
||||
def resolve_tag(name)
|
||||
names = TagName.where(language_code: 'ja', name:).to_a
|
||||
tag_ids = names.filter_map(&:tag_id).uniq
|
||||
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
|
||||
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
|
||||
|
||||
tag_ids.first
|
||||
end
|
||||
|
||||
def build_members(nodes)
|
||||
references = []
|
||||
visit = lambda do |items|
|
||||
items.each do |node|
|
||||
references << node.slice('tag_id', 'tag_group_id') if
|
||||
node.key?('tag_id') || node.key?('tag_group_id')
|
||||
visit.call(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
visit.call(nodes)
|
||||
references.uniq
|
||||
end
|
||||
|
||||
def inline_text(node)
|
||||
if node.attr.any?
|
||||
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
|
||||
case node.type
|
||||
when :text
|
||||
node.value.to_s
|
||||
when :p, :header
|
||||
node.children.map { inline_text(_1) }.join
|
||||
else
|
||||
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
end
|
||||
|
||||
def inline_markdown(node)
|
||||
case node.type
|
||||
when :text
|
||||
node.value.to_s
|
||||
when :codespan
|
||||
text = node.value.to_s
|
||||
longest = text.scan(/`+/).map(&:length).max || 0
|
||||
delimiter = '`' * (longest + 1)
|
||||
padded = text.start_with?('`', ' ') || text.end_with?('`', ' ')
|
||||
content = padded ? " #{ text } " : text
|
||||
"#{ delimiter }#{ content }#{ delimiter }"
|
||||
when :softbreak
|
||||
"\n"
|
||||
when :br
|
||||
" \n"
|
||||
when :p
|
||||
node.children.map { inline_markdown(_1) }.join
|
||||
when :a
|
||||
href = node.attr.fetch('href')
|
||||
label = node.children.map { inline_markdown(_1) }.join
|
||||
title = node.attr['title']&.gsub('"', '\\"')
|
||||
suffix = title ? " \"#{ title }\"" : ''
|
||||
"[#{ label }](#{ href }#{ suffix })"
|
||||
when :em
|
||||
"*#{ node.children.map { inline_markdown(_1) }.join }*"
|
||||
when :strong
|
||||
"**#{ node.children.map { inline_markdown(_1) }.join }**"
|
||||
else
|
||||
invalid!("未対応の説明文構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
end
|
||||
|
||||
def invalid! message
|
||||
raise InvalidDocument, message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
class TagGroupMarkdownRenderer
|
||||
class UnsupportedStructure < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.render group
|
||||
new(group).render
|
||||
end
|
||||
|
||||
def initialize group
|
||||
@group = group
|
||||
end
|
||||
|
||||
def render
|
||||
placements = @group.fetch('placements')
|
||||
validate_membership!(placements)
|
||||
blocks = []
|
||||
if @group['description'].present?
|
||||
blocks << clean_text(@group['description'])
|
||||
end
|
||||
|
||||
sections = placements.select { _1.key?('section_name') }
|
||||
if sections.any? && sections.size != placements.size
|
||||
raise UnsupportedStructure,
|
||||
'最上位の見出し Section と Placement の混在は Markdown にできません.'
|
||||
end
|
||||
|
||||
if placements.all? { _1.key?('section_name') }
|
||||
blocks.concat(placements.map { render_heading(_1, 2) })
|
||||
else
|
||||
blocks << render_list(placements, 0)
|
||||
end
|
||||
|
||||
blocks.compact.reject(&:empty?).join("\n").sub(/\n+\z/, '')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_membership! placements
|
||||
placed = placement_members(placements).uniq.sort
|
||||
members = @group.fetch('members').map { |member|
|
||||
if member['tag']
|
||||
['tag', member.fetch('tag').fetch('id')]
|
||||
else
|
||||
['tag_group', member.fetch('tag_group').fetch('id')]
|
||||
end
|
||||
}.uniq.sort
|
||||
missing = members - placed
|
||||
if missing.any?
|
||||
raise UnsupportedStructure, '掲載位置のない membership は Markdown にできません.'
|
||||
end
|
||||
end
|
||||
|
||||
def placement_members nodes
|
||||
nodes.flat_map do |node|
|
||||
own = if node['tag']
|
||||
[['tag', node.fetch('tag').fetch('id')]]
|
||||
elsif node['tag_group']
|
||||
[['tag_group', node.fetch('tag_group').fetch('id')]]
|
||||
else
|
||||
[]
|
||||
end
|
||||
own + placement_members(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
|
||||
def render_heading section, level
|
||||
header = "#{ '#' * level } #{ clean_text(section.fetch('section_name')) }"
|
||||
blocks = [header]
|
||||
blocks << clean_text(section['description']) if section['description'].present?
|
||||
children = section.fetch('children')
|
||||
section_indexes = children.each_index.select { |index| children[index].key?('section_name') }
|
||||
if section_indexes.any?
|
||||
first_section = section_indexes.first
|
||||
if children[first_section..].any? { !_1.key?('section_name') }
|
||||
raise UnsupportedStructure,
|
||||
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
|
||||
end
|
||||
list_nodes = children[0...first_section]
|
||||
blocks << render_list(list_nodes, 0) if list_nodes.any?
|
||||
blocks.concat(children[first_section..].map { render_heading(_1, level + 1) })
|
||||
elsif children.any?
|
||||
blocks << render_list(children, 0)
|
||||
end
|
||||
blocks.join("\n")
|
||||
end
|
||||
|
||||
def render_list nodes, depth
|
||||
nodes.map do |node|
|
||||
indent = ' ' * depth
|
||||
if node.key?('section_name')
|
||||
raise UnsupportedStructure,
|
||||
'リスト内 Section の説明文は Markdown にできません.' if node['description'].present?
|
||||
|
||||
children = node.fetch('children')
|
||||
head = "#{ indent }- #{ clean_text(node.fetch('section_name')) }:"
|
||||
tail = render_list(children, depth + 1) if children.any?
|
||||
[head, tail].compact.join("\n")
|
||||
else
|
||||
label = if node['tag']
|
||||
node.fetch('tag').fetch('name')
|
||||
else
|
||||
"#{ TagGroupRepr::PREFIX }#{ node.fetch('tag_group').fetch('name') }"
|
||||
end
|
||||
raise UnsupportedStructure, '名称のない Placement は Markdown にできません.' if label.blank?
|
||||
|
||||
head = "#{ indent }- #{ clean_text(label) }"
|
||||
children = node.fetch('children')
|
||||
tail = render_list(children, depth + 1) if children.any?
|
||||
[head, tail].compact.join("\n")
|
||||
end
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def clean_text value
|
||||
value.to_s.gsub(/\r\n?/, "\n").lines.map(&:rstrip).join("\n").strip
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,410 @@
|
||||
class TagGroupOperation
|
||||
MAX_PLACEMENT_DEPTH = 5
|
||||
PREFIX = TagGroupRepr::PREFIX
|
||||
|
||||
def self.create!(name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false, compatibility_body: nil)
|
||||
group, sync_issue = ApplicationRecord.transaction do
|
||||
group = TagGroup.new(version_no: 1, created_by_user: actor,
|
||||
updated_by_user: actor)
|
||||
attributes = prepare!(group:, name:, description:, members:, placements:)
|
||||
group.assign_attributes(name: attributes.fetch(:name),
|
||||
description: attributes.fetch(:description))
|
||||
group.save!
|
||||
persist_structure!(group:, attributes:, actor:)
|
||||
persist_version!(group:, attributes:, actor:, event_type: 'create')
|
||||
issue = sync_compatibility!(group:, actor:, old_name: nil,
|
||||
require_compatibility:, compatibility_body:)
|
||||
[group, issue]
|
||||
end
|
||||
report_sync_issue(group, actor, sync_issue)
|
||||
group
|
||||
end
|
||||
|
||||
def self.update!(group:, name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false, compatibility_body: nil)
|
||||
group, sync_issue = ApplicationRecord.transaction do
|
||||
group.lock!
|
||||
old_name = group.name
|
||||
attributes = prepare!(group:, name:, description:, members:, placements:)
|
||||
next [group, nil] if unchanged?(group, attributes)
|
||||
|
||||
group.update!(name: attributes.fetch(:name),
|
||||
description: attributes.fetch(:description),
|
||||
version_no: group.version_no + 1,
|
||||
updated_by_user: actor)
|
||||
persist_structure!(group:, attributes:, actor:)
|
||||
persist_version!(group:, attributes:, actor:, event_type: 'update')
|
||||
issue = sync_compatibility!(group:, actor:, old_name:,
|
||||
require_compatibility:, compatibility_body:)
|
||||
[group, issue]
|
||||
end
|
||||
report_sync_issue(group, actor, sync_issue)
|
||||
group
|
||||
end
|
||||
|
||||
def self.prepare!(group:, name:, description:, members:, placements:)
|
||||
record = group
|
||||
normalized_name = name.to_s.strip
|
||||
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
|
||||
if normalized_name.length > 255
|
||||
invalid!(record, :name, 'グループ名は 255 文字以内で入力してください.')
|
||||
end
|
||||
invalid!(record, :description, '説明文は文字列で入力してください.') unless
|
||||
description.nil? || description.is_a?(String)
|
||||
|
||||
record.name = normalized_name
|
||||
record.description = description
|
||||
unless record.valid?
|
||||
raise ActiveRecord::RecordInvalid, record
|
||||
end
|
||||
|
||||
member_rows = normalize_members(record, members)
|
||||
placement_tree = normalize_placements(record, placements)
|
||||
validate_references!(record, member_rows, placement_tree)
|
||||
validate_containment!(record, member_rows)
|
||||
{ name: normalized_name, description:, members: member_rows,
|
||||
placements: placement_tree }
|
||||
end
|
||||
private_class_method :prepare!
|
||||
|
||||
def self.unchanged?(group, attributes)
|
||||
unless (group.name_in_database == attributes.fetch(:name) &&
|
||||
group.description_in_database == attributes.fetch(:description))
|
||||
return false
|
||||
end
|
||||
|
||||
members = TagGroupTag.where(tag_group_id: group.id).pluck(:tag_id)
|
||||
.map { { 'tag_id' => _1 } }
|
||||
members.concat(TagGroupGroup.where(tag_group_id: group.id)
|
||||
.pluck(:child_tag_group_id).map { { 'tag_group_id' => _1 } })
|
||||
requested_members = attributes.fetch(:members).map do |member|
|
||||
member.except('version_no')
|
||||
end
|
||||
return false unless members.to_set == requested_members.to_set
|
||||
|
||||
rows = TagGroupPlacement.where(tag_group_id: group.id).order(:position)
|
||||
.group_by(&:parent_position)
|
||||
build = lambda do |parent_position|
|
||||
rows.fetch(parent_position, []).map do |row|
|
||||
node = if row.tag_id
|
||||
{ 'tag_id' => row.tag_id }
|
||||
elsif row.child_tag_group_id
|
||||
{ 'tag_group_id' => row.child_tag_group_id }
|
||||
else
|
||||
{ 'section_name' => row.section_name, 'description' => row.description }
|
||||
end
|
||||
node.merge('children' => build.call(row.position))
|
||||
end
|
||||
end
|
||||
build.call(nil) == comparable_placements(attributes.fetch(:placements))
|
||||
end
|
||||
private_class_method :unchanged?
|
||||
|
||||
def self.comparable_placements(nodes)
|
||||
nodes.map do |node|
|
||||
result = node.dup
|
||||
result['description'] = node['description'] if node.key?('section_name')
|
||||
result['children'] = comparable_placements(node.fetch('children'))
|
||||
result
|
||||
end
|
||||
end
|
||||
private_class_method :comparable_placements
|
||||
|
||||
def self.normalize_members(record, members)
|
||||
invalid!(record, :members, 'members は配列で指定してください.') unless members.is_a?(Array)
|
||||
|
||||
result = members.map do |member|
|
||||
member = stringify_keys(member)
|
||||
unless member.is_a?(Hash) && member.keys.one? &&
|
||||
(member.key?('tag_id') || member.key?('tag_group_id'))
|
||||
invalid!(record, :members, '各 member は tag_id または tag_group_id を一つ指定してください.')
|
||||
end
|
||||
key = member.keys.first
|
||||
id = integer_id(record, member.fetch(key), :members)
|
||||
{ key => id }
|
||||
end
|
||||
invalid!(record, :members, 'membership に重複があります.') unless result.uniq == result
|
||||
result
|
||||
end
|
||||
private_class_method :normalize_members
|
||||
|
||||
def self.normalize_placements(record, placements)
|
||||
invalid!(record, :placements, 'placements は配列で指定してください.') unless
|
||||
placements.is_a?(Array)
|
||||
|
||||
normalize_placement_nodes(record, placements, 1)
|
||||
end
|
||||
private_class_method :normalize_placements
|
||||
|
||||
def self.normalize_placement_nodes(record, nodes, depth)
|
||||
if depth > MAX_PLACEMENT_DEPTH && nodes.any?
|
||||
invalid!(record, :placements, '配置の深さは 5 階層までです.')
|
||||
end
|
||||
|
||||
nodes.map do |raw_node|
|
||||
node = stringify_keys(raw_node)
|
||||
unless node.is_a?(Hash) && node.key?('children')
|
||||
invalid!(record, :placements, '各 Placement に children 配列が必要です.')
|
||||
end
|
||||
children = node.delete('children')
|
||||
unless children.is_a?(Array)
|
||||
invalid!(record, :placements, 'children は配列で指定してください.')
|
||||
end
|
||||
|
||||
kinds = ['tag_id', 'tag_group_id', 'section_name'].select { node.key?(_1) }
|
||||
invalid!(record, :placements, 'Placement の種類を一つ指定してください.') unless
|
||||
kinds.one?
|
||||
kind = kinds.first
|
||||
allowed = [kind, 'description']
|
||||
invalid!(record, :placements, '不明な Placement 項目があります.') unless
|
||||
(node.keys - allowed).empty?
|
||||
|
||||
case kind
|
||||
when 'tag_id', 'tag_group_id'
|
||||
node[kind] = integer_id(record, node.fetch(kind), :placements)
|
||||
if node['description'].present?
|
||||
invalid!(record, :placements, 'Tag / Tag Group Placement に説明文は指定できません.')
|
||||
end
|
||||
node.delete('description')
|
||||
when 'section_name'
|
||||
section_name = node.fetch(kind)
|
||||
unless section_name.is_a?(String) && section_name.strip.present? &&
|
||||
section_name.length <= 255
|
||||
invalid!(record, :placements, 'Section 名は 1〜255 文字で入力してください.')
|
||||
end
|
||||
node[kind] = section_name.strip
|
||||
unless node['description'].nil? || node['description'].is_a?(String)
|
||||
invalid!(record, :placements, 'Section の説明文は文字列で入力してください.')
|
||||
end
|
||||
end
|
||||
|
||||
node['children'] = normalize_placement_nodes(record, children, depth + 1)
|
||||
node
|
||||
end
|
||||
end
|
||||
private_class_method :normalize_placement_nodes
|
||||
|
||||
def self.validate_references!(record, members, placements)
|
||||
membership = members.to_set
|
||||
placed = placement_references(placements).uniq
|
||||
missing = placed - membership.to_a
|
||||
invalid!(record, :placements, 'Placement の対象は membership に含めてください.') if
|
||||
missing.any?
|
||||
|
||||
tag_ids = members.filter_map { _1['tag_id'] }
|
||||
group_ids = members.filter_map { _1['tag_group_id'] }
|
||||
tags = Tag.where(id: tag_ids).index_by(&:id)
|
||||
groups = TagGroup.where(id: group_ids).index_by(&:id)
|
||||
invalid!(record, :members, '存在しない Tag が含まれています.') if
|
||||
tags.size != tag_ids.uniq.size
|
||||
invalid!(record, :members, '存在しない Tag Group が含まれています.') if
|
||||
groups.size != group_ids.uniq.size
|
||||
|
||||
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
|
||||
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
|
||||
.pluck(:tag_group_id, :version_no).to_set
|
||||
tag_names = TagName.where(tag_id: tag_ids, language_code: 'ja', primary_flg: true)
|
||||
.pluck(:tag_id).uniq
|
||||
|
||||
members.each do |member|
|
||||
if member.key?('tag_id')
|
||||
id = member.fetch('tag_id')
|
||||
invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") unless
|
||||
tag_names.include?(id)
|
||||
version_no = tags.fetch(id).version_no
|
||||
unless tag_versions.include?([id, version_no])
|
||||
invalid!(record, :members, "Tag ##{ id } の Version がありません.")
|
||||
end
|
||||
member['version_no'] = version_no
|
||||
else
|
||||
id = member.fetch('tag_group_id')
|
||||
version_no = groups.fetch(id).version_no
|
||||
unless group_versions.include?([id, version_no])
|
||||
invalid!(record, :members, "Tag Group ##{ id } の Version がありません.")
|
||||
end
|
||||
member['version_no'] = version_no
|
||||
end
|
||||
end
|
||||
end
|
||||
private_class_method :validate_references!
|
||||
|
||||
def self.validate_containment!(record, members)
|
||||
target_id = record.id
|
||||
return unless target_id
|
||||
|
||||
children = members.filter_map { _1['tag_group_id'] }
|
||||
edges = TagGroupGroup.pluck(:tag_group_id, :child_tag_group_id)
|
||||
.group_by(&:first).transform_values { _1.map(&:last) }
|
||||
edges[target_id] = children
|
||||
visiting = Set.new
|
||||
visited = Set.new
|
||||
visit = lambda do |group_id|
|
||||
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
|
||||
group_id == target_id && visiting.include?(group_id)
|
||||
return if visited.include?(group_id)
|
||||
|
||||
visiting.add(group_id)
|
||||
edges.fetch(group_id, []).each do |child_id|
|
||||
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
|
||||
child_id == target_id
|
||||
visit.call(child_id)
|
||||
end
|
||||
visiting.delete(group_id)
|
||||
visited.add(group_id)
|
||||
end
|
||||
children.each { visit.call(_1) }
|
||||
end
|
||||
private_class_method :validate_containment!
|
||||
|
||||
def self.persist_structure!(group:, attributes:, actor:)
|
||||
tag_members = TagGroupTag.where(tag_group_id: group.id).index_by(&:tag_id)
|
||||
group_members = TagGroupGroup.where(tag_group_id: group.id).index_by(&:child_tag_group_id)
|
||||
attributes.fetch(:members).each do |member|
|
||||
if member.key?('tag_id')
|
||||
next if tag_members.delete(member.fetch('tag_id'))
|
||||
|
||||
TagGroupTag.create!(tag_group_id: group.id, tag_id: member.fetch('tag_id'),
|
||||
created_by_user: actor)
|
||||
else
|
||||
next if group_members.delete(member.fetch('tag_group_id'))
|
||||
|
||||
TagGroupGroup.create!(tag_group_id: group.id,
|
||||
child_tag_group_id: member.fetch('tag_group_id'),
|
||||
created_by_user: actor)
|
||||
end
|
||||
end
|
||||
existing = TagGroupPlacement.where(tag_group_id: group.id).index_by(&:position)
|
||||
persist_placements!(group:, nodes: attributes.fetch(:placements), actor:, existing:)
|
||||
existing.values.sort_by(&:position).reverse_each(&:delete)
|
||||
tag_members.each_value(&:delete)
|
||||
group_members.each_value(&:delete)
|
||||
end
|
||||
private_class_method :persist_structure!
|
||||
|
||||
def self.persist_placements!(group:, nodes:, actor:, existing:, parent_position: nil,
|
||||
next_position: [0])
|
||||
nodes.each do |node|
|
||||
next_position[0] += 1
|
||||
position = next_position[0]
|
||||
row = existing.delete(position) || TagGroupPlacement.new(
|
||||
tag_group_id: group.id, position:, created_by_user: actor)
|
||||
row.assign_attributes(
|
||||
parent_position:,
|
||||
section_name: node['section_name'], description: node['description'],
|
||||
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'])
|
||||
if row.new_record? || row.changed?
|
||||
row.updated_by_user = actor
|
||||
row.save!
|
||||
end
|
||||
persist_placements!(group:, nodes: node.fetch('children'), actor:, existing:,
|
||||
parent_position: position, next_position:)
|
||||
end
|
||||
end
|
||||
private_class_method :persist_placements!
|
||||
|
||||
def self.persist_version!(group:, attributes:, actor:, event_type:)
|
||||
TagGroupVersion.create!(tag_group: group, version_no: group.version_no,
|
||||
event_type:, name: group.name,
|
||||
description: group.description,
|
||||
members: attributes.fetch(:members),
|
||||
placements: attributes.fetch(:placements),
|
||||
created_by_user: actor)
|
||||
end
|
||||
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::CompatibilityWriter.create!(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)
|
||||
nodes.flat_map do |node|
|
||||
own = if node.key?('tag_id')
|
||||
[{ 'tag_id' => node.fetch('tag_id') }]
|
||||
elsif node.key?('tag_group_id')
|
||||
[{ 'tag_group_id' => node.fetch('tag_group_id') }]
|
||||
else
|
||||
[]
|
||||
end
|
||||
own + placement_references(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
private_class_method :placement_references
|
||||
|
||||
def self.stringify_keys(value)
|
||||
case value
|
||||
when ActionController::Parameters
|
||||
value.to_unsafe_h.deep_stringify_keys
|
||||
when Hash
|
||||
value.deep_stringify_keys
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
private_class_method :stringify_keys
|
||||
|
||||
def self.integer_id(record, value, attribute)
|
||||
integer = if value.is_a?(Integer)
|
||||
value
|
||||
elsif value.is_a?(String) && value.match?(/\A[0-9]+\z/)
|
||||
value.to_i
|
||||
end
|
||||
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer
|
||||
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer.positive?
|
||||
integer
|
||||
end
|
||||
private_class_method :integer_id
|
||||
|
||||
def self.invalid!(record, attribute, message)
|
||||
record.errors.add(attribute, message)
|
||||
raise ActiveRecord::RecordInvalid, record
|
||||
end
|
||||
private_class_method :invalid!
|
||||
end
|
||||
@@ -8,12 +8,13 @@ module Wiki
|
||||
end
|
||||
|
||||
def self.create_content! tag_name:, body:, created_by_user:, message: nil
|
||||
if tag_name.tag && tag_name.language_code == 'ja'
|
||||
if tag_name.tag && tag_name.language_code == 'ja' && !tag_name.name.include?(':')
|
||||
return TagDescriptionOperation.create!(
|
||||
tag: tag_name.tag, body:, created_by_user:, message:)
|
||||
end
|
||||
|
||||
create_legacy_content!(tag_name:, body:, created_by_user:, message:)
|
||||
tag_name.errors.add(:base, :invalid)
|
||||
raise ActiveRecord::RecordInvalid, tag_name
|
||||
end
|
||||
|
||||
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
|
||||
@@ -42,14 +43,17 @@ module Wiki
|
||||
end
|
||||
|
||||
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
|
||||
description = TagDescription.find_by(id: page.id)
|
||||
if description
|
||||
description = TagDescription.find_by(tag_id: page.tag_name.tag_id, language_code: 'ja')
|
||||
description ||= TagDescription.joins(:tag).find_by(
|
||||
tags: { tag_name_id: page.tag_name_id }, language_code: 'ja')
|
||||
if description && !page.title.include?(':')
|
||||
return TagDescriptionOperation.update!(
|
||||
tag: description.tag, body:, updated_by_user: created_user,
|
||||
message:, base_revision_id:)
|
||||
end
|
||||
|
||||
legacy_content!(page:, body:, created_user:, message:, base_revision_id:)
|
||||
page.errors.add(:base, :invalid)
|
||||
raise ActiveRecord::RecordInvalid, page
|
||||
end
|
||||
|
||||
def self.legacy_content! page:, body:, created_user:, message: nil,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
module Wiki
|
||||
class CompatibilityWriter
|
||||
def self.create! tag_name:, body:, created_by_user:, message: nil
|
||||
Commit.create_legacy_content!(
|
||||
tag_name:, body:, created_by_user:, message:)
|
||||
ApplicationRecord.transaction do
|
||||
tag_name.lock!
|
||||
page = WikiPage.lock.find_by(tag_name_id: tag_name.id)
|
||||
if page
|
||||
update!(page:, body:, updated_by_user: created_by_user, message:)
|
||||
page
|
||||
else
|
||||
Commit.create_legacy_content!(tag_name:, body:, created_by_user:, message:)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.update! page:, body:, updated_by_user:, message: nil,
|
||||
@@ -23,7 +31,9 @@ module Wiki
|
||||
end
|
||||
end
|
||||
|
||||
return if Commit.normalise_body(page.body) == normalised
|
||||
if Commit.normalise_body(page.body) == normalised && page.wiki_revisions.exists?
|
||||
return
|
||||
end
|
||||
|
||||
Commit.legacy_content!(
|
||||
page:, body: normalised, created_user: updated_by_user, message:,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Rails.application.routes.draw do
|
||||
resources :tag_groups, only: [:index, :show, :create, :update]
|
||||
|
||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||
|
||||
scope 'tags/:parent_id/children', controller: :tag_children do
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
class CreateTagGroups < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
# 翻訳系は上流の Translation リソースが未完のため保留
|
||||
|
||||
create_table :tag_groups do |t|
|
||||
t.integer :version_no, null: false
|
||||
t.string :name, limit: 255, null: false
|
||||
t.text :description
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||
t.references :updated_by_user, foreign_key: { to_table: :users }
|
||||
t.timestamps
|
||||
|
||||
t.check_constraint 'version_no > 0', name: 'chk_tag_groups_version_no_positive'
|
||||
t.index :name, unique: true
|
||||
end
|
||||
|
||||
create_table :tag_group_tags, primary_key: [:tag_group_id, :tag_id] do |t|
|
||||
t.references :tag_group, null: false, foreign_key: true, index: false
|
||||
t.references :tag, null: false, foreign_key: true
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
|
||||
create_table :tag_group_groups, primary_key: [:tag_group_id, :child_tag_group_id] do |t|
|
||||
t.references :tag_group, null: false, foreign_key: true, index: false
|
||||
t.references :child_tag_group,
|
||||
null: false, foreign_key: { to_table: :tag_groups }
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||
t.datetime :created_at, null: false
|
||||
|
||||
t.check_constraint 'tag_group_id <> child_tag_group_id',
|
||||
name: 'chk_tag_group_groups_tag_group_id_not_child_tag_group_id'
|
||||
end
|
||||
|
||||
create_table :tag_group_placements, primary_key: [:tag_group_id, :position] do |t|
|
||||
t.references :tag_group, null: false, foreign_key: true, index: false
|
||||
t.integer :position, null: false
|
||||
t.integer :parent_position
|
||||
t.string :section_name, limit: 255
|
||||
t.text :description
|
||||
t.references :tag
|
||||
t.references :child_tag_group
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||
t.references :updated_by_user, foreign_key: { to_table: :users }
|
||||
t.timestamps
|
||||
|
||||
t.foreign_key :tag_group_placements,
|
||||
column: [:tag_group_id, :parent_position],
|
||||
primary_key: [:tag_group_id, :position]
|
||||
|
||||
t.foreign_key :tag_group_tags,
|
||||
column: [:tag_group_id, :tag_id],
|
||||
primary_key: [:tag_group_id, :tag_id]
|
||||
|
||||
t.foreign_key :tag_group_groups,
|
||||
column: [:tag_group_id, :child_tag_group_id],
|
||||
primary_key: [:tag_group_id, :child_tag_group_id]
|
||||
|
||||
t.check_constraint 'position > parent_position',
|
||||
name: 'chk_tag_group_placements_position_gt_parent_position'
|
||||
|
||||
t.check_constraint ('((section_name IS NOT NULL AND tag_id IS NULL AND child_tag_group_id IS NULL) OR ' +
|
||||
'(section_name IS NULL AND tag_id IS NOT NULL AND child_tag_group_id IS NULL) OR ' +
|
||||
'(section_name IS NULL AND tag_id IS NULL AND child_tag_group_id IS NOT NULL)) AND ' +
|
||||
'(section_name IS NOT NULL OR description IS NULL)'),
|
||||
name: 'chk_tag_group_placements_references_exclusive'
|
||||
end
|
||||
|
||||
create_table :tag_group_versions do |t|
|
||||
t.references :tag_group, null: false
|
||||
t.integer :version_no, null: false
|
||||
t.string :event_type, null: false
|
||||
t.string :name, limit: 255, null: false
|
||||
t.text :description
|
||||
t.json :members, null: false
|
||||
t.json :placements, null: false
|
||||
t.references :created_by_user
|
||||
t.datetime :created_at, null: false
|
||||
|
||||
t.check_constraint 'version_no > 0',
|
||||
name: 'chk_tag_group_versions_version_no_positive'
|
||||
t.index [:tag_group_id, :version_no], unique: true
|
||||
|
||||
add_members_constraint!(t)
|
||||
add_placements_constraint!(t)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_members_constraint! t
|
||||
schema = { type: 'array',
|
||||
uniqueItems: true,
|
||||
items: member_schema }
|
||||
|
||||
quoted_schema = connection.quote(JSON.generate(schema))
|
||||
|
||||
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, members)",
|
||||
name: 'chk_tag_group_versions_members_schema'
|
||||
end
|
||||
|
||||
def member_schema
|
||||
{ oneOf: [tag_member_schema, group_member_schema] }
|
||||
end
|
||||
|
||||
def tag_member_schema
|
||||
{ type: 'object',
|
||||
required: ['tag_id', 'version_no'],
|
||||
properties: { tag_id: { type: 'integer', minimum: 1 },
|
||||
version_no: { type: 'integer', minimum: 1 } },
|
||||
additionalProperties: false }
|
||||
end
|
||||
|
||||
def group_member_schema
|
||||
{ type: 'object',
|
||||
required: ['tag_group_id', 'version_no'],
|
||||
properties: { tag_group_id: { type: 'integer', minimum: 1 },
|
||||
version_no: { type: 'integer', minimum: 1 } },
|
||||
additionalProperties: false }
|
||||
end
|
||||
|
||||
def add_placements_constraint! t
|
||||
schema = { type: 'array', items: placement_schema(5) }
|
||||
|
||||
quoted_schema = connection.quote(JSON.generate(schema))
|
||||
|
||||
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, placements)",
|
||||
name: 'chk_tag_group_versions_placements_schema'
|
||||
end
|
||||
|
||||
def placement_schema rest
|
||||
{ type: 'object',
|
||||
required: ['children'],
|
||||
properties: { section_name: { type: 'string' },
|
||||
description: { type: ['string', 'null'] },
|
||||
tag_id: { type: 'integer', minimum: 1 },
|
||||
tag_group_id: { type: 'integer', minimum: 1 },
|
||||
children: if rest > 1
|
||||
{ type: 'array', items: placement_schema(rest - 1) }
|
||||
else
|
||||
{ type: 'array', maxItems: 0 }
|
||||
end },
|
||||
oneOf: [{ required: ['section_name'] },
|
||||
{ required: ['tag_id'] },
|
||||
{ required: ['tag_group_id'] }],
|
||||
dependencies: { description: ['section_name'] },
|
||||
additionalProperties: false }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,518 @@
|
||||
class MigrateTagGroupsFromWikiPages < ActiveRecord::Migration[8.0]
|
||||
PREFIX = 'タググループ:'
|
||||
|
||||
def up
|
||||
require 'json'
|
||||
require 'time'
|
||||
require 'kramdown'
|
||||
require 'kramdown-parser-gfm'
|
||||
|
||||
assert_empty_destination!
|
||||
|
||||
pages = connection.select_all(<<~SQL).to_a
|
||||
SELECT
|
||||
wp.id
|
||||
, wp.created_user_id
|
||||
, wp.updated_user_id
|
||||
, wp.created_at
|
||||
, wp.updated_at
|
||||
, wp.discarded_at
|
||||
, tn.name
|
||||
FROM
|
||||
wiki_pages wp
|
||||
INNER JOIN
|
||||
tag_names tn
|
||||
ON
|
||||
tn.id = wp.tag_name_id
|
||||
WHERE
|
||||
tn.language_code = 'ja'
|
||||
AND tn.name LIKE #{ quote("#{ PREFIX }%") }
|
||||
ORDER BY
|
||||
wp.id
|
||||
SQL
|
||||
|
||||
return if pages.empty?
|
||||
|
||||
pages.each do |page|
|
||||
if page['discarded_at']
|
||||
say "Wiki ##{ page.fetch('id') }: 破棄済みですが,Revision の移行を継続します."
|
||||
end
|
||||
end
|
||||
|
||||
tag_ids = load_tag_ids
|
||||
page_ids = pages.map { |page| page.fetch('id').to_i }
|
||||
|
||||
revisions = connection.select_all(<<~SQL).to_a
|
||||
SELECT
|
||||
wr.id
|
||||
, wr.wiki_page_id
|
||||
, wr.kind
|
||||
, wr.redirect_page_id
|
||||
, wr.created_user_id
|
||||
, wr.created_at
|
||||
FROM
|
||||
wiki_revisions wr
|
||||
WHERE
|
||||
wr.wiki_page_id IN (#{ page_ids.join(', ') })
|
||||
ORDER BY
|
||||
wr.wiki_page_id
|
||||
, wr.id
|
||||
SQL
|
||||
|
||||
revisions_by_page = revisions.group_by { |row| row.fetch('wiki_page_id').to_i }
|
||||
|
||||
pages = pages.select do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
if revisions_by_page.fetch(page_id, []).empty?
|
||||
# tag_groups.version_no > 0 のため,Revision 0 件の Wiki は作成しない.
|
||||
say "Wiki ##{ page_id }: Revision が 0 件のため移行対象から除外します."
|
||||
false
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
return if pages.empty?
|
||||
|
||||
group_ids = pages.to_h { |page| [page.fetch('name'), page.fetch('id').to_i] }
|
||||
revision_history = pages.to_h do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
history = revisions_by_page.fetch(page_id).each_with_index.map do |revision, index|
|
||||
{ 'id' => revision.fetch('id').to_i,
|
||||
'version_no' => index + 1,
|
||||
'created_at' => revision.fetch('created_at') }
|
||||
end
|
||||
[page_id, history]
|
||||
end
|
||||
|
||||
tag_history = connection.select_all(<<~SQL).to_a.group_by { |row| row.fetch('tag_id').to_i }
|
||||
SELECT
|
||||
tag_id
|
||||
, version_no
|
||||
, created_at
|
||||
FROM
|
||||
tag_versions
|
||||
ORDER BY
|
||||
tag_id
|
||||
, created_at
|
||||
, version_no
|
||||
SQL
|
||||
|
||||
prepared = { }
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
history = revisions_by_page.fetch(page_id)
|
||||
|
||||
prepared[page_id] = history.each_with_index.map do |revision, index|
|
||||
context = "Wiki ##{ page_id } / Revision ##{ revision.fetch('id') }"
|
||||
body = revision_body(revision.fetch('id').to_i)
|
||||
|
||||
if revision.fetch('kind').to_i == 1
|
||||
destination = revision['redirect_page_id']
|
||||
description = "旧 Wiki のリダイレクト先: Wiki ##{ destination }"
|
||||
placements = []
|
||||
members = []
|
||||
say "#{ context }: リダイレクトを説明文として移行します."
|
||||
else
|
||||
begin
|
||||
description, placements = parse_wiki(body, tag_ids, group_ids, context)
|
||||
validate_tree!(placements, context)
|
||||
members = build_members(placements, revision.fetch('created_at'),
|
||||
tag_history, revision_history, context)
|
||||
rescue StandardError => error
|
||||
# 解析できない版も捨てず,当時の Markdown を説明文として保存する.
|
||||
say "#{ context }: 構造化を保留し,本文を保存します (#{ error.message })."
|
||||
description = body
|
||||
placements = []
|
||||
members = []
|
||||
end
|
||||
end
|
||||
|
||||
{ revision:, version_no: index + 1, description:, members:, placements:, body: }
|
||||
end
|
||||
end
|
||||
|
||||
# 自己包含は禁止されてゐるため,当該版は原文の保存を優先する.
|
||||
prepared.each do |group_id, versions|
|
||||
current = versions.last
|
||||
next unless current.fetch(:members).any? { |member| member['tag_group_id'] == group_id }
|
||||
|
||||
say "Tag Group ##{ group_id }: 直接の自己参照を検出したため,現行構造化を保留します."
|
||||
current[:description] = current.fetch(:body)
|
||||
current[:members] = []
|
||||
current[:placements] = []
|
||||
end
|
||||
|
||||
begin
|
||||
validate_current_groups!(prepared)
|
||||
rescue StandardError => error
|
||||
say "Tag Group の包含関係に問題がありますが,移行を継続します (#{ error.message })."
|
||||
end
|
||||
|
||||
# 移行先の書込みはすべて検証の後.DDL は含めない.
|
||||
connection.transaction do
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
current = prepared.fetch(page_id).last
|
||||
|
||||
insert_row('tag_groups', {
|
||||
id: page_id,
|
||||
# page.version_no ではなく,この Wiki に紐付く Revision の総数.
|
||||
version_no: revisions_by_page.fetch(page_id).size,
|
||||
name: page.fetch('name').delete_prefix(PREFIX),
|
||||
description: current.fetch(:description),
|
||||
created_by_user_id: page.fetch('created_user_id'),
|
||||
updated_by_user_id: page.fetch('updated_user_id'),
|
||||
created_at: page.fetch('created_at'),
|
||||
updated_at: page.fetch('updated_at') })
|
||||
end
|
||||
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
name = page.fetch('name').delete_prefix(PREFIX)
|
||||
|
||||
prepared.fetch(page_id).each do |item|
|
||||
revision = item.fetch(:revision)
|
||||
|
||||
insert_row('tag_group_versions', {
|
||||
id: revision.fetch('id'),
|
||||
tag_group_id: page_id,
|
||||
version_no: item.fetch(:version_no),
|
||||
event_type: item.fetch(:version_no) == 1 ? 'create' : 'update',
|
||||
name:,
|
||||
description: item.fetch(:description),
|
||||
members: JSON.generate(item.fetch(:members)),
|
||||
placements: JSON.generate(item.fetch(:placements)),
|
||||
created_by_user_id: revision.fetch('created_user_id'),
|
||||
created_at: revision.fetch('created_at') })
|
||||
end
|
||||
end
|
||||
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
current = prepared.fetch(page_id).last
|
||||
revision = current.fetch(:revision)
|
||||
|
||||
current.fetch(:members).each do |member|
|
||||
if member.key?('tag_id')
|
||||
insert_row('tag_group_tags', {
|
||||
tag_group_id: page_id,
|
||||
tag_id: member.fetch('tag_id'),
|
||||
created_by_user_id: revision.fetch('created_user_id'),
|
||||
created_at: revision.fetch('created_at') })
|
||||
else
|
||||
insert_row('tag_group_groups', {
|
||||
tag_group_id: page_id,
|
||||
child_tag_group_id: member.fetch('tag_group_id'),
|
||||
created_by_user_id: revision.fetch('created_user_id'),
|
||||
created_at: revision.fetch('created_at') })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
current = prepared.fetch(page_id).last
|
||||
revision = current.fetch(:revision)
|
||||
position = 0
|
||||
|
||||
append = -> nodes, parent_position do
|
||||
nodes.each do |node|
|
||||
position += 1
|
||||
own_position = position
|
||||
|
||||
insert_row('tag_group_placements', {
|
||||
tag_group_id: page_id,
|
||||
position: own_position,
|
||||
parent_position:,
|
||||
section_name: node['section_name'],
|
||||
description: node['description'],
|
||||
tag_id: node['tag_id'],
|
||||
child_tag_group_id: node['tag_group_id'],
|
||||
created_by_user_id: revision.fetch('created_user_id'),
|
||||
updated_by_user_id: revision.fetch('created_user_id'),
|
||||
created_at: revision.fetch('created_at'),
|
||||
updated_at: revision.fetch('created_at') })
|
||||
|
||||
append.(node.fetch('children'), own_position)
|
||||
end
|
||||
end
|
||||
|
||||
append.(current.fetch(:placements), nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_empty_destination!
|
||||
['tag_groups', 'tag_group_versions', 'tag_group_tags',
|
||||
'tag_group_groups', 'tag_group_placements'].each do |table|
|
||||
count = connection.select_value("SELECT COUNT(*) FROM #{ table }").to_i
|
||||
raise "#{ table } が空ではありません." if count.positive?
|
||||
end
|
||||
end
|
||||
|
||||
# 解決は tag_names.name -> tag_names.tag_id のみ.
|
||||
# tags.tag_name_id は読まない.
|
||||
def load_tag_ids
|
||||
rows = connection.select_all(<<~SQL).to_a
|
||||
SELECT
|
||||
name
|
||||
, tag_id
|
||||
FROM
|
||||
tag_names
|
||||
WHERE
|
||||
language_code = 'ja'
|
||||
AND tag_id IS NOT NULL
|
||||
SQL
|
||||
|
||||
rows.each_with_object({ }) do |row, by_name|
|
||||
name = row.fetch('name')
|
||||
id = row.fetch('tag_id').to_i
|
||||
by_name[name] ||= []
|
||||
by_name[name] << id unless by_name[name].include?(id)
|
||||
end
|
||||
end
|
||||
|
||||
def revision_body revision_id
|
||||
connection.select_rows(<<~SQL).flatten.join("\n")
|
||||
SELECT
|
||||
wl.body
|
||||
FROM
|
||||
wiki_revision_lines wrl
|
||||
INNER JOIN wiki_lines wl ON wl.id = wrl.wiki_line_id
|
||||
WHERE
|
||||
wrl.wiki_revision_id = #{ Integer(revision_id) }
|
||||
ORDER BY
|
||||
wrl.position
|
||||
SQL
|
||||
end
|
||||
|
||||
def parse_wiki markdown, tag_ids, group_ids, context
|
||||
root = Kramdown::Document.new(markdown, input: 'GFM').root
|
||||
placements = []
|
||||
description_parts = []
|
||||
headings = []
|
||||
|
||||
root.children.each_with_index do |node, index|
|
||||
case node.type
|
||||
when :blank
|
||||
next
|
||||
when :header
|
||||
level = node.options.fetch(:level)
|
||||
name = inline_text(node).strip
|
||||
raise "#{ context }: 名前のない見出しがあります." if name.empty?
|
||||
|
||||
headings.pop while headings.any? && headings.last.first >= level
|
||||
parent = headings.last&.last
|
||||
section = { 'section_name' => name, 'description' => nil, 'children' => [] }
|
||||
(parent ? parent.fetch('children') : placements) << section
|
||||
headings << [level, section]
|
||||
when :p
|
||||
text = inline_markdown(node).strip
|
||||
next if text.empty?
|
||||
|
||||
if headings.any?
|
||||
heading = headings.last.last
|
||||
heading['description'] = [heading['description'], text].compact.join("\n\n")
|
||||
else
|
||||
description_parts << text
|
||||
end
|
||||
when :ul, :ol
|
||||
if headings.any? && see_also_heading?(headings.last.last.fetch('section_name'))
|
||||
heading = headings.last.last
|
||||
raw = original_block(markdown, root.children, index)
|
||||
heading['description'] = [heading['description'], raw].compact.join("\n\n")
|
||||
else
|
||||
target = headings.any? ? headings.last.last.fetch('children') : placements
|
||||
target.concat(node.children.map { |item| parse_list_item(item, tag_ids, group_ids, context) })
|
||||
end
|
||||
else
|
||||
raise "#{ context }: 未対応の Markdown 構文 #{ node.type.inspect }"
|
||||
end
|
||||
end
|
||||
|
||||
[description_parts.empty? ? nil : description_parts.join("\n\n"), placements]
|
||||
end
|
||||
|
||||
def see_also_heading? name
|
||||
['see also', '関連項目'].include?(name.strip.downcase.sub(/[::]\z/, ''))
|
||||
end
|
||||
|
||||
def original_block markdown, siblings, index
|
||||
current = siblings.fetch(index)
|
||||
start_line = current.options[:location]
|
||||
raise 'Markdown block の位置が不明です.' unless start_line
|
||||
|
||||
following = siblings[(index + 1)..].find do |node|
|
||||
node.options[:location] && node.options[:location] > start_line
|
||||
end
|
||||
end_line = following ? following.options.fetch(:location) - 1 : markdown.lines.length
|
||||
markdown.lines[(start_line - 1)...end_line].join.strip
|
||||
end
|
||||
|
||||
def parse_list_item item, tag_ids, group_ids, context
|
||||
raise "#{ context }: リスト項目ではありません." if item.type != :li
|
||||
|
||||
nested, own = item.children.partition { |child| [:ul, :ol].include?(child.type) }
|
||||
own.reject! { |child| child.type == :blank }
|
||||
unless own.all? { |child| [:p, :text, :a, :codespan].include?(child.type) }
|
||||
raise "#{ context }: 未対応のリスト項目があります."
|
||||
end
|
||||
|
||||
label = own.map { |child| inline_text(child) }.join(' ').strip
|
||||
raise "#{ context }: 空のリスト項目があります." if label.empty?
|
||||
|
||||
node =
|
||||
if group_ids.key?(label)
|
||||
{ 'tag_group_id' => group_ids.fetch(label) }
|
||||
elsif tag_ids.key?(label)
|
||||
ids = tag_ids.fetch(label)
|
||||
unless ids.one?
|
||||
raise "#{ context }: タグ名 #{ label.inspect } が複数の ID を指します:#{ ids.inspect }"
|
||||
end
|
||||
{ 'tag_id' => ids.first }
|
||||
elsif label.end_with?(':', ':')
|
||||
name = label.sub(/[::]\z/, '')
|
||||
raise "#{ context }: 空の区分名があります." if name.empty?
|
||||
if see_also_heading?(name) && nested.any?
|
||||
raise "#{ context }: リスト内の See also は説明文への保管処理が未実装です."
|
||||
end
|
||||
{ 'section_name' => name, 'description' => nil }
|
||||
else
|
||||
raise "#{ context }: 解決不能なリスト項目 #{ label.inspect }"
|
||||
end
|
||||
|
||||
node['children'] = nested.flat_map do |list|
|
||||
list.children.map { |child| parse_list_item(child, tag_ids, group_ids, context) }
|
||||
end
|
||||
|
||||
node
|
||||
end
|
||||
|
||||
def inline_text node
|
||||
case node.type
|
||||
when :text, :codespan
|
||||
node.value.to_s
|
||||
when :softbreak, :br
|
||||
' '
|
||||
when :a, :p, :header, :em, :strong, :span, :li
|
||||
node.children.map { |child| inline_text(child) }.join
|
||||
else
|
||||
raise "未対応のインライン構文 #{ node.type.inspect }"
|
||||
end
|
||||
end
|
||||
|
||||
def inline_markdown node
|
||||
case node.type
|
||||
when :text
|
||||
node.value.to_s
|
||||
when :codespan
|
||||
"`#{ node.value }`"
|
||||
when :softbreak
|
||||
"\n"
|
||||
when :br
|
||||
" \n"
|
||||
when :p
|
||||
node.children.map { |child| inline_markdown(child) }.join
|
||||
when :a
|
||||
href = node.attr.fetch('href')
|
||||
"[#{ node.children.map { |child| inline_markdown(child) }.join }](#{ href })"
|
||||
when :em
|
||||
"*#{ node.children.map { |child| inline_markdown(child) }.join }*"
|
||||
when :strong
|
||||
"**#{ node.children.map { |child| inline_markdown(child) }.join }**"
|
||||
else
|
||||
raise "未対応のインライン構文 #{ node.type.inspect }"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_tree! nodes, context, depth = 1
|
||||
raise "#{ context }: 配置の深さが 5 を超えてゐます." if depth > 5 && nodes.any?
|
||||
|
||||
nodes.each do |node|
|
||||
name = node['section_name']
|
||||
if name && name.length > 255
|
||||
raise "#{ context }: 区分名が 255 文字を超えてゐます."
|
||||
end
|
||||
validate_tree!(node.fetch('children'), context, depth + 1)
|
||||
end
|
||||
end
|
||||
|
||||
def build_members nodes, date, tag_history, group_history, context
|
||||
ids_of_tags = []
|
||||
ids_of_groups = []
|
||||
|
||||
visit = -> items do
|
||||
items.each do |node|
|
||||
ids_of_tags << node['tag_id'] if node.key?('tag_id')
|
||||
ids_of_groups << node['tag_group_id'] if node.key?('tag_group_id')
|
||||
visit.(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
visit.(nodes)
|
||||
|
||||
tags = ids_of_tags.uniq.map do |id|
|
||||
{ 'tag_id' => id,
|
||||
'version_no' => version_at(tag_history[id], date, context, "Tag ##{ id }") }
|
||||
end
|
||||
|
||||
groups = ids_of_groups.uniq.map do |id|
|
||||
{ 'tag_group_id' => id,
|
||||
'version_no' => version_at(group_history[id], date, context, "TagGroup ##{ id }") }
|
||||
end
|
||||
|
||||
tags + groups
|
||||
end
|
||||
|
||||
def version_at history, date, context, target
|
||||
raise "#{ context }: #{ target } の履歴が存在しません." unless history&.any?
|
||||
|
||||
timestamp = Time.parse(date.to_s)
|
||||
candidates = history.select do |row|
|
||||
Time.parse(row.fetch('created_at').to_s) <= timestamp
|
||||
end
|
||||
if candidates.empty?
|
||||
raise "#{ context }: #{ target } は #{ date } 時点で存在しません."
|
||||
end
|
||||
|
||||
candidates.max_by { |row|
|
||||
[Time.parse(row.fetch('created_at').to_s), row.fetch('version_no').to_i]
|
||||
}.fetch('version_no').to_i
|
||||
end
|
||||
|
||||
def validate_current_groups! prepared
|
||||
edges = { }
|
||||
prepared.each do |group_id, versions|
|
||||
edges[group_id] = versions.last.fetch(:members)
|
||||
.filter_map { |member| member['tag_group_id'] }
|
||||
end
|
||||
|
||||
visiting = { }
|
||||
visited = { }
|
||||
visit = -> group_id do
|
||||
raise "Tag Group の循環参照を検出しました:#{ group_id }" if visiting[group_id]
|
||||
return if visited[group_id]
|
||||
|
||||
visiting[group_id] = true
|
||||
edges.fetch(group_id, []).each { |child_id| visit.(child_id) }
|
||||
visiting.delete(group_id)
|
||||
visited[group_id] = true
|
||||
end
|
||||
|
||||
edges.keys.each { |group_id| visit.(group_id) }
|
||||
end
|
||||
|
||||
def quote(value) = connection.quote(value)
|
||||
|
||||
def insert_row table, values
|
||||
columns = values.keys.join(', ')
|
||||
payload = values.values.map { |value| quote(value) }.join(', ')
|
||||
execute "INSERT INTO #{ table } (#{ columns }) VALUES (#{ payload })"
|
||||
end
|
||||
end
|
||||
生成ファイル
+89
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) 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|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -483,6 +483,80 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "tag_group_groups", primary_key: ["tag_group_id", "child_tag_group_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_group_id", null: false
|
||||
t.bigint "child_tag_group_id", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["child_tag_group_id"], name: "index_tag_group_groups_on_child_tag_group_id"
|
||||
t.index ["created_by_user_id"], name: "index_tag_group_groups_on_created_by_user_id"
|
||||
t.check_constraint "`tag_group_id` <> `child_tag_group_id`", name: "chk_tag_group_groups_tag_group_id_not_child_tag_group_id"
|
||||
end
|
||||
|
||||
create_table "tag_group_placements", primary_key: ["tag_group_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_group_id", null: false
|
||||
t.integer "position", null: false
|
||||
t.integer "parent_position"
|
||||
t.string "section_name"
|
||||
t.text "description"
|
||||
t.bigint "tag_id"
|
||||
t.bigint "child_tag_group_id"
|
||||
t.bigint "created_by_user_id"
|
||||
t.bigint "updated_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["child_tag_group_id"], name: "index_tag_group_placements_on_child_tag_group_id"
|
||||
t.index ["created_by_user_id"], name: "index_tag_group_placements_on_created_by_user_id"
|
||||
t.index ["tag_group_id", "child_tag_group_id"], name: "fk_rails_7342b54ced"
|
||||
t.index ["tag_group_id", "parent_position"], name: "fk_rails_3af6ee71ca"
|
||||
t.index ["tag_group_id", "tag_id"], name: "fk_rails_365dc60759"
|
||||
t.index ["tag_id"], name: "index_tag_group_placements_on_tag_id"
|
||||
t.index ["updated_by_user_id"], name: "index_tag_group_placements_on_updated_by_user_id"
|
||||
t.check_constraint "(((`section_name` is not null) and (`tag_id` is null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is not null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is null) and (`child_tag_group_id` is not null))) and ((`section_name` is not null) or (`description` is null))", name: "chk_tag_group_placements_references_exclusive"
|
||||
t.check_constraint "`position` > `parent_position`", name: "chk_tag_group_placements_position_gt_parent_position"
|
||||
end
|
||||
|
||||
create_table "tag_group_tags", primary_key: ["tag_group_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_group_id", null: false
|
||||
t.bigint "tag_id", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["created_by_user_id"], name: "index_tag_group_tags_on_created_by_user_id"
|
||||
t.index ["tag_id"], name: "index_tag_group_tags_on_tag_id"
|
||||
end
|
||||
|
||||
create_table "tag_group_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_group_id", null: false
|
||||
t.integer "version_no", null: false
|
||||
t.string "event_type", null: false
|
||||
t.string "name", null: false
|
||||
t.text "description"
|
||||
t.json "members", null: false
|
||||
t.json "placements", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id"
|
||||
t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true
|
||||
t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive"
|
||||
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"maxItems\":0}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}',`placements`)", name: "chk_tag_group_versions_placements_schema"
|
||||
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"uniqueItems\":true,\"items\":{\"oneOf\":[{\"type\":\"object\",\"required\":[\"tag_id\",\"version_no\"],\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false},{\"type\":\"object\",\"required\":[\"tag_group_id\",\"version_no\"],\"properties\":{\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false}]}}',`members`)", name: "chk_tag_group_versions_members_schema"
|
||||
end
|
||||
|
||||
create_table "tag_groups", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.integer "version_no", null: false
|
||||
t.string "name", null: false
|
||||
t.text "description"
|
||||
t.bigint "created_by_user_id"
|
||||
t.bigint "updated_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["created_by_user_id"], name: "index_tag_groups_on_created_by_user_id"
|
||||
t.index ["name"], name: "index_tag_groups_on_name", unique: true
|
||||
t.index ["updated_by_user_id"], name: "index_tag_groups_on_updated_by_user_id"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tag_groups_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_id", null: false
|
||||
t.bigint "parent_tag_id", null: false
|
||||
@@ -823,6 +897,20 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
add_foreign_key "tag_descriptions", "tags"
|
||||
add_foreign_key "tag_descriptions", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_descriptions", "users", column: "updated_by_user_id"
|
||||
add_foreign_key "tag_group_groups", "tag_groups"
|
||||
add_foreign_key "tag_group_groups", "tag_groups", column: "child_tag_group_id"
|
||||
add_foreign_key "tag_group_groups", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_group_placements", "tag_group_groups", column: ["tag_group_id", "child_tag_group_id"], primary_key: ["tag_group_id", "child_tag_group_id"]
|
||||
add_foreign_key "tag_group_placements", "tag_group_placements", column: ["tag_group_id", "parent_position"], primary_key: ["tag_group_id", "position"]
|
||||
add_foreign_key "tag_group_placements", "tag_group_tags", column: ["tag_group_id", "tag_id"], primary_key: ["tag_group_id", "tag_id"]
|
||||
add_foreign_key "tag_group_placements", "tag_groups"
|
||||
add_foreign_key "tag_group_placements", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_group_placements", "users", column: "updated_by_user_id"
|
||||
add_foreign_key "tag_group_tags", "tag_groups"
|
||||
add_foreign_key "tag_group_tags", "tags"
|
||||
add_foreign_key "tag_group_tags", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_groups", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_groups", "users", column: "updated_by_user_id"
|
||||
add_foreign_key "tag_implications", "tags"
|
||||
add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
|
||||
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace :help do
|
||||
desc 'ヘルプ移行'
|
||||
task migrate: :environment do
|
||||
help_path = ENV.fetch('BTRC_HUB_HELP_PATH')
|
||||
abort 'ヘルプ置場指定してね.' if help_path.blank?
|
||||
|
||||
# ファイルがすでに存在する場合は通さなぃ.
|
||||
abort 'ファイル消してね.' if Dir.glob("#{ help_path }/*").present?
|
||||
|
||||
# 現状物理削除はなぃはず
|
||||
pages =
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.where(tag_names: { tag_id: nil, language_code: 'ja' })
|
||||
.where.not("tag_names.name LIKE 'タググループ:%'")
|
||||
|
||||
puts "移行対象: #{pages.count} 件"
|
||||
abort '移行対象がありません.' if pages.empty?
|
||||
|
||||
pages.find_each do |page|
|
||||
puts page.body
|
||||
puts page.title
|
||||
|
||||
puts 'この identifier は何にする?'
|
||||
identifier = $stdin.gets.chomp
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }"
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }/editions"
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }/editions/ja"
|
||||
|
||||
last_file_name = ''
|
||||
WikiVersion.where(wiki_page_id: page.id).order(:version_no).each do |ver|
|
||||
last_file_name, last_file_name_no = last_file_name.split('.')
|
||||
|
||||
file_name = ver.created_at.strftime('%Y-%m-%d')
|
||||
if file_name == last_file_name
|
||||
file_name += ".#{ (last_file_name_no || 1).to_i + 1 }"
|
||||
end
|
||||
|
||||
body = "# #{ ver.title.sub(/^ヘルプ:/, '') }\n\n#{ ver.body }"
|
||||
File.write "#{ help_path }/#{ identifier }/editions/ja/#{ file_name }.mdx", body
|
||||
|
||||
last_file_name = file_name
|
||||
end
|
||||
|
||||
article_meta = { 'identifier' => identifier,
|
||||
'canonical_language' => 'ja',
|
||||
'languages' => { 'ja' => { 'current_edition' => last_file_name } } }
|
||||
File.write "#{ help_path }/#{ identifier }/article.yml", article_meta.to_yaml
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,11 +5,11 @@ RSpec.describe 'Tag description migration identity' do
|
||||
it 'preserves every revision ID and repeated body in an independent sequence' do
|
||||
user = create_member_user!
|
||||
tag_name = create(:tag_name, name: 'description_migration')
|
||||
page = Wiki::Commit.create_content!(
|
||||
page = Wiki::Commit.create_legacy_content!(
|
||||
tag_name:, body: 'A', created_by_user: user)
|
||||
revisions = [page.current_revision]
|
||||
['A', 'B', 'B', 'A'].each do |body|
|
||||
revisions << Wiki::Commit.content!(
|
||||
revisions << Wiki::Commit.legacy_content!(
|
||||
page:, body:, created_user: user, base_revision_id: revisions.last.id)
|
||||
end
|
||||
tag = create(:tag, primary_tag_name: tag_name)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group persistence constraints' do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('制約確認') }
|
||||
let!(:group_id) do
|
||||
seed_group(
|
||||
name: '制約確認',
|
||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }])
|
||||
end
|
||||
|
||||
def placement attributes
|
||||
insert_group_row('tag_group_placements', {
|
||||
tag_group_id: group_id, position: 1,
|
||||
created_at: Time.current, updated_at: Time.current }.merge(attributes))
|
||||
end
|
||||
|
||||
def reject_statement
|
||||
before_state = group_state
|
||||
expect { yield }.to raise_error(ActiveRecord::StatementInvalid)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'allows membership without placement' do
|
||||
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
|
||||
expect(group_rows('tag_group_placements', tag_group_id: group_id)).to be_empty
|
||||
end
|
||||
|
||||
it 'allows repeated Tag placements with unique membership' do
|
||||
placement(tag_id: tag.id)
|
||||
placement(tag_id: tag.id, position: 2)
|
||||
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
|
||||
expect(group_rows('tag_group_placements', tag_group_id: group_id).size).to eq(2)
|
||||
end
|
||||
|
||||
it 'rejects duplicate Tag membership' do
|
||||
reject_statement do
|
||||
insert_group_row('tag_group_tags', {
|
||||
tag_group_id: group_id, tag_id: tag.id, created_at: Time.current })
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects duplicate child membership' do
|
||||
child_id = seed_group(name: '子')
|
||||
attributes = { tag_group_id: group_id, child_tag_group_id: child_id,
|
||||
created_at: Time.current }
|
||||
insert_group_row('tag_group_groups', attributes)
|
||||
reject_statement { insert_group_row('tag_group_groups', attributes) }
|
||||
end
|
||||
|
||||
it 'rejects direct self membership' do
|
||||
reject_statement do
|
||||
insert_group_row('tag_group_groups', {
|
||||
tag_group_id: group_id, child_tag_group_id: group_id, created_at: Time.current })
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a placement without a kind' do
|
||||
reject_statement { placement({ }) }
|
||||
end
|
||||
|
||||
it 'rejects simultaneous Section and Tag kinds' do
|
||||
reject_statement { placement(section_name: '区分', tag_id: tag.id) }
|
||||
end
|
||||
|
||||
it 'rejects a description attached to a Tag placement' do
|
||||
reject_statement { placement(tag_id: tag.id, description: '不正') }
|
||||
end
|
||||
|
||||
it 'allows an optional description on a Section' do
|
||||
placement(section_name: '区分', description: '説明')
|
||||
expect(group_rows('tag_group_placements', tag_group_id: group_id).sole)
|
||||
.to include('section_name' => '区分', 'description' => '説明')
|
||||
end
|
||||
|
||||
it 'rejects a Tag placement absent from membership' do
|
||||
other = group_tag('非メンバー')
|
||||
reject_statement { placement(tag_id: other.id) }
|
||||
end
|
||||
|
||||
it 'rejects a missing parent placement' do
|
||||
reject_statement { placement(section_name: '子', parent_position: 9) }
|
||||
end
|
||||
|
||||
it 'rejects a child ordered before its parent' do
|
||||
placement(section_name: '親', position: 2)
|
||||
reject_statement { placement(section_name: '子', position: 1, parent_position: 2) }
|
||||
end
|
||||
|
||||
it 'rejects duplicate group names' do
|
||||
reject_statement { seed_group(name: '制約確認') }
|
||||
end
|
||||
|
||||
it 'rejects a missing mandatory group name' do
|
||||
reject_statement { seed_group(name: nil) }
|
||||
end
|
||||
|
||||
it 'rejects a six-level snapshot' do
|
||||
nodes = []
|
||||
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
|
||||
reject_statement do
|
||||
insert_group_row('tag_group_versions', {
|
||||
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
|
||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
|
||||
placements: nodes.to_json, created_at: Time.current })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group migration identity and structure' do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
it 'preserves page and revision IDs, timestamps and repeated historical structures' do
|
||||
tag = group_tag('洗澡鹿')
|
||||
body = "説明文\n\n- 洗澡鹿\n- 洗澡鹿"
|
||||
page = legacy_group('伊地知ニジカ亜種', body)
|
||||
revisions = [page.current_revision]
|
||||
[body, '# 空の区分', body].each do |text|
|
||||
revisions << Wiki::Commit.legacy_content!(page:, body: text, created_user: user)
|
||||
end
|
||||
before_state = group_state.except(*TagGroupRecords::GROUP_TABLES)
|
||||
|
||||
migrate_groups
|
||||
|
||||
versions = group_rows('tag_group_versions', tag_group_id: page.id)
|
||||
.sort_by { _1.fetch('version_no') }
|
||||
expect(versions.map { _1.fetch('id') }).to eq(revisions.map(&:id))
|
||||
expect(versions.map { _1.fetch('version_no') }).to eq([1, 2, 3, 4])
|
||||
expect(versions.map { _1.fetch('created_at') }).to eq(revisions.map(&:created_at))
|
||||
expect(versions.map { _1.fetch('created_by_user_id') }).to eq([user.id] * 4)
|
||||
expect(versions.map { _1.fetch('event_type') }).to eq(['create', 'update', 'update', 'update'])
|
||||
expect(group_version(page.id, 1).fetch('placements')).to eq(
|
||||
group_version(page.id, 2).fetch('placements'))
|
||||
expect(group_version(page.id, 3).fetch('placements')).to eq([
|
||||
{ 'section_name' => '空の区分', 'description' => nil, 'children' => [] }])
|
||||
expect_group_snapshot(
|
||||
page.id, number: 4, description: '説明文',
|
||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }],
|
||||
placements: [{ 'tag_id' => tag.id, 'children' => [] }] * 2)
|
||||
expect(group_rows('tag_groups', id: page.id).sole).to include(
|
||||
'created_at' => page.created_at, 'updated_at' => page.reload.updated_at)
|
||||
expect(group_state.except(*TagGroupRecords::GROUP_TABLES)).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'migrates nested list Sections and child groups as different placement kinds' do
|
||||
series = group_tag('ぼざろクリーチャーシリーズ')
|
||||
deer = group_tag('洗澡鹿')
|
||||
nijika = group_tag('伊地知ニジカ')
|
||||
child = legacy_group('伊地知ニジカ亜種', <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ亜種:
|
||||
- 洗澡鹿
|
||||
MARKDOWN
|
||||
parent = legacy_group('ぼざろクリーチャーシリーズのキャラクター', <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ
|
||||
- タググループ:伊地知ニジカ亜種
|
||||
MARKDOWN
|
||||
|
||||
migrate_groups
|
||||
|
||||
expect_group_snapshot(
|
||||
child.id, number: 1,
|
||||
members: [series, deer].map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
|
||||
placements: [{ 'tag_id' => series.id, 'children' => [
|
||||
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
|
||||
'children' => [{ 'tag_id' => deer.id, 'children' => [] }] }] }])
|
||||
expect_group_snapshot(
|
||||
parent.id, number: 1,
|
||||
members: [
|
||||
{ 'tag_id' => series.id, 'version_no' => series.version_no },
|
||||
{ 'tag_id' => nijika.id, 'version_no' => nijika.version_no },
|
||||
{ 'tag_group_id' => child.id, 'version_no' => 1 }],
|
||||
placements: [{ 'tag_id' => series.id, 'children' => [
|
||||
{ 'tag_id' => nijika.id, 'children' => [
|
||||
{ 'tag_group_id' => child.id, 'children' => [] }] }] }])
|
||||
end
|
||||
|
||||
it 'preserves heading order, group paragraphs and Section descriptions' do
|
||||
names = ['タグ希望', 'キャラクター名希望', '楽曲情報希望', 'bot操作', 'ニジラー情報不詳']
|
||||
tags = names.map { group_tag(_1) }
|
||||
markdown = <<~MARKDOWN
|
||||
全体の説明
|
||||
|
||||
# 要求
|
||||
要求の説明
|
||||
|
||||
- タグ希望
|
||||
- キャラクター名希望
|
||||
- 楽曲情報希望
|
||||
|
||||
# 情報
|
||||
- bot操作
|
||||
- ニジラー情報不詳
|
||||
MARKDOWN
|
||||
page = legacy_group('メタタグ', markdown)
|
||||
expect(page.current_revision.body).to eq(markdown.chomp)
|
||||
|
||||
migrate_groups
|
||||
|
||||
leaves = tags.map { { 'tag_id' => _1.id, 'children' => [] } }
|
||||
leaves.first['children'] = leaves[1..2]
|
||||
expect_group_snapshot(
|
||||
page.id, number: 1, description: '全体の説明',
|
||||
members: tags.map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
|
||||
placements: [
|
||||
{ 'section_name' => '要求', 'description' => '要求の説明',
|
||||
'children' => [leaves.first] },
|
||||
{ 'section_name' => '情報', 'description' => nil,
|
||||
'children' => leaves[3..4] }])
|
||||
end
|
||||
|
||||
it 'leaves Help in the legacy domain' do
|
||||
page = Wiki::Commit.create_legacy_content!(
|
||||
tag_name: create(:tag_name, name: 'Help:移行対象外'),
|
||||
body: 'Help 本文', created_by_user: user)
|
||||
before_state = group_state
|
||||
migrate_groups
|
||||
expect(group_state).to eq(before_state)
|
||||
expect(page.reload.body).to eq('Help 本文')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
|
||||
include TagGroupRecords
|
||||
let(:user) { create_member_user! }
|
||||
|
||||
def create_resource resource
|
||||
if resource == :tag
|
||||
Tag.find_or_create_by_tag_name!(Locale.nipponese, 'shared_id_new', category: :general)
|
||||
else
|
||||
ExternalTag.create!(platform: :nico, name: 'shared_id_new')
|
||||
end
|
||||
end
|
||||
|
||||
[:tag, :external_tag].each do |resource|
|
||||
it "starts #{ resource } at 1 when both tables are empty" do
|
||||
expect(Tag.count).to eq(0)
|
||||
expect(ExternalTag.count).to eq(0)
|
||||
expect(create_resource(resource).id).to eq(1)
|
||||
end
|
||||
|
||||
it "allocates #{ resource } above the other table's maximum" do
|
||||
create(:tag, id: resource == :tag ? 1 : 20)
|
||||
create(:external_tag, id: resource == :external_tag ? 1 : 20)
|
||||
|
||||
expect(create_resource(resource).id).to eq(21)
|
||||
end
|
||||
|
||||
it "uses the current maximum after deleting earlier #{ resource } records" do
|
||||
tag = create(:tag, id: 100)
|
||||
TagName.where(tag_id: tag.id).update_all(tag_id: nil)
|
||||
tag.destroy!
|
||||
create(:external_tag, id: 200).destroy!
|
||||
expect(Tag.unscoped.count).to eq(0)
|
||||
expect(ExternalTag.unscoped.count).to eq(0)
|
||||
expect(create_resource(resource).id).to eq(1)
|
||||
end
|
||||
|
||||
it "preserves legacy overlaps and gaps when creating #{ resource }" do
|
||||
[1, 2, 5].each { create(:tag, id: _1) }
|
||||
[1, 3, 8].each { create(:external_tag, id: _1) }
|
||||
tags = Tag.order(:id).map(&:attributes)
|
||||
external_tags = ExternalTag.order(:id).map(&:attributes)
|
||||
|
||||
record = create_resource(resource)
|
||||
|
||||
aggregate_failures do
|
||||
expect(record.id).to eq(9)
|
||||
expect(Tag.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(tags)
|
||||
expect(ExternalTag.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(external_tags)
|
||||
expect(Tag.exists?(id: 3)).to be(false)
|
||||
expect(ExternalTag.exists?(id: 2)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores the description and group ID space when creating #{ resource }" do
|
||||
tag = create(:tag, id: 1)
|
||||
create(:external_tag, id: 2)
|
||||
TagDescription.create!(id: 100, tag:, language_code: 'ja', body: '', version_no: 1)
|
||||
seed_group(name: 'separate_space', id: 200)
|
||||
|
||||
expect(create_resource(resource).id).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
it 'preserves overlapping IDs during updates without rewriting the other resource' do
|
||||
tag = create(:tag, id: 5)
|
||||
external = create(:external_tag, id: 5)
|
||||
tag.update!(category: :meta)
|
||||
external.update!(name: 'updated_external')
|
||||
|
||||
expect(tag.reload.id).to eq(5)
|
||||
expect(external.reload.id).to eq(5)
|
||||
expect(tag.category).to eq('meta')
|
||||
expect(external.name).to eq('updated_external')
|
||||
expect(Tag.count).to eq(1)
|
||||
expect(ExternalTag.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'allocates distinct IDs for successive creations across both tables' do
|
||||
records = [:tag, :external_tag].map { create_resource(_1) }
|
||||
expect(records.map(&:id)).to eq([1, 2])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagRepr do
|
||||
let(:tag) { create(:tag) }
|
||||
|
||||
def description_for record
|
||||
TagDescription.create!(tag: record, language_code: 'ja', body: '', version_no: 1)
|
||||
end
|
||||
|
||||
it 'keeps has_wiki boolean for the selected language in base and inline' do
|
||||
description_for(tag)
|
||||
[:base, :inline].each do |method|
|
||||
expect(described_class.public_send(method, tag).as_json.fetch('has_wiki')).to be(true)
|
||||
expect(described_class.public_send(method, tag, language_code: 'en')
|
||||
.as_json.fetch('has_wiki')).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses the same language for a tag and its parents' do
|
||||
parent = create(:tag)
|
||||
description_for(tag)
|
||||
description_for(parent)
|
||||
TagImplication.create!(tag:, parent_tag: parent)
|
||||
['ja', 'en'].each do |language_code|
|
||||
row = described_class.base(tag, language_code:).as_json
|
||||
expected = language_code == 'ja'
|
||||
expect(row.fetch('has_wiki')).to be(expected)
|
||||
expect(row.fetch('parents').sole.fetch('has_wiki')).to be(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'preloads existence checks for many tags and their parents without Wiki reads' do
|
||||
parent = create(:tag)
|
||||
description_for(parent)
|
||||
records = Array.new(3) do
|
||||
create(:tag).tap { TagImplication.create!(tag: _1, parent_tag: parent) }
|
||||
end
|
||||
description_for(records.first)
|
||||
queries = []
|
||||
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
||||
rows = nil
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
rows = described_class.many(records).as_json
|
||||
end
|
||||
expect(rows.map { _1.fetch('has_wiki') }).to eq([true, false, false])
|
||||
expect(rows.map { _1.fetch('parents').sole.fetch('has_wiki') }).to eq([true] * 3)
|
||||
expect(queries.grep(/\bSELECT\b.*\btag_descriptions\b/im).size).to be <= 2
|
||||
expect(queries.grep(/\bwiki_(pages|revisions|versions)\b/i)).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,47 @@
|
||||
require 'rails_helper'
|
||||
|
||||
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
|
||||
['nico:reserved', 'NiCo:reserved'].each do |name|
|
||||
it "rejects the reserved prefix #{ name } for internal tags" do
|
||||
|
||||
@@ -178,6 +178,7 @@ RSpec.describe 'Materials API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /materials/:id' do
|
||||
include WikiRecords
|
||||
let!(:tag) do
|
||||
create(:tag, primary_name: 'material_show', category: :material)
|
||||
end
|
||||
@@ -205,6 +206,56 @@ RSpec.describe 'Materials API', type: :request do
|
||||
get '/materials/999999999'
|
||||
expect(response).to have_http_status(:not_found)
|
||||
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
|
||||
|
||||
describe 'POST /materials' do
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag_name) { create(:tag_name, name: 'description_contract') }
|
||||
let!(:page) do
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:, body: 'old-revision', created_by_user: user)
|
||||
end
|
||||
let!(:revisions) do
|
||||
first = page.current_revision
|
||||
second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user)
|
||||
third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user)
|
||||
second = Wiki::Commit.legacy_content!(page:, body: 'old-second', created_user: user)
|
||||
third = Wiki::Commit.legacy_content!(page:, body: 'old-third', created_user: user)
|
||||
[first, second, third]
|
||||
end
|
||||
let!(:tag) { create(:tag, primary_tag_name: tag_name) }
|
||||
@@ -242,8 +243,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
end
|
||||
|
||||
describe 'mixed changes' do
|
||||
it 'includes unmigrated history and ignores migrated legacy-only revisions' do
|
||||
legacy_page = Wiki::Commit.create_content!(
|
||||
it 'excludes unmigrated history and migrated legacy-only revisions' do
|
||||
legacy_page = seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'Help_mixed'),
|
||||
body: 'help', created_by_user: user)
|
||||
legacy_revision = legacy_page.current_revision
|
||||
@@ -256,7 +257,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
|
||||
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).to include(include(
|
||||
expect(json).not_to include(include(
|
||||
'revision_id' => legacy_revision.id,
|
||||
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag_name) { create(:tag_name, name: 'description_write') }
|
||||
let(:tag) { create(:tag, primary_tag_name: tag_name) }
|
||||
@@ -31,7 +32,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
headers: headers
|
||||
end
|
||||
|
||||
it 'creates both current records and histories with a shared revision ID' do
|
||||
it 'creates a description with matching Wiki content and histories' do
|
||||
expect { create_description }
|
||||
.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
@@ -39,17 +40,28 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
.and change(TagDescription, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1)
|
||||
expect(version).to have_attributes(
|
||||
id: page.current_revision.id, body: 'initial', version_no: 1,
|
||||
event_type: 'create', created_by_user_id: user.id)
|
||||
expect(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(version.body)
|
||||
expect(page.wiki_versions.last.body).to eq(version.body)
|
||||
expect(page.current_revision.body).to eq(description.body)
|
||||
expect(page.wiki_versions.sole.body).to eq(description.body)
|
||||
expect(json.fetch('id')).to eq(description.id)
|
||||
expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja'))
|
||||
.to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id)
|
||||
end
|
||||
|
||||
it 'reuses an existing compatibility page and synchronises its histories' do
|
||||
page = Wiki::Commit.create_legacy_content!(
|
||||
tag_name:, body: 'legacy', created_by_user: user)
|
||||
expect { create_description }
|
||||
.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
description = TagDescription.find_by!(tag_id: tag.id)
|
||||
expect(page.reload.body).to eq(description.body)
|
||||
expect(TagDescriptionVersion.find(page.current_revision.id).body).to eq(page.body)
|
||||
end
|
||||
|
||||
['missing_description', 'orphan_description'].each do |title|
|
||||
@@ -65,48 +77,19 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a namespaced legacy Wiki resource without a Tag' do
|
||||
expect do
|
||||
post '/wiki', params: { title: 'Help:description_write', 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(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.tag_name.tag).to be_nil
|
||||
expect(page.body).to eq('legacy')
|
||||
expect(page.current_revision.body).to eq('legacy')
|
||||
[false, true].each do |linked|
|
||||
it "rejects namespaced creation with Tag present: #{ linked }" do
|
||||
name = create(:tag_name, name: 'Help:frozen')
|
||||
create(:tag, primary_tag_name: name) if linked
|
||||
before_state = stored_state
|
||||
post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers
|
||||
expect(response.status).to be_between(400, 499)
|
||||
expect(stored_state).to eq(before_state)
|
||||
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
|
||||
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back both systems when #{ model.name } persistence fails" do
|
||||
tag_name
|
||||
it "rolls back new description creation when #{ model.name } persistence fails" do
|
||||
before_state = stored_state
|
||||
aggregate_failures do
|
||||
fail_save(model) { create_description }
|
||||
@@ -116,10 +99,32 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates missing compatibility data without writing an ID-colliding Help page' do
|
||||
description = TagDescription.create!(
|
||||
id: 500, tag:, language_code: 'ja', body: 'before', version_no: 1)
|
||||
help = WikiPage.create!(id: description.id,
|
||||
tag_name: create(:tag_name, name: 'Help:unrelated'),
|
||||
body: 'unrelated', created_user: user, updated_user: user)
|
||||
Wiki::Commit.legacy_content!(page: help, body: help.body, created_user: user)
|
||||
before_help = help.attributes
|
||||
expect do
|
||||
put "/wiki/#{ description.id }", params: { title: tag_name.name, body: 'after' },
|
||||
headers: headers
|
||||
end.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(description.reload).to have_attributes(id: 500, body: 'after')
|
||||
expect(help.reload.attributes).to eq(before_help)
|
||||
compatibility = WikiPage.find_by!(tag_name:)
|
||||
expect(compatibility.body).to eq(description.body)
|
||||
expect(compatibility.current_revision.body).to eq(description.body)
|
||||
end
|
||||
|
||||
describe 'PUT /wiki/:id' do
|
||||
let!(:page) do
|
||||
tag
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:, body: 'before', created_by_user: user)
|
||||
end
|
||||
let!(:first_revision) { page.current_revision }
|
||||
@@ -138,7 +143,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
expect(page.reload.version_no).to eq(1)
|
||||
expect(description.version_no).to eq(1)
|
||||
expect { update_description }
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
@@ -157,7 +163,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
tag_name.update_columns(tag_id: nil)
|
||||
|
||||
expect { update_description }
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
|
||||
@@ -177,10 +184,10 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
page_state = page.attributes
|
||||
description_state = description.attributes
|
||||
|
||||
update_description(body: 'before', title: 'Help:description_renamed')
|
||||
update_description(body: 'before', title: 'description_renamed')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(tag_name.reload.name).to eq('Help:description_renamed')
|
||||
expect(tag_name.reload.name).to eq('description_renamed')
|
||||
expect(WikiRevision.count).to eq(revision_count)
|
||||
expect(WikiVersion.count).to eq(wiki_version_count)
|
||||
expect(TagDescriptionVersion.count).to eq(description_version_count)
|
||||
|
||||
@@ -206,7 +206,13 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
context 'without an existing description' do
|
||||
before { tag }
|
||||
|
||||
it 'creates both current resources and histories with matching compatibility IDs' do
|
||||
it 'creates a description with synchronised Wiki content and histories' do
|
||||
unrelated = Wiki::Commit.create_legacy_content!(
|
||||
tag_name: create(:tag_name, name: 'Help:unrelated_native_description'),
|
||||
body: 'unrelated body', created_by_user: author)
|
||||
unrelated_state = [unrelated.attributes,
|
||||
unrelated.wiki_revisions.map(&:attributes),
|
||||
unrelated.wiki_versions.map(&:attributes)]
|
||||
expect { native_write(body: 'created natively') }
|
||||
.to change(TagDescription, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
@@ -215,7 +221,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
page = WikiPage.find(description.id)
|
||||
page = WikiPage.find_by!(tag_name: tag.tag_name('ja'))
|
||||
revision = page.current_revision
|
||||
version = TagDescriptionVersion.find(revision.id)
|
||||
expect(description).to have_attributes(
|
||||
@@ -227,6 +233,9 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body)
|
||||
expect(revision.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)
|
||||
|
||||
get "/wiki/#{ description.id }"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :request do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:series) { group_tag('ぼざろクリーチャーシリーズ') }
|
||||
let!(:deer) { group_tag('洗澡鹿') }
|
||||
let!(:nizika) { group_tag('伊地知ニジカ') }
|
||||
let!(:wanted) { group_tag('タグ希望') }
|
||||
let!(:character) { group_tag('キャラクター名希望') }
|
||||
let!(:music) { group_tag('楽曲情報希望') }
|
||||
let!(:bot) { group_tag('bot操作') }
|
||||
let!(:unknown) { group_tag('ニジラー情報不詳') }
|
||||
let!(:child_id) { seed_group(name: '伊地知ニジカ亜種', description: '子の説明') }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def tag_node tag, children = []
|
||||
{ 'tag_id' => tag.id, 'children' => children }
|
||||
end
|
||||
|
||||
def tag_member tag
|
||||
{ 'tag_id' => tag.id, 'version_no' => tag.version_no }
|
||||
end
|
||||
|
||||
def check_round_trip placements:, members:, markdown:, description: nil
|
||||
id = seed_group(name: '変換元', placements:, members:, description:)
|
||||
without_group_legacy_reads { get group_title_path('変換元') }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('id' => id)
|
||||
body = json.fetch('body')
|
||||
expect(body).not_to include("\r")
|
||||
expect(body.lines.map(&:chomp)).to all(satisfy { |line| line == line.rstrip })
|
||||
# A trailing LF or a blank line between blocks is semantically irrelevant.
|
||||
expect(body.lines.map(&:chomp).reject(&:empty?)).to eq(
|
||||
markdown.lines.map(&:chomp).reject(&:empty?))
|
||||
|
||||
post '/wiki', params: { title: 'タググループ:再解析', body: },
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
expect(response).to have_http_status(:created)
|
||||
parsed = group_rows('tag_groups', name: '再解析').sole
|
||||
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
|
||||
end
|
||||
|
||||
it 'renders a list Section under a Tag with two-space indentation' do
|
||||
check_round_trip(
|
||||
members: [series, deer].map { tag_member(_1) },
|
||||
placements: [tag_node(series, [
|
||||
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
|
||||
'children' => [tag_node(deer)] }])],
|
||||
markdown: <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ亜種:
|
||||
- 洗澡鹿
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'renders a child Tag Group distinctly from its parent Tags' do
|
||||
check_round_trip(
|
||||
members: [tag_member(series), tag_member(nizika),
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 }],
|
||||
placements: [tag_node(series, [tag_node(nizika, [
|
||||
{ 'tag_group_id' => child_id, 'children' => [] }])])],
|
||||
markdown: <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ
|
||||
- タググループ:伊地知ニジカ亜種
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'renders top-level heading Sections, descriptions and ordered nested Tags' do
|
||||
check_round_trip(
|
||||
description: '全体の説明',
|
||||
members: [wanted, character, music, bot, unknown].map { tag_member(_1) },
|
||||
placements: [
|
||||
{ 'section_name' => '要求', 'description' => '要求の説明',
|
||||
'children' => [tag_node(wanted, [tag_node(character), tag_node(music)])] },
|
||||
{ 'section_name' => '情報', 'description' => nil,
|
||||
'children' => [tag_node(bot), tag_node(unknown)] }],
|
||||
markdown: <<~MARKDOWN)
|
||||
全体の説明
|
||||
|
||||
## 要求
|
||||
要求の説明
|
||||
|
||||
- タグ希望
|
||||
- キャラクター名希望
|
||||
- 楽曲情報希望
|
||||
|
||||
## 情報
|
||||
- bot操作
|
||||
- ニジラー情報不詳
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'renders nested heading Sections and all repeated Tag placements' do
|
||||
check_round_trip(
|
||||
members: [tag_member(deer)],
|
||||
placements: [
|
||||
{ 'section_name' => '親', 'description' => '親の説明', 'children' => [
|
||||
tag_node(deer),
|
||||
{ 'section_name' => '子', 'description' => '子の説明',
|
||||
'children' => [tag_node(deer)] }] }],
|
||||
markdown: <<~MARKDOWN)
|
||||
## 親
|
||||
親の説明
|
||||
|
||||
- 洗澡鹿
|
||||
|
||||
### 子
|
||||
子の説明
|
||||
|
||||
- 洗澡鹿
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'rejects mixed top-level heading Sections and Tags without losing structure' do
|
||||
id = seed_group(
|
||||
name: '混在構造', members: [tag_member(series)],
|
||||
placements: [
|
||||
{ 'section_name' => '区分', 'description' => nil, 'children' => [] },
|
||||
tag_node(series)])
|
||||
|
||||
get group_title_path('混在構造')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
|
||||
end
|
||||
|
||||
it 'rejects membership without a placement instead of omitting it' do
|
||||
id = seed_group(name: '未配置構造', members: [tag_member(series)])
|
||||
|
||||
get group_title_path('未配置構造')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,309 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
include WikiRecords
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:old_tag) { group_tag('旧タグ') }
|
||||
let!(:new_tag) { group_tag('新タグ') }
|
||||
let!(:page) { legacy_group('読取り契約', '- 旧タグ') }
|
||||
let!(:first_revision) { page.current_revision }
|
||||
let!(:last_revision) do
|
||||
Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user)
|
||||
end
|
||||
|
||||
before do
|
||||
I18n.locale = :ja
|
||||
migrate_groups
|
||||
page.update_columns(body: 'legacy stale current')
|
||||
page.wiki_revisions.each do |revision|
|
||||
revision.wiki_lines.update_all(body: 'legacy stale revision')
|
||||
end
|
||||
page.wiki_versions.update_all(body: 'legacy stale version')
|
||||
end
|
||||
|
||||
[:id, :title].each do |lookup|
|
||||
context "with #{ lookup } lookup" do
|
||||
let(:path) { lookup == :id ? "/wiki/#{ page.id }" : group_title_path('読取り契約') }
|
||||
|
||||
it 'reads current structure without consulting migrated Wiki tables' do
|
||||
without_group_legacy_reads { get path }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => page.id, 'title' => 'タググループ:読取り契約',
|
||||
'revision_id' => last_revision.id, 'pred' => first_revision.id, 'succ' => nil)
|
||||
expect(json.fetch('body').strip).to eq('- 新タグ')
|
||||
end
|
||||
|
||||
it 'reads the old snapshot rather than current membership or placement' do
|
||||
without_group_legacy_reads { get path, params: { version: first_revision.id } }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').strip).to eq('- 旧タグ')
|
||||
expect(json).to include(
|
||||
'revision_id' => first_revision.id, 'pred' => nil, 'succ' => last_revision.id,
|
||||
'updated_at' => first_revision.created_at.iso8601(3))
|
||||
end
|
||||
|
||||
it 'checks existence without consulting migrated Wiki tables' do
|
||||
without_group_legacy_reads { get "#{ path }/exists" }
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'does not fall back to legacy history when a group version is missing' do
|
||||
group_connection.execute(
|
||||
"DELETE FROM tag_group_versions WHERE id = #{ first_revision.id }")
|
||||
without_group_legacy_reads { get path, params: { version: first_revision.id } }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
['/wiki', '/wiki/search'].each do |path|
|
||||
it "lists migrated groups once with current titles at #{ path }" do
|
||||
get path, params: { title: 'タググループ:読取り' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
rows = json.select { _1.fetch('title') == 'タググループ:読取り契約' }
|
||||
expect(rows.size).to eq(1)
|
||||
expect(rows.sole).to include('id' => page.id)
|
||||
expect(response.body).not_to include('legacy stale')
|
||||
end
|
||||
end
|
||||
|
||||
it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do
|
||||
help = seed_wiki_page(
|
||||
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)
|
||||
get '/wiki'
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.fetch('title') }).to include(
|
||||
'タググループ:読取り契約', '新タグ')
|
||||
expect(json.map { _1.fetch('title') }).not_to include(help.title)
|
||||
end
|
||||
|
||||
it 'excludes Help when its ID collides with a new group' do
|
||||
help = seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
|
||||
created_by_user: user)
|
||||
seed_group(name: '別 ID重複', id: help.id, description: 'group body')
|
||||
|
||||
get '/wiki'
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複')
|
||||
expect(json.map { _1.fetch('title') }).not_to include(help.title)
|
||||
|
||||
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
|
||||
expect(response).to have_http_status(:not_found)
|
||||
|
||||
get '/wiki/changes'
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.dig('wiki_page', 'title') }).to include(
|
||||
'タググループ:別 ID重複')
|
||||
expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title)
|
||||
end
|
||||
|
||||
it 'finds groups by a partial prefix of their displayed title' do
|
||||
get '/wiki', params: { title: 'タググル' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.fetch('title') }).to include('タググループ:読取り契約')
|
||||
expect(json.find { _1.fetch('title') == 'タググループ:読取り契約' })
|
||||
.to include('updated_at' => be_present)
|
||||
end
|
||||
|
||||
it 'does not restore a stale namespaced Wiki page after a group rename' do
|
||||
old_page = legacy_group('旧名称', '- 旧本文')
|
||||
seed_group(name: '新名称', id: old_page.id, description: '正本')
|
||||
|
||||
get group_title_path('旧名称')
|
||||
expect(response).to have_http_status(:not_found)
|
||||
get "#{ group_title_path('旧名称') }/exists"
|
||||
expect(response).to have_http_status(:not_found)
|
||||
get group_title_path('新名称')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').strip).to eq('正本')
|
||||
end
|
||||
|
||||
it 'orders mixed resource history by timestamp rather than independent IDs' do
|
||||
help = seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
|
||||
created_by_user: user)
|
||||
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
|
||||
TagGroupVersion.update_all(created_at: 3.days.ago)
|
||||
WikiRevision.where(wiki_page_id: help.id).update_all(created_at: 2.days.ago)
|
||||
TagDescriptionVersion.update_all(created_at: 1.day.ago)
|
||||
|
||||
get '/wiki/changes'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
|
||||
expect(timestamps).to eq(timestamps.sort.reverse)
|
||||
titles = json.map { _1.dig('wiki_page', 'title') }
|
||||
expect(titles).not_to include(help.title)
|
||||
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
|
||||
end
|
||||
|
||||
it 'renders nullable group editors without raising' do
|
||||
group_connection.execute(
|
||||
"UPDATE tag_groups SET created_by_user_id = NULL, updated_by_user_id = NULL " +
|
||||
"WHERE id = #{ page.id }")
|
||||
group_connection.execute(
|
||||
"UPDATE tag_group_versions SET created_by_user_id = NULL " +
|
||||
"WHERE tag_group_id = #{ page.id }")
|
||||
|
||||
get "/tag_groups/#{ page.id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('created_by_user' => nil, 'updated_by_user' => nil)
|
||||
get "/wiki/title/#{ CGI.escapeURIComponent('タググループ:読取り契約') }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'reads group version IDs, editor and timestamps in scoped changes' do
|
||||
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
|
||||
expect(json.first).to include(
|
||||
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
|
||||
'timestamp' => last_revision.created_at.iso8601(3),
|
||||
'user' => { 'id' => user.id, 'name' => user.name },
|
||||
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
|
||||
end
|
||||
|
||||
it 'includes group history but excludes all legacy-only revisions' do
|
||||
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
|
||||
help = seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
|
||||
get '/wiki/changes'
|
||||
expect(response).to have_http_status(:ok)
|
||||
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') }).not_to include(extra.id)
|
||||
expect(json).not_to include(include(
|
||||
'revision_id' => help.current_revision.id,
|
||||
'wiki_page' => include('title' => help.title)))
|
||||
end
|
||||
|
||||
[false, true].each do |explicit|
|
||||
it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do
|
||||
params = { from: first_revision.id }
|
||||
params[:to] = last_revision.id if explicit
|
||||
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'wiki_page_id' => page.id, 'older_revision_id' => first_revision.id,
|
||||
'newer_revision_id' => last_revision.id)
|
||||
differences = json.fetch('diff').map { [_1.fetch('type'), _1.fetch('content').strip] }
|
||||
expect(differences).to include(['removed', '- 旧タグ'], ['added', '- 新タグ'])
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not use a version belonging to another group' do
|
||||
other_id = seed_group(name: '別グループ', description: '別本文')
|
||||
other_version = group_version(other_id, 1)
|
||||
get group_title_path('読取り契約'), params: { version: other_version.fetch('id') }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
get "/wiki/#{ page.id }/diff",
|
||||
params: { from: other_version.fetch('id'), to: last_revision.id }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'resolves a group without any compatibility Wiki page' do
|
||||
id = seed_group(name: '独立グループ', description: '独立した説明')
|
||||
without_group_legacy_reads { get group_title_path('独立グループ') }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('id' => id, 'title' => 'タググループ:独立グループ')
|
||||
expect(json.fetch('body').strip).to eq('独立した説明')
|
||||
end
|
||||
|
||||
context 'when Help, description and group share the same numeric ID' do
|
||||
let(:colliding_help) do
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
|
||||
body: 'help', created_by_user: user)
|
||||
end
|
||||
let(:colliding_group_id) do
|
||||
seed_group(name: '三者ID衝突', id: colliding_help.id, description: 'group')
|
||||
end
|
||||
let(:colliding_description) do
|
||||
TagDescription.create!(
|
||||
id: colliding_help.id, tag: new_tag, language_code: 'ja',
|
||||
body: 'description', version_no: 1)
|
||||
end
|
||||
let(:colliding_description_version) do
|
||||
TagDescriptionVersion.create!(
|
||||
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
|
||||
event_type: 'create', body: 'description', created_by_user_id: user.id,
|
||||
created_at: Time.current)
|
||||
end
|
||||
|
||||
before do
|
||||
colliding_group_id
|
||||
colliding_description
|
||||
colliding_description_version
|
||||
end
|
||||
|
||||
it 'lists both new resources sharing a numeric ID without legacy Help' do
|
||||
get '/wiki'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
rows = json.select { _1.fetch('id') == colliding_help.id }
|
||||
expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly(
|
||||
[colliding_description.id, '新タグ'],
|
||||
[colliding_group_id, 'タググループ:三者ID衝突'])
|
||||
end
|
||||
|
||||
it 'lists each resource history without deduplicating the shared resource ID' do
|
||||
get '/wiki/changes'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
rows = json.select { _1.fetch('wiki_page').fetch('id') == colliding_help.id }
|
||||
histories = rows.map do |row|
|
||||
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
|
||||
end
|
||||
expect(histories).to contain_exactly(
|
||||
[colliding_description_version.id, '新タグ'],
|
||||
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
|
||||
end
|
||||
|
||||
it 'still excludes legacy copies of an actually migrated group and its history' do
|
||||
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
|
||||
|
||||
get '/wiki'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
rows = json.select { _1.fetch('id') == page.id }
|
||||
expect(rows.map { _1.fetch('title') }).to eq(['タググループ:読取り契約'])
|
||||
|
||||
get '/wiki/changes'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
rows = json.select { _1.fetch('wiki_page').fetch('id') == page.id }
|
||||
expect(rows.map { _1.fetch('revision_id') })
|
||||
.to contain_exactly(first_revision.id, last_revision.id)
|
||||
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
|
||||
expect(rows.map { _1.fetch('wiki_page').fetch('title') })
|
||||
.to all(eq('タググループ:読取り契約'))
|
||||
end
|
||||
end
|
||||
|
||||
it 'resolves new resources by title while excluding colliding legacy Help' do
|
||||
help = seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
|
||||
seed_group(name: 'ID衝突', id: help.id, description: 'group')
|
||||
TagDescription.create!(
|
||||
id: help.id, tag: new_tag, language_code: 'ja', body: 'description', version_no: 1)
|
||||
TagDescriptionVersion.create!(
|
||||
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
|
||||
event_type: 'create', body: 'description', created_at: Time.current)
|
||||
{ 'タググループ:ID衝突' => 'group', '新タグ' => 'description' }
|
||||
.each do |title, body|
|
||||
get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').strip).to eq(body)
|
||||
end
|
||||
without_group_legacy_reads do
|
||||
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
|
||||
end
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,307 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
include TagGroupRecords
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('洗澡鹿') }
|
||||
let!(:other_tag) { group_tag('伊地知ニジカ') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def create_group title: 'タググループ:新規', body: '- 洗澡鹿', request_headers: headers
|
||||
post '/wiki', params: { title:, body: }, headers: request_headers
|
||||
end
|
||||
|
||||
def expect_rejected_without_writes
|
||||
before_state = group_state
|
||||
yield
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
expect(response.media_type).to eq('application/json')
|
||||
expect(response.body).not_to be_empty
|
||||
next if response.body.empty?
|
||||
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error', 'errors' => a_kind_of(Hash),
|
||||
'base_errors' => a_kind_of(Array))
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates the group, snapshot and matching compatibility histories' do
|
||||
expect { create_group }
|
||||
.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
group = group_rows('tag_groups', name: '新規').sole
|
||||
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
|
||||
expect_group_snapshot(group.fetch('id'), number: 1, members:, placements:)
|
||||
version = group_version(group.fetch('id'), 1)
|
||||
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
|
||||
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
|
||||
expect(page.body).to eq('- 洗澡鹿')
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
||||
end
|
||||
|
||||
it 'keeps repeated placements but only one membership and one snapshot member' do
|
||||
create_group(body: "- 洗澡鹿\n# 再掲\n- 洗澡鹿")
|
||||
expect(response).to have_http_status(:created)
|
||||
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
|
||||
expect_group_snapshot(
|
||||
id, number: 1, members:,
|
||||
placements: placements + [
|
||||
{ 'section_name' => '再掲', 'description' => nil, 'children' => placements }])
|
||||
end
|
||||
|
||||
it 'resolves a Japanese alias through tag_names.tag_id' do
|
||||
create(:tag_name, :alias, tag:, name: '鹿の別名')
|
||||
create_group(body: '- 鹿の別名')
|
||||
expect(response).to have_http_status(:created)
|
||||
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
|
||||
expect_group_snapshot(id, number: 1, members:, placements:)
|
||||
get group_title_path('新規')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').strip).to eq('- 洗澡鹿')
|
||||
end
|
||||
|
||||
{
|
||||
'unknown Tag' => '- 解決不能',
|
||||
'unknown Tag Group' => '- タググループ:存在しない',
|
||||
'empty Section' => '- :',
|
||||
'unsupported placement syntax' => "```\n洗澡鹿\n```",
|
||||
'six levels' => "- 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n" +
|
||||
" - 洗澡鹿\n - 洗澡鹿",
|
||||
'blank body' => '',
|
||||
'normalised blank body' => "\r\n\r\n" }.each do |label, body|
|
||||
it "rejects #{ label } without fallback or partial create" do
|
||||
expect_rejected_without_writes { create_group(body:) }
|
||||
end
|
||||
end
|
||||
|
||||
['', 'タググループ:', "タググループ:#{ '長' * 256 }"].each do |title|
|
||||
it "rejects an invalid required title of length #{ title.length }" do
|
||||
expect_rejected_without_writes { create_group(title:) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a duplicate group name even without a matching legacy Wiki' do
|
||||
seed_group(name: '新規', description: '既存')
|
||||
expect_rejected_without_writes { create_group }
|
||||
end
|
||||
|
||||
it 'accepts the existing five-level placement depth boundary' do
|
||||
body = (0...5).map { "#{ ' ' * _1 }- 洗澡鹿" }.join("\n")
|
||||
create_group(body:)
|
||||
expect(response).to have_http_status(:created)
|
||||
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
|
||||
tree = []
|
||||
5.times { tree = [{ 'tag_id' => tag.id, 'children' => tree }] }
|
||||
expect_group_snapshot(id, number: 1, members:, placements: tree)
|
||||
end
|
||||
|
||||
context 'updating a migrated group' do
|
||||
let!(:page) { legacy_group('更新', '- 洗澡鹿') }
|
||||
let!(:old_revision) { page.current_revision }
|
||||
|
||||
before { migrate_groups }
|
||||
|
||||
def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers
|
||||
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
|
||||
end
|
||||
|
||||
[1, 3].each do |repetitions|
|
||||
it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do
|
||||
body = page.body
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id, page:) do
|
||||
repetitions.times do
|
||||
update_group(body:)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps identical Markdown unchanged with a stale base_revision_id' do
|
||||
update_group
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = page.reload.body
|
||||
expect(page.current_revision.id).not_to eq(old_revision.id)
|
||||
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id, page:) do
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: page.title, body:, base_revision_id: old_revision.id },
|
||||
headers: headers
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps the original editor on identical Markdown from another member' do
|
||||
editor = create(:user, :member)
|
||||
body = page.body
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id, page:) do
|
||||
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not add history for Wiki content already changed through Native' do
|
||||
changed_members = [{ 'tag_id' => other_tag.id }]
|
||||
changed_placements = [{ 'tag_id' => other_tag.id, 'children' => [] }]
|
||||
put "/tag_groups/#{ page.id }",
|
||||
params: { name: '更新', description: nil,
|
||||
members: changed_members, placements: changed_placements },
|
||||
headers:, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
page.id, number: 2,
|
||||
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
|
||||
placements: changed_placements)
|
||||
body = page.reload.body
|
||||
expect(body).to eq('- 伊地知ニジカ')
|
||||
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id, page:) { update_group(body:) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'replaces current membership and placement, retaining the old snapshot' do
|
||||
old = group_version(page.id, 1)
|
||||
update_group
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
page.id, number: 2,
|
||||
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
|
||||
placements: [{ 'tag_id' => other_tag.id, 'children' => [] }])
|
||||
expect(group_version(page.id, 1)).to eq(old)
|
||||
expect(group_version(page.id, 2)).to include(
|
||||
'event_type' => 'update', 'created_by_user_id' => user.id)
|
||||
expect(page.reload.body.strip).to eq('- 伊地知ニジカ')
|
||||
expect(page.wiki_revisions.count).to eq(2)
|
||||
expect(page.wiki_versions.count).to eq(2)
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
|
||||
end
|
||||
|
||||
it 'accepts the current base_revision_id used by the existing frontend' do
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: page.title, body: '- 伊地知ニジカ',
|
||||
base_revision_id: old_revision.id }, headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2)
|
||||
end
|
||||
|
||||
it 'ignores a stale base_revision_id without rejecting the update' do
|
||||
update_group
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: page.title, body: '- 洗澡鹿',
|
||||
base_revision_id: old_revision.id }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(3)
|
||||
expect(page.reload.body.strip).to eq('- 洗澡鹿')
|
||||
end
|
||||
|
||||
it 'records the editor and update time' do
|
||||
editor = create(:user, :member, name: '編輯者')
|
||||
travel_to(Time.current + 1.hour) do
|
||||
update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
||||
expect(response).to have_http_status(:ok)
|
||||
current = group_rows('tag_groups', id: page.id).sole
|
||||
version = group_version(page.id, 2)
|
||||
expect(current).to include('updated_by_user_id' => editor.id)
|
||||
expect(version).to include('created_by_user_id' => editor.id)
|
||||
expect(current.fetch('updated_at')).to be_within(1.second).of(Time.current)
|
||||
expect(version.fetch('created_at')).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
['- 不明なタグ', '- タググループ:不明', '', '- :'].each do |body|
|
||||
it "rejects invalid update #{ body.inspect } atomically" do
|
||||
expect_rejected_without_writes { update_group(body:) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects direct self inclusion' do
|
||||
expect_rejected_without_writes { update_group(body: '- タググループ:更新') }
|
||||
end
|
||||
|
||||
it 'rejects indirect cyclic membership' do
|
||||
seed_group(
|
||||
name: '子', members: [{ 'tag_group_id' => page.id, 'version_no' => 1 }],
|
||||
placements: [{ 'tag_group_id' => page.id, 'children' => [] }])
|
||||
expect_rejected_without_writes { update_group(body: '- タググループ:子') }
|
||||
end
|
||||
|
||||
it 'rejects duplicate rename without retaining a partial title or version' do
|
||||
seed_group(name: '既存', description: '既存')
|
||||
expect_rejected_without_writes { update_group(title: 'タググループ:既存') }
|
||||
end
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
context "#{ operation } access and atomicity" do
|
||||
let!(:page) do
|
||||
next unless operation == :update
|
||||
|
||||
legacy = legacy_group('更新', '- 洗澡鹿')
|
||||
seed_group(name: '更新', id: legacy.id, members:, placements:)
|
||||
legacy
|
||||
end
|
||||
let!(:child_id) { seed_group(name: '子グループ', description: '子') }
|
||||
|
||||
def write_group operation, request_headers
|
||||
body = "- 伊地知ニジカ\n- タググループ:子グループ"
|
||||
if operation == :create
|
||||
create_group(body:, request_headers:)
|
||||
else
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: 'タググループ:更新', body: }, headers: request_headers
|
||||
end
|
||||
end
|
||||
|
||||
[nil, :guest, :banned].each do |role|
|
||||
it "rejects #{ role || 'anonymous' } without writes" do
|
||||
actor = case role
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
before_state = group_state
|
||||
write_group(operation, request_headers)
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a banned IP even for a member' do
|
||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||
before_state = group_state
|
||||
write_group(operation, headers)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
tables = TagGroupRecords::GROUP_TABLES
|
||||
tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions']
|
||||
tables.each do |table|
|
||||
it "rolls back all writes after a real #{ table } write fails" do
|
||||
expect_atomic_group_write(table) { write_group(operation, headers) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,331 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group write integrity', type: :request do
|
||||
include WikiRecords
|
||||
include TagGroupRecords
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('洗澡鹿') }
|
||||
let!(:other_tag) { group_tag('伊地知ニジカ') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def native_payload name: '更新', nodes: placements, member_rows: members
|
||||
{ name:, description: nil, members: member_rows.map do |member|
|
||||
member.except('version_no')
|
||||
end, placements: nodes }
|
||||
end
|
||||
|
||||
def put_wiki id, title:, body: '- 伊地知ニジカ', base: nil
|
||||
put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers
|
||||
end
|
||||
|
||||
def create_help body
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
|
||||
end
|
||||
|
||||
def expect_no_writes
|
||||
before_state = group_state
|
||||
yield
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
context 'resource identity' do
|
||||
it 'rejects removing the prefix from a migrated group atomically' do
|
||||
page = legacy_group('更新', '- 洗澡鹿')
|
||||
seed_group(name: '更新', id: page.id, members:, placements:)
|
||||
|
||||
expect_no_writes do
|
||||
put_wiki(page.id, title: '更新', base: page.current_revision.id)
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects an unprefixed update to an independent group' do
|
||||
id = seed_group(name: '独立', members:, placements:)
|
||||
expect_no_writes { put_wiki(id, title: '独立') }
|
||||
end
|
||||
|
||||
it 'does not revive legacy writes when the compatibility title is stale' do
|
||||
page = legacy_group('更新', '- 洗澡鹿')
|
||||
seed_group(name: '更新', id: page.id, members:, placements:)
|
||||
page.tag_name.update!(name: '古い別名')
|
||||
|
||||
expect_no_writes { put_wiki(page.id, title: '古い別名') }
|
||||
end
|
||||
|
||||
it 'renames the group and its compatibility page through the operation' do
|
||||
page = legacy_group('更新', '- 洗澡鹿')
|
||||
seed_group(name: '更新', id: page.id, members:, placements:)
|
||||
|
||||
put_wiki(page.id, title: 'タググループ:改名')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroup.find(page.id)).to have_attributes(name: '改名', version_no: 2)
|
||||
expect(page.reload.title).to eq('タググループ:改名')
|
||||
expect(page.body).to eq('- 伊地知ニジカ')
|
||||
end
|
||||
|
||||
it 'rejects a standalone Help update despite a colliding group ID' do
|
||||
page = create_help('Help body')
|
||||
id = seed_group(name: '別グループ', id: page.id, members:, placements:)
|
||||
group_before = TagGroup.find(id).attributes
|
||||
version_before = group_version(id, 1)
|
||||
|
||||
expect_no_writes { put_wiki(id, title: 'Help:改名', body: 'Help after') }
|
||||
|
||||
expect(page.reload.body).to eq('Help body')
|
||||
expect(page.title).to eq('Help:既存')
|
||||
expect(TagGroup.find(id).attributes).to eq(group_before)
|
||||
expect(group_version(id, 1)).to eq(version_before)
|
||||
end
|
||||
|
||||
it 'rejects an ambiguous unprefixed rename when Help and group IDs collide' do
|
||||
page = create_help('Help body')
|
||||
seed_group(name: '更新', id: page.id, members:, placements:)
|
||||
|
||||
expect_no_writes { put_wiki(page.id, title: '更新') }
|
||||
expect_no_writes do
|
||||
put_wiki(page.id, title: '更新', base: page.current_revision.id)
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps a description update separate from a colliding group' do
|
||||
page = Wiki::Commit.create_content!(
|
||||
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
|
||||
description = TagDescription.find_by!(tag_id: tag.id)
|
||||
id = seed_group(name: '更新', id: description.id, members:, placements:)
|
||||
group_before = TagGroup.find(id).attributes
|
||||
|
||||
put_wiki(id, title: '洗澡鹿', body: 'description after')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagDescription.find(id).body).to eq('description after')
|
||||
expect(page.reload.body).to eq('description after')
|
||||
expect(TagGroup.find(id).attributes).to eq(group_before)
|
||||
end
|
||||
|
||||
it 'does not route a colliding Help write into an unrelated description' do
|
||||
page = create_help('Help before')
|
||||
id = seed_group(name: '更新', id: page.id, members:, placements:)
|
||||
description = TagDescription.create!(
|
||||
id:, tag:, language_code: 'ja', version_no: 1, body: 'description')
|
||||
|
||||
expect_no_writes { put_wiki(id, title: 'Help:既存', body: 'Help after') }
|
||||
|
||||
expect(page.reload.body).to eq('Help before')
|
||||
expect(description.reload.body).to eq('description')
|
||||
expect(TagGroup.find(id).version_no).to eq(1)
|
||||
put_wiki(id, title: '洗澡鹿', body: 'description after')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(description.reload.body).to eq('description after')
|
||||
put_wiki(id, title: 'タググループ:更新')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroup.find(id).version_no).to eq(2)
|
||||
expect(page.reload.body).to eq('Help before')
|
||||
expect(description.reload.body).to eq('description after')
|
||||
end
|
||||
end
|
||||
|
||||
context 'compatibility synchronisation' do
|
||||
it 'synchronises a native rename without modifying an ID-colliding Help page' do
|
||||
help = create_help('Help before')
|
||||
id = seed_group(name: '更新', id: help.id, members:, placements:)
|
||||
help_before = help.attributes
|
||||
|
||||
expect do
|
||||
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(TagGroup.find(id).name).to eq('改名')
|
||||
expect(help.reload.attributes).to eq(help_before)
|
||||
end
|
||||
|
||||
['wiki_lines', 'wiki_revision_lines'].each do |table|
|
||||
it "rolls back native and compatibility state on #{ table } failure" do
|
||||
expect_atomic_group_write(table) do
|
||||
post '/tag_groups', params: native_payload, headers:, as: :json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'rolls back a renderer failure instead of treating it as unsupported structure' do
|
||||
allow(TagGroupMarkdownRenderer).to receive(:render).and_raise('renderer failure')
|
||||
before_state = group_state
|
||||
|
||||
expect do
|
||||
post '/tag_groups', params: native_payload, headers:, as: :json
|
||||
end.to raise_error(RuntimeError, 'renderer failure')
|
||||
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'preserves unplaced membership and reports the skipped sync with its version' do
|
||||
page = legacy_group('更新', '- 洗澡鹿')
|
||||
id = seed_group(name: '更新', id: page.id, members:, placements:)
|
||||
wiki_before = group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))
|
||||
warnings = []
|
||||
allow(Rails.logger).to receive(:warn) { warnings << _1 }
|
||||
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload(member_rows: members + [{ 'tag_id' => other_tag.id }]),
|
||||
headers:, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('members').map { _1.fetch('tag').fetch('id') })
|
||||
.to contain_exactly(tag.id, other_tag.id)
|
||||
expect(group_version(id, 2).fetch('members')).to include(
|
||||
{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no })
|
||||
expect(group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))).to eq(wiki_before)
|
||||
payload = JSON.parse(warnings.grep(/tag_group_wiki_sync_skipped/).sole.split(' ', 2).last)
|
||||
expect(payload).to include(
|
||||
'status' => 'unsupported_structure', 'tag_group_id' => id,
|
||||
'version_no' => 2, 'user_id' => user.id,
|
||||
'reason' => '掲載位置のない membership は Markdown にできません.')
|
||||
end
|
||||
|
||||
it 'preserves a list Section description and records why no Wiki was created' do
|
||||
nodes = [{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'section_name' => '区分', 'description' => '構造化の説明', 'children' => [] }] }]
|
||||
warnings = []
|
||||
allow(Rails.logger).to receive(:warn) { warnings << _1 }
|
||||
before_count = WikiPage.count
|
||||
|
||||
post '/tag_groups', params: native_payload(nodes:), headers:, as: :json
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
expect(group_version(id, 1).fetch('placements')).to eq(nodes)
|
||||
expect(json.fetch('placements').sole.fetch('children').sole)
|
||||
.to include('description' => '構造化の説明')
|
||||
expect(WikiPage.count).to eq(before_count)
|
||||
expect(warnings.grep(/tag_group_wiki_sync_skipped/).sole)
|
||||
.to include('リスト内 Section の説明文は Markdown にできません.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'identifier Markdown' do
|
||||
['[洗澡鹿](https://example.com)', '*洗澡鹿*', '**洗澡鹿**', '`洗澡鹿`',
|
||||
'',
|
||||
'[タググループ:子](https://example.com)', '*区分*:',
|
||||
'[区分](https://example.com):'].each do |label|
|
||||
[:create, :update].each do |operation|
|
||||
it "rejects decorated #{ label } on #{ operation } without writes" do
|
||||
seed_group(name: '子', description: '子')
|
||||
id = seed_group(name: '更新', members:, placements:)
|
||||
expect_no_writes do
|
||||
if operation == :create
|
||||
post '/wiki', params: { title: 'タググループ:新規', body: "- #{ label }" },
|
||||
headers: headers
|
||||
else
|
||||
put_wiki(id, title: 'タググループ:更新', body: "- #{ label }")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
['# [区分](https://example.com)', '# *区分*', '# `区分`', '# 区分 {#custom}',
|
||||
'# '].each do |heading|
|
||||
it "rejects a decorated heading #{ heading } without writes" do
|
||||
expect_no_writes do
|
||||
post '/wiki', params: { title: 'タググループ:新規', body: "#{ heading }\n- 洗澡鹿" },
|
||||
headers: headers
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'preserves allowed markup in group and heading descriptions' do
|
||||
description = '[参照](https://example.com "リンクの説明") と *強調* と ``a`b``'
|
||||
body = "#{ description }\n\n# 区分\n#{ description }\n\n- 洗澡鹿"
|
||||
post '/wiki', params: { title: 'タググループ:説明構文', body: }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
version = group_version(id, 1)
|
||||
expect(version.fetch('description')).to eq(description)
|
||||
expect(version.fetch('placements').sole.fetch('description')).to eq(description)
|
||||
end
|
||||
end
|
||||
|
||||
context 'structure audit information' do
|
||||
let!(:child_id) { seed_group(name: '子', description: '子') }
|
||||
let(:members) do
|
||||
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
|
||||
end
|
||||
let(:placements) do
|
||||
[{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_group_id' => child_id, 'children' => [] }] }]
|
||||
end
|
||||
let!(:id) { seed_group(name: '更新', members:, placements:) }
|
||||
let!(:editor) { create(:user, :member) }
|
||||
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
|
||||
|
||||
it 'keeps all membership and placement audit fields on a name-only edit' do
|
||||
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||
travel_to(1.hour.from_now) do
|
||||
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
|
||||
expect(group_version(id, 2)).to include('name' => '改名', 'members' => members)
|
||||
end
|
||||
|
||||
it 'keeps membership and placement audit fields on a description-only edit' do
|
||||
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||
travel_to(1.hour.from_now) do
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload.merge(description: '説明を追加'), headers:, as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
|
||||
expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members)
|
||||
end
|
||||
|
||||
it 'rolls back membership removal and placement edits on a late sync failure' do
|
||||
new_members = [{ 'tag_id' => tag.id }]
|
||||
new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }]
|
||||
expect_atomic_group_write('wiki_versions') do
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
|
||||
end
|
||||
end
|
||||
|
||||
it 'changes only added or removed membership and keeps placement creation audit' do
|
||||
tag_row = TagGroupTag.find([id, tag.id]).attributes
|
||||
placement_row = TagGroupPlacement.find([id, 1]).attributes
|
||||
new_members = [{ 'tag_id' => tag.id }, { 'tag_id' => other_tag.id }]
|
||||
new_nodes = [{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_id' => other_tag.id, 'children' => [] }] }]
|
||||
child_row = TagGroupPlacement.find([id, 2]).attributes
|
||||
travel_to(1.hour.from_now) do
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroupTag.find([id, tag.id]).attributes).to eq(tag_row)
|
||||
expect(TagGroupGroup.exists?(tag_group_id: id, child_tag_group_id: child_id)).to be(false)
|
||||
expect(TagGroupTag.find([id, other_tag.id]).created_by_user_id).to eq(editor.id)
|
||||
expect(TagGroupPlacement.find([id, 1]).attributes).to eq(placement_row)
|
||||
changed = TagGroupPlacement.find([id, 2])
|
||||
expect(changed).to have_attributes(
|
||||
tag_id: other_tag.id, child_tag_group_id: nil, updated_by_user_id: editor.id,
|
||||
created_at: child_row.fetch('created_at'),
|
||||
created_by_user_id: child_row.fetch('created_by_user_id'))
|
||||
expect(changed.updated_at).to be > child_row.fetch('updated_at')
|
||||
expect(group_version(id, 1).fetch('members')).to eq(members)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,467 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group native API', type: :request do
|
||||
include TagGroupRecords
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:group_id) { seed_group(name: '専用API', description: '説明') }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def expect_native_group value
|
||||
expect(value).to include(
|
||||
'id' => group_id, 'name' => '専用API', 'description' => '説明',
|
||||
'version_no' => 1, 'members' => [], 'placements' => [],
|
||||
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
|
||||
current = group_rows('tag_groups', id: group_id).sole
|
||||
expect(value).to include(
|
||||
'created_at' => current.fetch('created_at').iso8601(3),
|
||||
'updated_at' => current.fetch('updated_at').iso8601(3))
|
||||
end
|
||||
|
||||
it 'serves the collection as JSON without reading Wiki tables' do
|
||||
without_group_legacy_reads { get '/tag_groups' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.media_type).to eq('application/json')
|
||||
expect(json).to be_a(Array)
|
||||
expect_native_group(json.find { _1.fetch('id') == group_id })
|
||||
end
|
||||
|
||||
it 'serves an independent group as JSON without reading Wiki tables' do
|
||||
without_group_legacy_reads { get "/tag_groups/#{ group_id }" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.media_type).to eq('application/json')
|
||||
expect_native_group(json)
|
||||
end
|
||||
|
||||
it 'provides the confirmed PUT update route' do
|
||||
route = Rails.application.routes.recognize_path("/tag_groups/#{ group_id }", method: :put)
|
||||
expect(route).to include(action: 'update', id: group_id.to_s)
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
[nil, :guest, :banned].each do |role|
|
||||
it "rejects #{ role || 'anonymous' } native #{ operation } without writes" do
|
||||
actor = case role
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
else
|
||||
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
|
||||
end
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects a banned IP for native #{ operation }" do
|
||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||
headers = { 'X-Transfer-Code' => user.inheritance_code }
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
else
|
||||
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
|
||||
end
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'structured membership and placements' do
|
||||
let!(:tag) { group_tag('掲載タグ') }
|
||||
let!(:unplaced) { group_tag('未掲載タグ') }
|
||||
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:members) do
|
||||
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
|
||||
end
|
||||
let(:snapshot_members) do
|
||||
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 },
|
||||
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }]
|
||||
end
|
||||
let(:placements) do
|
||||
[{ 'section_name' => '区分', 'description' => '区分の説明', 'children' => [
|
||||
{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_group_id' => child_id, 'children' => [] }] },
|
||||
{ 'tag_id' => tag.id, 'children' => [] }] }]
|
||||
end
|
||||
let(:payload) { { name: '構造化', description: '全体の説明', members:, placements: } }
|
||||
|
||||
def native_write operation, attributes = payload
|
||||
if operation == :create
|
||||
post '/tag_groups', params: attributes, headers:, as: :json
|
||||
else
|
||||
put "/tag_groups/#{ group_id }", params: attributes, headers:, as: :json
|
||||
end
|
||||
end
|
||||
|
||||
def expect_structured_response
|
||||
tag_repr = { 'id' => tag.id, 'name' => '掲載タグ', 'category' => tag.category }
|
||||
unplaced_repr = { 'id' => unplaced.id, 'name' => '未掲載タグ',
|
||||
'category' => unplaced.category }
|
||||
expect(json.fetch('members')).to contain_exactly(
|
||||
include('tag' => include(tag_repr)),
|
||||
include('tag' => include(unplaced_repr)),
|
||||
include('tag_group' => include('id' => child_id, 'name' => '子')))
|
||||
expect(json.fetch('placements')).to match([
|
||||
include('section_name' => '区分', 'description' => '区分の説明', 'children' => [
|
||||
include('tag' => include(tag_repr), 'children' => [
|
||||
include('tag_group' => include('id' => child_id, 'name' => '子'),
|
||||
'children' => [])]),
|
||||
include('tag' => include(tag_repr), 'children' => [])])])
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
it "#{ operation }s structured data and keeps unplaced membership" do
|
||||
previous = group_version(group_id, 1)
|
||||
native_write(operation)
|
||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
||||
id = operation == :create ? json.fetch('id') : group_id
|
||||
number = operation == :create ? 1 : 2
|
||||
expect_group_snapshot(
|
||||
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
||||
expect(group_version(group_id, 1)).to eq(previous)
|
||||
get "/tag_groups/#{ id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => id, 'name' => '構造化', 'description' => '全体の説明', 'version_no' => number,
|
||||
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
|
||||
expect_structured_response
|
||||
end
|
||||
|
||||
{
|
||||
'blank name' => { name: '' },
|
||||
'missing name' => { name: nil },
|
||||
'oversized name' => { name: '長' * 256 },
|
||||
'duplicate name' => { name: '子' },
|
||||
'placement without a kind' => { placements: [{ children: [] }] },
|
||||
'empty Section name' => { placements: [{ section_name: '', children: [] }] } }
|
||||
.each do |label, invalid|
|
||||
it "rejects #{ label } on #{ operation } without partial writes" do
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(invalid))
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects multiple placement kinds on #{ operation }" do
|
||||
before_state = group_state
|
||||
invalid = [{ tag_id: tag.id, section_name: '不正', children: [] }]
|
||||
native_write(operation, payload.merge(placements: invalid))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it "rejects a missing referenced Tag on #{ operation }" do
|
||||
missing = Tag.maximum(:id).to_i + 1
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(
|
||||
members: [{ tag_id: missing }], placements: [{ tag_id: missing, children: [] }]))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it "rejects a missing referenced group on #{ operation }" do
|
||||
missing = group_rows('tag_groups').map { _1.fetch('id') }.max + 1
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(
|
||||
members: [{ tag_group_id: missing }],
|
||||
placements: [{ tag_group_id: missing, children: [] }]))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it "rejects six descendant placement levels on #{ operation }" do
|
||||
nodes = []
|
||||
6.times { nodes = [{ tag_id: tag.id, children: nodes }] }
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(placements: nodes))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it 'preserves membership-only data in structured reads without inventing placements' do
|
||||
id = seed_group(name: '未掲載のみ', members: [snapshot_members.last])
|
||||
without_group_legacy_reads { get "/tag_groups/#{ id }" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('placements')).to eq([])
|
||||
expect(json.fetch('members')).to match([
|
||||
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
|
||||
end
|
||||
|
||||
it 'rejects indirect containment cycles' do
|
||||
parent = seed_group(
|
||||
name: '親', members: [{ 'tag_group_id' => group_id, 'version_no' => 1 }])
|
||||
before_state = group_state
|
||||
native_write(:update, payload.merge(
|
||||
members: [{ tag_group_id: parent }], placements: []))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'rejects direct containment even without a corresponding placement' do
|
||||
before_state = group_state
|
||||
native_write(:update, payload.merge(
|
||||
members: [{ tag_group_id: group_id }], placements: []))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'preserves list Section descriptions in native structured data' do
|
||||
nodes = [{ tag_id: tag.id, children: [
|
||||
{ section_name: 'リスト内区分', description: '消してはいけない説明', children: [] }] }]
|
||||
native_write(:update, payload.merge(placements: nodes))
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_version(group_id, 2).fetch('placements')).to eq(
|
||||
JSON.parse(nodes.to_json))
|
||||
get "/tag_groups/#{ group_id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
section = json.fetch('placements').sole.fetch('children').sole
|
||||
expect(section).to include(
|
||||
'section_name' => 'リスト内区分', 'description' => '消してはいけない説明')
|
||||
end
|
||||
|
||||
context 'compatibility writes for representable structures' do
|
||||
let(:group_id) do
|
||||
page = legacy_group('専用API', '説明')
|
||||
seed_group(name: '専用API', id: page.id, description: '説明')
|
||||
end
|
||||
let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] }
|
||||
let(:snapshot_members) do
|
||||
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
|
||||
end
|
||||
|
||||
it 'treats identical native content with unplaced membership as a no-op' do
|
||||
unplaced_payload = payload.merge(
|
||||
name: '専用API', members: members + [{ 'tag_id' => unplaced.id }])
|
||||
native_write(:update, unplaced_payload)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
group_id, number: 2, description: '全体の説明', placements:,
|
||||
members: snapshot_members + [
|
||||
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }])
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:専用API' })
|
||||
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:, page:) do
|
||||
native_write(:update, unplaced_payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'no-op updates against synchronised state' do
|
||||
let(:synchronised_payload) do
|
||||
{ name: '構造化', description: '全体の説明',
|
||||
members: [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }], placements: }
|
||||
end
|
||||
let(:additional_tag) { group_tag('追加対象タグ') }
|
||||
let(:compatibility_page) do
|
||||
WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' })
|
||||
end
|
||||
|
||||
before do
|
||||
native_write(:update, synchronised_payload)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(compatibility_page.body).to include('- 掲載タグ', '- タググループ:子')
|
||||
end
|
||||
|
||||
[1, 3].each do |repetitions|
|
||||
it "keeps identical native content unchanged after #{ repetitions } PUTs" do
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:, page: compatibility_page) do
|
||||
repetitions.times do
|
||||
native_write(:update, synchronised_payload)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'treats membership order alone as a no-op' do
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:, page: compatibility_page) do
|
||||
native_write(:update, synchronised_payload.merge(
|
||||
members: synchronised_payload.fetch(:members).reverse))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps the original editor when another member submits identical content' do
|
||||
editor = create(:user, :member)
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:, page: compatibility_page) do
|
||||
put "/tag_groups/#{ group_id }", params: synchronised_payload,
|
||||
headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not add history for Native content already changed through Wiki' do
|
||||
put "/wiki/#{ group_id }",
|
||||
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(compatibility_page.reload.body).to eq('- 未掲載タグ')
|
||||
same_content = {
|
||||
name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }],
|
||||
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] }
|
||||
expect_group_snapshot(
|
||||
group_id, number: 3, description: nil,
|
||||
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
|
||||
placements: same_content.fetch(:placements))
|
||||
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:, page: compatibility_page) do
|
||||
native_write(:update, same_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
['name', 'description', 'membership addition', 'membership removal',
|
||||
'Section name', 'Section description', 'placement order', 'placement hierarchy']
|
||||
.each do |change|
|
||||
it "records a real #{ change } change in both histories" do
|
||||
changed = synchronised_payload.deep_dup
|
||||
section = changed.fetch(:placements).first
|
||||
children = section.fetch('children')
|
||||
case change
|
||||
when 'name' then changed[:name] = '実際の改名'
|
||||
when 'description' then changed[:description] = '実際の説明変更'
|
||||
when 'membership addition'
|
||||
expect(changed.fetch(:members)).not_to include('tag_id' => additional_tag.id)
|
||||
changed[:members] << { 'tag_id' => additional_tag.id }
|
||||
children << { 'tag_id' => additional_tag.id, 'children' => [] }
|
||||
when 'membership removal'
|
||||
changed[:members].reject! { _1.key?('tag_group_id') }
|
||||
children.first['children'] = []
|
||||
when 'Section name' then section['section_name'] = '別区分'
|
||||
when 'Section description' then section['description'] = '別の区分説明'
|
||||
when 'placement order' then children.reverse!
|
||||
when 'placement hierarchy'
|
||||
children << children.first.fetch('children').pop
|
||||
end
|
||||
|
||||
old_versions = group_rows('tag_group_versions', tag_group_id: group_id)
|
||||
group = TagGroup.find(group_id)
|
||||
number = group.version_no
|
||||
page = compatibility_page
|
||||
old_revision_id = page.current_revision.id
|
||||
old_wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id
|
||||
revision_count = page.wiki_revisions.count
|
||||
version_count = page.wiki_versions.count
|
||||
travel_to(1.hour.from_now) { native_write(:update, changed) }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expected_members = changed.fetch(:members).map do |member|
|
||||
target = if member.key?('tag_id')
|
||||
Tag.find(member.fetch('tag_id'))
|
||||
else
|
||||
TagGroup.find(member.fetch('tag_group_id'))
|
||||
end
|
||||
member.merge('version_no' => target.version_no)
|
||||
end
|
||||
expect_group_snapshot(
|
||||
group_id, number: number + 1, members: expected_members,
|
||||
placements: changed.fetch(:placements), description: changed.fetch(:description))
|
||||
expect(group.reload.name).to eq(changed.fetch(:name))
|
||||
versions = group_rows('tag_group_versions', tag_group_id: group_id)
|
||||
expect(versions.size).to eq(old_versions.size + 1)
|
||||
expect(versions.select { _1.fetch('version_no') <= number }).to eq(old_versions)
|
||||
expect(page.wiki_revisions.count).to eq(revision_count + 1)
|
||||
expect(page.wiki_versions.count).to eq(version_count + 1)
|
||||
expect(page.reload.current_revision.id).not_to eq(old_revision_id)
|
||||
expect(page.wiki_versions.order(version_no: :desc).first.id)
|
||||
.not_to eq(old_wiki_version_id)
|
||||
get group_title_path(changed.fetch(:name))
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body')).to eq(page.body)
|
||||
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|
|
||||
it "synchronises Wiki current state and histories on native #{ operation }" do
|
||||
native_write(operation)
|
||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
||||
id = operation == :create ? json.fetch('id') : group_id
|
||||
number = operation == :create ? 1 : 2
|
||||
expect_group_snapshot(
|
||||
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' })
|
||||
expect(page.body).to include('全体の説明', '# 区分', '- 掲載タグ', '- タググループ:子')
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
|
||||
expect(page.wiki_revisions.count).to eq(number)
|
||||
expect(page.wiki_versions.count).to eq(number)
|
||||
get group_title_path('構造化')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body')).to eq(page.body)
|
||||
end
|
||||
|
||||
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
|
||||
.each do |table|
|
||||
it "rolls back native #{ operation } after #{ table } writes" do
|
||||
expect_atomic_group_write(table) { native_write(operation) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'assigns the next group ID from the existing maximum' do
|
||||
child_id
|
||||
expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1
|
||||
native_write(:create)
|
||||
expect(response).to have_http_status(:created)
|
||||
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
|
||||
|
||||
it 'updates a native group through Wiki and then reads structured current data' do
|
||||
native_write(:create)
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
previous = group_version(id, 1)
|
||||
put "/wiki/#{ id }",
|
||||
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
id, number: 2,
|
||||
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
|
||||
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }])
|
||||
expect(group_version(id, 1)).to eq(previous)
|
||||
get "/tag_groups/#{ id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('version_no' => 2)
|
||||
expect(json.fetch('members')).to match([
|
||||
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag resource ID allocation', type: :request do
|
||||
include TagGroupRecords
|
||||
include WikiRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
|
||||
def seed_description id
|
||||
tag = create(:tag, primary_name: "description_id_#{ id }")
|
||||
TagDescription.create!(id:, tag:, language_code: 'ja', body: 'existing', version_no: 1)
|
||||
end
|
||||
|
||||
def create_resource resource
|
||||
if resource == :description
|
||||
tag = create(:tag, primary_name: 'new_description')
|
||||
post '/wiki', params: { title: tag.name('ja'), body: 'new description' }, headers: headers
|
||||
else
|
||||
post '/tag_groups', params: {
|
||||
name: 'new_group', description: 'new group', members: [], placements: [] },
|
||||
headers:, as: :json
|
||||
end
|
||||
expect(response).to have_http_status(:created)
|
||||
end
|
||||
|
||||
[:description, :group].each do |resource|
|
||||
it "keeps #{ resource } independent of the Tag and ExternalTag ID space" do
|
||||
create(:tag, id: 100)
|
||||
create(:external_tag, id: 200)
|
||||
seed_description(1)
|
||||
seed_group(name: 'shared_space', id: 2)
|
||||
|
||||
create_resource(resource)
|
||||
|
||||
expect(json.fetch('id')).to eq(3)
|
||||
end
|
||||
|
||||
it "uses the other resource's larger maximum when allocating #{ resource }" do
|
||||
seed_description(resource == :description ? 1 : 20)
|
||||
seed_group(name: 'other_space', id: resource == :group ? 1 : 20)
|
||||
create_resource(resource)
|
||||
expect(json.fetch('id')).to eq(21)
|
||||
end
|
||||
|
||||
it "starts #{ resource } at 1 even after previous allocated rows were removed" do
|
||||
expect(TagDescription.count).to eq(0)
|
||||
expect(TagGroup.count).to eq(0)
|
||||
if resource == :description
|
||||
seed_description(100).destroy!
|
||||
else
|
||||
id = seed_group(name: 'removed', id: 100)
|
||||
TagGroup.find(id).destroy!
|
||||
end
|
||||
create_resource(resource)
|
||||
expect(json.fetch('id')).to eq(1)
|
||||
end
|
||||
|
||||
it "allocates #{ resource } above both maxima, preserving legacy overlaps and gaps" do
|
||||
[1, 2, 5].each { seed_description(_1) }
|
||||
[1, 3, 8].each { seed_group(name: "group_#{ _1 }", id: _1) }
|
||||
descriptions = TagDescription.order(:id).map(&:attributes)
|
||||
groups = TagGroup.order(:id).map(&:attributes)
|
||||
|
||||
create_resource(resource)
|
||||
|
||||
expect(json.fetch('id')).to eq(9)
|
||||
expect(TagDescription.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(descriptions)
|
||||
expect(TagGroup.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(groups)
|
||||
expect(TagDescription.exists?(id: 3)).to be(false)
|
||||
expect(TagGroup.exists?(id: 2)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
it 'preserves group ID and unrelated rows on update' do
|
||||
id = seed_group(name: 'existing', id: 5, description: 'before')
|
||||
other_id = seed_group(name: 'other', id: 8, description: 'other')
|
||||
other = TagGroup.find(other_id).attributes
|
||||
put "/tag_groups/#{ id }", params: {
|
||||
name: 'renamed', description: 'after', members: [], placements: [] }, headers:, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('id')).to eq(id)
|
||||
expect(TagGroup.find(id).description).to eq('after')
|
||||
expect(TagGroup.find(other_id).attributes).to eq(other)
|
||||
expect(TagGroup.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'preserves description identity on a compatibility update' do
|
||||
tag = create(:tag, primary_name: 'existing_description')
|
||||
page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
|
||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
id = description.id
|
||||
put "/wiki/#{ id }", params: {
|
||||
title: tag.name('ja'), body: 'after', base_revision_id: page.current_revision.id },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(description.reload.id).to eq(id)
|
||||
expect(description.body).to eq('after')
|
||||
expect(TagDescription.count).to eq(1)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
include WikiRecords
|
||||
let(:member_user) { create(:user, role: 'member') }
|
||||
|
||||
def stub_current_user user
|
||||
@@ -13,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
end
|
||||
|
||||
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
|
||||
@@ -38,6 +38,22 @@ RSpec.describe 'Tags API', type: :request do
|
||||
end
|
||||
|
||||
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
|
||||
external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
@@ -9,8 +10,8 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
|
||||
it 'returns 409 when base_revision_id is stale' do
|
||||
page =
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: create(:tag_name, name: 'wiki_conflict_request'),
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'),
|
||||
body: 'first',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
@@ -25,12 +26,10 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
base_revision_id: stale_id)
|
||||
|
||||
put "/wiki/#{ page.id }",
|
||||
params: {
|
||||
title: 'wiki_conflict_request',
|
||||
params: { title: 'wiki_conflict_request',
|
||||
body: 'third',
|
||||
message: 'stale',
|
||||
base_revision_id: stale_id,
|
||||
},
|
||||
base_revision_id: stale_id },
|
||||
headers: auth_headers(user)
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki creation freeze', type: :request do
|
||||
include TagGroupRecords
|
||||
include WikiRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
|
||||
it 'creates missing compatibility history when updating an independent group' do
|
||||
id = seed_group(name: 'independent', description: 'before')
|
||||
expect do
|
||||
put "/tag_groups/#{ id }", params: {
|
||||
name: 'independent', description: 'after', members: [], placements: [] },
|
||||
headers:, as: :json
|
||||
end.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroup.find(id).description).to eq('after')
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:independent' })
|
||||
expect(page.body).to include('after')
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
||||
end
|
||||
|
||||
context 'existing description write protection' do
|
||||
let!(:tag) { create(:tag, primary_name: 'protected_description') }
|
||||
let!(:page) do
|
||||
seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
|
||||
end
|
||||
|
||||
[nil, :guest, :banned].each do |role|
|
||||
it "rejects #{ role || 'anonymous' } without changing either history" do
|
||||
actor = role && create(:user, role == :banned ? :member : role)
|
||||
actor.update!(banned_at: Time.current) if role == :banned
|
||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
before_state = group_state
|
||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
|
||||
headers: request_headers
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects banned IPs without writes' do
|
||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||
before_state = group_state
|
||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' }, headers: headers
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
['', "\r\n\r\n"].each do |body|
|
||||
it "rejects invalid description body #{ body.inspect } atomically" do
|
||||
before_state = group_state
|
||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: }, headers: headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ require 'cgi'
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki history integrity', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
@@ -9,49 +10,22 @@ RSpec.describe 'Wiki history integrity', type: :request do
|
||||
end
|
||||
|
||||
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag, primary_name: title).tag_name('ja'),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
message:)
|
||||
end
|
||||
|
||||
describe 'POST /wiki' do
|
||||
it 'creates wiki_page, wiki_revision, and wiki_version atomically' do
|
||||
expect {
|
||||
post '/wiki',
|
||||
params: {
|
||||
title: 'Help:wiki_history_create_atomic',
|
||||
body: "a\nb\nc",
|
||||
message: 'initial commit',
|
||||
},
|
||||
it 'rejects frozen creation without partial page or history' do
|
||||
expect do
|
||||
post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' },
|
||||
headers: auth_headers(user)
|
||||
}
|
||||
.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)
|
||||
|
||||
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.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
expect(response.status).to be_between(400, 499)
|
||||
end
|
||||
|
||||
it 'returns 422 and creates nothing when normalised body is blank' do
|
||||
@@ -71,21 +45,19 @@ RSpec.describe 'Wiki history integrity', type: :request do
|
||||
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
|
||||
end
|
||||
|
||||
it 'returns 422 and creates no partial page when title already exists' do
|
||||
it 'rejects frozen creation without modifying an existing page or history' do
|
||||
create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first')
|
||||
|
||||
expect {
|
||||
expect do
|
||||
post '/wiki',
|
||||
params: {
|
||||
title: 'Help:wiki_history_duplicate_title',
|
||||
body: 'second',
|
||||
message: 'duplicate',
|
||||
},
|
||||
params: { title: 'Help:wiki_history_duplicate_title',
|
||||
body: 'second', message: 'duplicate' },
|
||||
headers: auth_headers(user)
|
||||
}
|
||||
.not_to change(WikiPage, :count)
|
||||
end.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.status).to be_between(400, 499)
|
||||
expect(WikiPage.joins(:tag_name)
|
||||
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki resource ordering', type: :request do
|
||||
include TagGroupRecords
|
||||
let(:user) { create_member_user! }
|
||||
|
||||
it 'selects the latest twenty across both resources before applying the global limit' do
|
||||
start = Time.utc(2026, 1, 1)
|
||||
descriptions = 24.times.map do |index|
|
||||
tag = create(:tag, primary_name: "rank_#{ index }")
|
||||
TagDescription.create!(tag:, language_code: 'ja', body: 'current', version_no: 1,
|
||||
updated_at: start + index.hours)
|
||||
end
|
||||
groups = [24, 25].map do |hour|
|
||||
group = TagGroup.find(seed_group(name: "rank_group_#{ hour }"))
|
||||
group.update_columns(updated_at: start + hour.hours)
|
||||
group
|
||||
end
|
||||
|
||||
get '/wiki', params: { title: 'rank' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expected = groups.reverse.map { ['tag_group', _1.id] } +
|
||||
descriptions.last(18).reverse.map { ['tag_description', _1.id] }
|
||||
expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] }).to eq(expected)
|
||||
end
|
||||
|
||||
it 'breaks timestamp ties by resource kind and ID while retaining legacy overlaps' do
|
||||
time = Time.utc(2026, 1, 1)
|
||||
tag = create(:tag, primary_name: 'tied')
|
||||
TagDescription.create!(id: 51, tag:, language_code: 'ja', body: 'current',
|
||||
version_no: 1, updated_at: time)
|
||||
group = TagGroup.find(seed_group(name: 'tied', id: 51))
|
||||
group.update_columns(updated_at: time)
|
||||
|
||||
2.times do
|
||||
get '/wiki', params: { title: 'tied' }
|
||||
expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] })
|
||||
.to eq([['tag_description', 51], ['tag_group', 51]])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ require 'securerandom'
|
||||
|
||||
|
||||
RSpec.describe 'Wiki API', type: :request do
|
||||
include WikiRecords
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
end
|
||||
@@ -18,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
deprecated_at: Time.zone.local(2026, 6, 1))
|
||||
end
|
||||
let!(:page) do
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: tn,
|
||||
body: 'init',
|
||||
created_by_user: user,
|
||||
@@ -108,94 +109,14 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when success' do
|
||||
it 'creates wiki_page and first content revision' do
|
||||
['Help:TestPage', 'ヘルプ:ホーム'].each do |title|
|
||||
it "rejects frozen creation of #{ title } without history writes" do
|
||||
expect do
|
||||
post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' },
|
||||
headers: auth_headers(member)
|
||||
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(: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'])
|
||||
post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member)
|
||||
end.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
expect(response.status).to be_between(400, 499)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -208,10 +129,10 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
end
|
||||
|
||||
let!(:test_tag_name) { create(:tag_name, name: 'TestPage') }
|
||||
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
|
||||
|
||||
let!(:page) do
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: test_tag_name,
|
||||
body: "a\nb",
|
||||
created_by_user: member,
|
||||
@@ -249,7 +170,8 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
|
||||
expect do
|
||||
put "/wiki/#{page.id}",
|
||||
params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
|
||||
params: { title: 'TestPage', body: "x\ny", message: 'edit',
|
||||
base_revision_id: current_id },
|
||||
headers: auth_headers(member)
|
||||
end
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
@@ -284,7 +206,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
created_by_user: member)
|
||||
|
||||
linked_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: linked_tag_name,
|
||||
body: 'before',
|
||||
created_by_user: member,
|
||||
@@ -358,13 +280,14 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
|
||||
describe 'GET /wiki/search' do
|
||||
before do
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: create(:tag_name, name: 'spec_wiki_title_2'),
|
||||
search_tag = create(:tag, primary_name: 'spec_wiki_title_2')
|
||||
seed_wiki_page(
|
||||
tag_name: search_tag.tag_name('ja'),
|
||||
body: 'search body 2',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'unrelated_title'),
|
||||
body: 'unrelated body',
|
||||
created_by_user: user,
|
||||
@@ -516,7 +439,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
it 'raises because redirect revisions are deprecated' do
|
||||
target_tag_name = create(:tag_name, name: 'redirect_deprecated_target')
|
||||
target =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: target_tag_name,
|
||||
body: 'target',
|
||||
created_by_user: user,
|
||||
@@ -539,7 +462,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general)
|
||||
|
||||
linked_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: linked_tag_name,
|
||||
body: 'before',
|
||||
created_by_user: user,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki title collision', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
@@ -8,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do
|
||||
end
|
||||
|
||||
def create_wiki_page title:, body:
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
@@ -43,20 +44,18 @@ RSpec.describe 'Wiki title collision', type: :request do
|
||||
expect(source.wiki_versions.count).to eq(source_version_count)
|
||||
end
|
||||
|
||||
it 'returns 422 when creating wiki with existing title' do
|
||||
it 'rejects frozen creation with an existing title without adding history' do
|
||||
create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists')
|
||||
|
||||
expect {
|
||||
expect do
|
||||
post '/wiki',
|
||||
params: {
|
||||
title: 'Help:wiki_collision_create',
|
||||
body: 'new body',
|
||||
message: 'duplicate create',
|
||||
},
|
||||
params: { title: 'Help:wiki_collision_create', body: 'new body',
|
||||
message: 'duplicate create' },
|
||||
headers: auth_headers(user)
|
||||
}
|
||||
.not_to change(WikiPage, :count)
|
||||
end.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.status).to be_between(400, 499)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,115 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag_name) { create(:tag_name, name: 'Help_contract') }
|
||||
let!(:page) do
|
||||
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user)
|
||||
seed_wiki_page(tag_name:, body: 'before', created_by_user: user)
|
||||
end
|
||||
let!(:first_revision) { page.current_revision }
|
||||
let!(:last_revision) do
|
||||
Wiki::Commit.content!(page:, body: 'after', created_user: user,
|
||||
Wiki::Commit.legacy_content!(page:, body: 'after', created_user: user,
|
||||
message: 'legacy edit', base_revision_id: first_revision.id)
|
||||
end
|
||||
|
||||
before { expect(TagDescription.exists?(id: page.id)).to be(false) }
|
||||
|
||||
it 'keeps a Tag-linked legacy page in the legacy domain when its title changes' do
|
||||
it 'rejects standalone updates even after a Tag is linked to the legacy page' do
|
||||
create(:tag, primary_tag_name: tag_name)
|
||||
original = page.reload.attributes
|
||||
|
||||
expect do
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: 'legacy_tag_renamed', body: 'changed legacy body',
|
||||
message: 'legacy rename', base_revision_id: last_revision.id },
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
end.to change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
end.to change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.and change(TagDescription, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(page.reload).to have_attributes(body: 'changed legacy body')
|
||||
expect(page.title).to eq('legacy_tag_renamed')
|
||||
expect(page.current_revision).to have_attributes(message: 'legacy rename')
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(page.reload.attributes).to eq(original)
|
||||
expect(page.current_revision).to have_attributes(message: 'legacy edit')
|
||||
end
|
||||
|
||||
it 'shows legacy current and historical content by ID' do
|
||||
get "/wiki/#{ page.id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id,
|
||||
'pred' => first_revision.id, 'succ' => nil)
|
||||
get "/wiki/#{ page.id }", params: { version: first_revision.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id,
|
||||
'pred' => nil, 'succ' => last_revision.id)
|
||||
end
|
||||
include TagGroupRecords
|
||||
|
||||
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)
|
||||
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 }
|
||||
[: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 'retains legacy history metadata and ordering' do
|
||||
get '/wiki/changes', params: { id: page.id }
|
||||
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.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 })
|
||||
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(json).to eq([])
|
||||
end
|
||||
|
||||
[false, true].each do |explicit|
|
||||
it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do
|
||||
params = { from: first_revision.id }
|
||||
params[:to] = last_revision.id if explicit
|
||||
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
require 'rails_helper'
|
||||
require 'timeout'
|
||||
|
||||
RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
||||
self.use_transactional_tests = false
|
||||
|
||||
before do
|
||||
expect(Rails.env.test?).to be(true)
|
||||
expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i)
|
||||
expect(ApplicationRecord.connection_pool.size).to be >= 3
|
||||
@prefix = "shared_ids_#{ SecureRandom.hex(8) }"
|
||||
@names = [0, 1].map { create(:tag_name, name: "#{ @prefix }_#{ _1 }") }
|
||||
@tag = Tag.create!(tag_name_id: @names.first.id, category: :general)
|
||||
@names.first.update!(tag_id: @tag.id)
|
||||
@threads = []
|
||||
@release = Queue.new
|
||||
end
|
||||
|
||||
after do
|
||||
@release << true if @release
|
||||
begin
|
||||
Array(@threads).each do |thread|
|
||||
raise 'Shared ID worker did not finish' unless thread.join(15)
|
||||
end
|
||||
ensure
|
||||
if @prefix
|
||||
ExternalTag.where(name: "#{ @prefix }_external").delete_all
|
||||
TagGroup.where(name: "#{ @prefix }_group").delete_all
|
||||
end
|
||||
TagDescription.where(tag_id: @tag.id).delete_all if @tag
|
||||
if @names
|
||||
ids = @names.map(&:id)
|
||||
TagName.where(id: ids).update_all(tag_id: nil)
|
||||
Tag.where(tag_name_id: ids).delete_all
|
||||
TagName.where(id: ids).delete_all
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def await_queue queue
|
||||
Timeout.timeout(15) { queue.pop }
|
||||
end
|
||||
|
||||
def worker &block
|
||||
Thread.new do
|
||||
Thread.current.report_on_exception = false
|
||||
ApplicationRecord.connection_pool.with_connection(&block)
|
||||
end.tap { @threads << _1 }
|
||||
end
|
||||
|
||||
def create_resource kind
|
||||
case kind
|
||||
when :tag
|
||||
Tag.create!(tag_name_id: @names.last.id, category: :general)
|
||||
when :external
|
||||
ExternalTag.create!(platform: :nico, name: "#{ @prefix }_external")
|
||||
when :description
|
||||
TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1)
|
||||
when :group
|
||||
TagGroup.create!(name: "#{ @prefix }_group", version_no: 1)
|
||||
end
|
||||
end
|
||||
|
||||
def hold_creation kind, rollback: false
|
||||
ready = Queue.new
|
||||
thread = worker do |connection|
|
||||
id = nil
|
||||
connection.transaction do
|
||||
lock_name = nil
|
||||
subscriber = lambda do |*args|
|
||||
match = args.last.fetch(:sql).match(/GET_LOCK\('([^']+)'/)
|
||||
lock_name = match[1] if match
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
id = create_resource(kind).id
|
||||
end
|
||||
ready << [id, connection.select_value('SELECT CONNECTION_ID()').to_i, lock_name]
|
||||
await_queue(@release)
|
||||
raise ActiveRecord::Rollback if rollback
|
||||
end
|
||||
id
|
||||
end
|
||||
[thread, await_queue(ready)]
|
||||
end
|
||||
|
||||
def result thread
|
||||
raise 'Shared ID worker did not finish' unless thread.join(15)
|
||||
thread.value
|
||||
end
|
||||
|
||||
{ tags: [:external, :tag], wiki: [:description, :group] }.each do |space, kinds|
|
||||
[false, true].each do |rollback|
|
||||
it "serialises #{ space } inserts through #{ rollback ? 'rollback' : 'commit' }" do
|
||||
tables = SharedResourceIdAllocator::SPACES.fetch(space.to_s)
|
||||
maxima = tables.map do |table|
|
||||
ApplicationRecord.connection.select_value("SELECT MAX(id) FROM #{ table }").to_i
|
||||
end
|
||||
first, (first_id, connection_id, lock_name) = hold_creation(kinds.first, rollback:)
|
||||
expect(first_id).to eq(maxima.max + 1)
|
||||
connection = ApplicationRecord.connection
|
||||
quoted_lock = connection.quote(lock_name)
|
||||
expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i)
|
||||
.to eq(connection_id)
|
||||
|
||||
started = Queue.new
|
||||
second = worker do |other|
|
||||
started << other.select_value('SELECT CONNECTION_ID()').to_i
|
||||
create_resource(kinds.last).id
|
||||
end
|
||||
expect(await_queue(started)).not_to eq(connection_id)
|
||||
@release << true
|
||||
expect(result(first)).to eq(first_id)
|
||||
expect(result(second)).to eq(rollback ? first_id : first_id + 1)
|
||||
expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows the other ID space to commit while the first space remains locked' do
|
||||
first, (_id, connection_id, lock_name) = hold_creation(:external)
|
||||
second = worker { create_resource(:group).id }
|
||||
expect(result(second)).to be_positive
|
||||
connection = ApplicationRecord.connection
|
||||
quoted_lock = connection.quote(lock_name)
|
||||
expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i)
|
||||
.to eq(connection_id)
|
||||
@release << true
|
||||
result(first)
|
||||
end
|
||||
|
||||
it 'raises on a real lock acquisition failure without inserting a record' do
|
||||
first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true)
|
||||
stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0)
|
||||
expect { create_resource(:external) }.to raise_error(ActiveRecord::LockWaitTimeout)
|
||||
expect(ExternalTag.where(name: "#{ @prefix }_external")).not_to exist
|
||||
@release << true
|
||||
result(first)
|
||||
connection = ApplicationRecord.connection
|
||||
quoted_lock = connection.quote(lock_name)
|
||||
expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i)
|
||||
.to eq(1)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SharedResourceIdAllocator do
|
||||
let(:record) { build(:external_tag) }
|
||||
let(:connection) { double('connection') }
|
||||
let(:transaction) { double('transaction', open?: true) }
|
||||
let(:callbacks) { { } }
|
||||
let(:sql) { [] }
|
||||
|
||||
before do
|
||||
config = double('config', database: 'test_shared_ids')
|
||||
allow(connection).to receive_message_chain(:pool, :db_config).and_return(config)
|
||||
allow(connection).to receive(:quote) { "'#{ _1 }'" }
|
||||
allow(connection).to receive(:quote_table_name) { "`#{ _1 }`" }
|
||||
allow(connection).to receive(:select_value) do |statement|
|
||||
sql << statement
|
||||
if statement.include?('GET_LOCK') || statement.include?('RELEASE_LOCK')
|
||||
1
|
||||
elsif statement.include?('`tags`')
|
||||
8
|
||||
else
|
||||
3
|
||||
end
|
||||
end
|
||||
allow(ExternalTag).to receive(:connection).and_return(connection)
|
||||
allow(ExternalTag).to receive(:current_transaction).and_return(transaction)
|
||||
allow(ExternalTag).to receive(:uncached).and_yield
|
||||
[:after_commit, :after_rollback].each do |event|
|
||||
allow(transaction).to receive(event) { |&callback| callbacks[event] = callback }
|
||||
end
|
||||
end
|
||||
|
||||
[:after_commit, :after_rollback].each do |event|
|
||||
it "holds the shared lock until #{ event }" do
|
||||
described_class.assign!(record)
|
||||
expect(record.id).to eq(9)
|
||||
expect(sql.grep(/RELEASE_LOCK/)).to be_empty
|
||||
expect(sql.grep(/FOR UPDATE/).size).to eq(2)
|
||||
callbacks.fetch(event).call
|
||||
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not silently proceed after an acquisition timeout' do
|
||||
allow(connection).to receive(:select_value).and_return(0)
|
||||
expect { described_class.assign!(record) }.to raise_error(ActiveRecord::LockWaitTimeout)
|
||||
expect(record.id).to be_nil
|
||||
expect(callbacks).to be_empty
|
||||
end
|
||||
|
||||
it 'releases a successfully acquired lock when allocation fails and rolls back' do
|
||||
allow(connection).to receive(:select_value).with(/FOR UPDATE/).and_raise('query failed')
|
||||
expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed')
|
||||
callbacks.fetch(:after_rollback).call
|
||||
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
|
||||
end
|
||||
|
||||
it 'preserves explicitly supplied historical IDs' do
|
||||
record.id = 5
|
||||
described_class.assign!(record)
|
||||
expect(record.id).to eq(5)
|
||||
expect(sql.grep(/FOR UPDATE/)).to be_empty
|
||||
callbacks.fetch(:after_commit).call
|
||||
end
|
||||
|
||||
it 'uses the same named lock within each space and different locks between spaces' do
|
||||
locks = [Tag, ExternalTag, TagDescription, TagGroup].map do |model|
|
||||
allow(model).to receive(:connection).and_return(connection)
|
||||
allow(model).to receive(:current_transaction).and_return(transaction)
|
||||
allow(model).to receive(:uncached).and_yield
|
||||
record = model.new(id: 50)
|
||||
described_class.assign!(record)
|
||||
statement = sql.grep(/GET_LOCK/).last
|
||||
callbacks.fetch(:after_commit).call
|
||||
statement
|
||||
end
|
||||
expect(locks[0]).to eq(locks[1])
|
||||
expect(locks[2]).to eq(locks[3])
|
||||
expect(locks[0]).not_to eq(locks[2])
|
||||
end
|
||||
|
||||
it 'reports lock release failures' do
|
||||
described_class.assign!(record)
|
||||
allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil)
|
||||
expect { callbacks.fetch(:after_commit).call }
|
||||
.to raise_error(ActiveRecord::ActiveRecordError, /lock release failed/)
|
||||
end
|
||||
end
|
||||
@@ -2,41 +2,42 @@ require 'digest'
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Wiki::Commit do
|
||||
include WikiRecords
|
||||
let(:user) { create_member_user! }
|
||||
|
||||
def create_page title:, body: 'initial body'
|
||||
described_class.create_content!(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag, primary_name: title).tag_name('ja'),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
end
|
||||
|
||||
describe '.create_content!' do
|
||||
it 'creates page, revision, and version with normalised body' do
|
||||
expect {
|
||||
described_class.create_content!(
|
||||
tag_name: create(:tag_name, name: 'commit_integrity_create'),
|
||||
body: "a\r\nb\r\n\r\n",
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
}
|
||||
.to change(WikiPage, :count).by(1)
|
||||
it 'rejects standalone Help creation without adding page or history' do
|
||||
name = create(:tag_name, name: 'Help:frozen_service')
|
||||
counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
|
||||
expect do
|
||||
described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user)
|
||||
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
|
||||
end
|
||||
|
||||
it 'keeps the internal compatibility writer available' do
|
||||
name = create(:tag_name, name: 'タググループ:compatibility_service')
|
||||
page = nil
|
||||
expect do
|
||||
page = described_class.create_legacy_content!(
|
||||
tag_name: name, body: 'initial', created_by_user: user)
|
||||
end.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
|
||||
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')
|
||||
expect(page.current_revision.body).to eq('initial')
|
||||
expect(page.wiki_versions.sole.body).to eq('initial')
|
||||
end
|
||||
|
||||
it 'rejects body that becomes blank after normalisation' do
|
||||
tag_name = create(:tag_name, name: 'commit_integrity_blank')
|
||||
tag_name = create(:tag, primary_name: 'commit_integrity_blank').tag_name('ja')
|
||||
|
||||
expect {
|
||||
described_class.create_content!(
|
||||
@@ -52,6 +53,17 @@ RSpec.describe Wiki::Commit do
|
||||
end
|
||||
|
||||
describe '.content!' do
|
||||
it 'rejects standalone Help updates without modifying histories' do
|
||||
page = seed_wiki_page(tag_name: create(:tag_name, name: 'Help:standalone'),
|
||||
body: 'before', created_by_user: user)
|
||||
counts = [WikiRevision.count, WikiVersion.count]
|
||||
expect do
|
||||
described_class.content!(page:, body: 'after', created_user: user)
|
||||
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect(page.reload.body).to eq('before')
|
||||
expect([WikiRevision.count, WikiVersion.count]).to eq(counts)
|
||||
end
|
||||
|
||||
it 'updates page body, revision, and version' do
|
||||
page = create_page(title: 'commit_integrity_update', body: 'before')
|
||||
current_id = page.current_revision.id
|
||||
@@ -80,7 +92,7 @@ RSpec.describe Wiki::Commit do
|
||||
tag = create(:tag, primary_tag_name: tag_name, category: :general)
|
||||
|
||||
page =
|
||||
described_class.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:,
|
||||
body: 'before',
|
||||
created_by_user: user,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Wiki::Commit do
|
||||
include WikiRecords
|
||||
let(:user) { create_member_user! }
|
||||
|
||||
def create_page(title: 'commit_spec_page', body: 'initial body')
|
||||
tag_name = create(:tag_name, name: title)
|
||||
tag_name = create(:tag, primary_name: title).tag_name('ja')
|
||||
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:,
|
||||
body:,
|
||||
created_by_user: user,
|
||||
@@ -84,7 +85,7 @@ RSpec.describe Wiki::Commit do
|
||||
tag = create(:tag, primary_tag_name: tag_name, category: :general)
|
||||
|
||||
page =
|
||||
described_class.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:,
|
||||
body: 'before',
|
||||
created_by_user: user,
|
||||
@@ -111,7 +112,7 @@ RSpec.describe Wiki::Commit do
|
||||
tag = create(:tag, primary_tag_name: tag_name, category: :general)
|
||||
|
||||
page =
|
||||
described_class.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:,
|
||||
body: 'before',
|
||||
created_by_user: user,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe WikiVersionRecorder do
|
||||
include WikiRecords
|
||||
let(:user) { create_member_user! }
|
||||
|
||||
def create_page title:, body: 'body'
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
require 'cgi'
|
||||
|
||||
# SQL fixtures use the migration contract without inventing application models.
|
||||
# Include explicitly; these helpers must not affect unrelated examples.
|
||||
module TagGroupRecords
|
||||
GROUP_TABLES = [
|
||||
'tag_groups', 'tag_group_tags', 'tag_group_groups',
|
||||
'tag_group_placements', 'tag_group_versions'].freeze
|
||||
WRITE_TABLES = GROUP_TABLES + [
|
||||
'wiki_pages', 'wiki_revisions', 'wiki_versions', 'wiki_lines',
|
||||
'wiki_revision_lines', 'tag_names', 'tag_description_versions',
|
||||
'tag_descriptions']
|
||||
|
||||
def group_connection = ApplicationRecord.connection
|
||||
|
||||
def group_rows table, conditions = { }
|
||||
clauses = conditions.map do |column, value|
|
||||
"#{ group_connection.quote_column_name(column) } = #{ group_connection.quote(value) }"
|
||||
end
|
||||
sql = "SELECT * FROM #{ group_connection.quote_table_name(table) }"
|
||||
sql += " WHERE #{ clauses.join(' AND ') }" if clauses.any?
|
||||
group_connection.select_all(sql).to_a.sort_by { |row| row.values.map(&:to_s) }
|
||||
end
|
||||
|
||||
def insert_group_row table, attributes
|
||||
columns = attributes.keys.map { group_connection.quote_column_name(_1) }.join(', ')
|
||||
values = attributes.values.map { group_connection.quote(_1) }.join(', ')
|
||||
group_connection.insert(
|
||||
"INSERT INTO #{ group_connection.quote_table_name(table) } (#{ columns }) " +
|
||||
"VALUES (#{ values })")
|
||||
end
|
||||
|
||||
def group_state
|
||||
WRITE_TABLES.to_h { |table| [table, group_rows(table)] }
|
||||
end
|
||||
|
||||
def expect_noop_group_update group_id:, page:
|
||||
before_state = group_state
|
||||
group = TagGroup.find(group_id)
|
||||
version_id = TagGroupVersion.find_by!(
|
||||
tag_group_id: group_id, version_no: group.version_no).id
|
||||
revision_id = page.reload.current_revision.id
|
||||
wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id
|
||||
writes = []
|
||||
tables = Regexp.union(WRITE_TABLES)
|
||||
subscriber = lambda do |*args|
|
||||
sql = args.last.fetch(:sql)
|
||||
if sql.match?(/\A\s*(?:INSERT INTO|UPDATE|DELETE FROM)\s+`?(?:#{ tables })`?\s/i)
|
||||
writes << sql
|
||||
end
|
||||
end
|
||||
|
||||
aggregate_failures 'no-op current state, history and SQL writes' do
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
|
||||
expect(response).to have_http_status(:ok)
|
||||
after_state = group_state
|
||||
before_state.each do |table, rows|
|
||||
expect(after_state.fetch(table)).to eq(rows), "No-op changed #{ table }"
|
||||
end
|
||||
expect(TagGroupVersion.find_by!(
|
||||
tag_group_id: group_id, version_no: group.reload.version_no).id).to eq(version_id)
|
||||
expect(page.reload.current_revision.id).to eq(revision_id)
|
||||
expect(page.wiki_versions.order(version_no: :desc).first.id).to eq(wiki_version_id)
|
||||
expect(writes).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
def expect_atomic_group_write table
|
||||
before_state = group_state
|
||||
failure = Class.new(StandardError)
|
||||
injected = false
|
||||
subscriber = lambda do |*args|
|
||||
sql = args.last.fetch(:sql)
|
||||
next unless sql.match?(/\A\s*(?:INSERT INTO|UPDATE)\s+`?#{ table }`?\s/i)
|
||||
|
||||
injected = true
|
||||
raise failure, 'injected after SQL write'
|
||||
end
|
||||
begin
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
|
||||
rescue failure
|
||||
# Inspect the database after the operation's transaction unwinds.
|
||||
end
|
||||
aggregate_failures do
|
||||
expect(injected).to be(true), "No INSERT/UPDATE reached #{ table }"
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
def group_version group_id, number
|
||||
row = group_rows('tag_group_versions', tag_group_id: group_id, version_no: number).sole
|
||||
row.merge('members' => JSON.parse(row.fetch('members')),
|
||||
'placements' => JSON.parse(row.fetch('placements')))
|
||||
end
|
||||
|
||||
def group_tag name
|
||||
tag = create(:tag, primary_name: name)
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: user)
|
||||
tag.reload
|
||||
end
|
||||
|
||||
def legacy_group name, body
|
||||
# Historical fixtures preserve blank lines independently of today's writer.
|
||||
body = body.sub(/\n+\z/, '')
|
||||
page = WikiPage.create!(
|
||||
tag_name: create(:tag_name, name: "タググループ:#{ name }"),
|
||||
body:, created_user: user, updated_user: user)
|
||||
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: user)
|
||||
lines = body.split("\n")
|
||||
shas = lines.map { Digest::SHA256.hexdigest(_1) }
|
||||
revision = WikiRevision.create!(
|
||||
wiki_page: page, kind: :content, created_user: user, lines_count: lines.size,
|
||||
tree_sha256: Digest::SHA256.hexdigest(shas.join(',')))
|
||||
lines.each_with_index do |line, position|
|
||||
sha = shas.fetch(position)
|
||||
line_id = WikiLine.find_by(sha256: sha)&.id || insert_group_row('wiki_lines', {
|
||||
body: line, sha256: sha, created_at: Time.current, updated_at: Time.current })
|
||||
WikiRevisionLine.create!(wiki_revision: revision, wiki_line_id: line_id, position:)
|
||||
end
|
||||
page
|
||||
end
|
||||
|
||||
def seed_group name:, placements: [], members: [], description: nil, id: nil
|
||||
now = Time.current.change(usec: 0)
|
||||
attributes = {
|
||||
name:, description:, version_no: 1,
|
||||
created_by_user_id: user.id, updated_by_user_id: user.id,
|
||||
created_at: now, updated_at: now }
|
||||
attributes[:id] = id if id
|
||||
group_id = insert_group_row('tag_groups', attributes)
|
||||
insert_group_row('tag_group_versions', {
|
||||
tag_group_id: group_id, version_no: 1, event_type: 'create',
|
||||
name:, description:, members: members.to_json, placements: placements.to_json,
|
||||
created_by_user_id: user.id, created_at: now })
|
||||
members.each do |member|
|
||||
tag_member = member.key?('tag_id')
|
||||
table = tag_member ? 'tag_group_tags' : 'tag_group_groups'
|
||||
key = tag_member ? :tag_id : :child_tag_group_id
|
||||
value = member.fetch(tag_member ? 'tag_id' : 'tag_group_id')
|
||||
insert_group_row(table, {
|
||||
tag_group_id: group_id, key => value,
|
||||
created_by_user_id: user.id, created_at: now })
|
||||
end
|
||||
position = 0
|
||||
append = lambda do |nodes, parent|
|
||||
nodes.each do |node|
|
||||
position += 1
|
||||
own_position = position
|
||||
insert_group_row('tag_group_placements', {
|
||||
tag_group_id: group_id, position:, parent_position: parent,
|
||||
section_name: node['section_name'], description: node['description'],
|
||||
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'],
|
||||
created_by_user_id: user.id, updated_by_user_id: user.id,
|
||||
created_at: now, updated_at: now })
|
||||
append.call(node.fetch('children'), own_position)
|
||||
end
|
||||
end
|
||||
append.call(placements, nil)
|
||||
group_id
|
||||
end
|
||||
|
||||
def without_group_legacy_reads
|
||||
queries = []
|
||||
subscriber = lambda do |*args|
|
||||
sql = args.last.fetch(:sql)
|
||||
if (sql.match?(/\bSELECT\b/i) &&
|
||||
sql.match?(/\bwiki_(pages|revisions|versions|lines|revision_lines)\b/i))
|
||||
queries << sql
|
||||
end
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
|
||||
expect(queries).to be_empty
|
||||
end
|
||||
|
||||
def group_title_path name
|
||||
"/wiki/title/#{ CGI.escapeURIComponent("タググループ:#{ name }") }"
|
||||
end
|
||||
|
||||
def migrate_groups
|
||||
require_relative '../../db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages'
|
||||
migration = MigrateTagGroupsFromWikiPages.new
|
||||
@group_migration_messages = []
|
||||
allow(migration).to receive(:say) do |message, *_args|
|
||||
@group_migration_messages << message if message.start_with?('Wiki #', 'Tag Group')
|
||||
end
|
||||
migration.suppress_messages { migration.up }
|
||||
end
|
||||
|
||||
def expect_group_snapshot group_id, number:, placements:, members:, description: nil
|
||||
current = group_rows('tag_groups', id: group_id).sole
|
||||
version = group_version(group_id, number)
|
||||
expect(current).to include('version_no' => number, 'description' => description),
|
||||
@group_migration_messages&.join("\n")
|
||||
expect(version).to include(
|
||||
'name' => current.fetch('name'), 'description' => description,
|
||||
'version_no' => number, 'placements' => placements)
|
||||
expect(version.fetch('members')).to match_array(members)
|
||||
expect(group_rows('tag_group_tags', tag_group_id: group_id).map { _1.fetch('tag_id') })
|
||||
.to match_array(members.filter_map { _1['tag_id'] })
|
||||
children = group_rows('tag_group_groups', tag_group_id: group_id)
|
||||
expect(children.map { _1.fetch('child_tag_group_id') })
|
||||
.to match_array(members.filter_map { _1['tag_group_id'] })
|
||||
|
||||
rows = group_rows('tag_group_placements', tag_group_id: group_id)
|
||||
tree = lambda do |parent|
|
||||
rows.select { _1['parent_position'] == parent }.sort_by { _1.fetch('position') }
|
||||
.map do |row|
|
||||
node = if row['tag_id']
|
||||
{ 'tag_id' => row.fetch('tag_id') }
|
||||
elsif row['child_tag_group_id']
|
||||
{ 'tag_group_id' => row.fetch('child_tag_group_id') }
|
||||
else
|
||||
{ 'section_name' => row.fetch('section_name'), 'description' => row['description'] }
|
||||
end
|
||||
node.merge('children' => tree.call(row.fetch('position')))
|
||||
end
|
||||
end
|
||||
expect(tree.call(nil)).to eq(placements)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
# Historical records must remain seedable after application creation is frozen.
|
||||
module WikiRecords
|
||||
def seed_wiki_page(tag_name:, body:, created_by_user:, message: nil)
|
||||
page = WikiPage.create!(tag_name:, body:,
|
||||
created_user: created_by_user, updated_user: created_by_user)
|
||||
revision = Wiki::Commit.legacy_content!(
|
||||
page:, body:, created_user: created_by_user, message:)
|
||||
if tag_name.tag && tag_name.language_code == 'ja'
|
||||
TagDescription.create!(
|
||||
id: page.id, tag: tag_name.tag, language_code: 'ja', body: page.reload.body,
|
||||
version_no: 1, created_by_user_id: created_by_user.id,
|
||||
updated_by_user_id: created_by_user.id)
|
||||
TagDescriptionVersion.create!(
|
||||
id: revision.id, tag_id: tag_name.tag_id, language_code: 'ja',
|
||||
version_no: 1, event_type: 'create', body: page.body,
|
||||
created_by_user_id: created_by_user.id, created_at: revision.created_at)
|
||||
end
|
||||
page
|
||||
end
|
||||
end
|
||||
@@ -18,8 +18,8 @@ RSpec.describe 'nico:export' do
|
||||
before do
|
||||
task.reenable
|
||||
|
||||
allow(ENV).to receive(:fetch).with('MYSQL_USER').and_return('mysql-user')
|
||||
allow(ENV).to receive(:fetch).with('MYSQL_PASS').and_return('mysql-pass')
|
||||
allow(ENV).to receive(:fetch).with('NIZIKA_NICO_MYSQL_USER').and_return('mysql-user')
|
||||
allow(ENV).to receive(:fetch).with('NIZIKA_NICO_MYSQL_PASS').and_return('mysql-pass')
|
||||
allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico')
|
||||
end
|
||||
|
||||
@@ -33,8 +33,8 @@ RSpec.describe 'nico:export' do
|
||||
expect(Open3).to receive(:capture3) do |env, *args, **kwargs|
|
||||
expect(env).to eq(
|
||||
{
|
||||
'MYSQL_USER' => 'mysql-user',
|
||||
'MYSQL_PASS' => 'mysql-pass',
|
||||
'NIZIKA_NICO_MYSQL_USER' => 'mysql-user',
|
||||
'NIZIKA_NICO_MYSQL_PASS' => 'mysql-pass',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
/help
|
||||
|
||||
生成ファイル
+4
-43
@@ -38,6 +38,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"unist-util-visit-parents": "^6.0.1",
|
||||
"yaml": "^2.9.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2060,9 +2061,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2077,9 +2075,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2094,9 +2089,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2111,9 +2103,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2128,9 +2117,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2145,9 +2131,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2162,9 +2145,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2179,9 +2159,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2196,9 +2173,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2213,9 +2187,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2230,9 +2201,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2247,9 +2215,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2264,9 +2229,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -9114,10 +9076,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"dev": true,
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"unist-util-visit-parents": "^6.0.1",
|
||||
"yaml": "^2.9.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { generateHelpAssets } from './help-assets'
|
||||
|
||||
import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help'
|
||||
|
||||
vi.hoisted (() => {
|
||||
vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor)
|
||||
})
|
||||
afterAll (() => vi.unstubAllGlobals ())
|
||||
|
||||
|
||||
const readAsset = <T,> (modules: Map<string, string>, name: string): T => {
|
||||
const source = modules.get (`virtual:help/${ name }`)!
|
||||
const argument = source.slice ('export default JSON.parse('.length, -1)
|
||||
return JSON.parse (JSON.parse (argument))
|
||||
}
|
||||
|
||||
|
||||
describe ('Build-time Help assets', () => {
|
||||
let root: string
|
||||
|
||||
beforeEach (async () => {
|
||||
root = await mkdtemp (path.join (tmpdir (), 'btrc-help-assets-'))
|
||||
})
|
||||
|
||||
afterEach (async () => {
|
||||
await rm (root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const writeArticle = async (
|
||||
identifier: string,
|
||||
editions: Record<string, string>,
|
||||
current = '2026-10-02.10',
|
||||
) => {
|
||||
const articleRoot = path.join (root, identifier)
|
||||
const editionRoot = path.join (articleRoot, 'editions', 'ja')
|
||||
await mkdir (editionRoot, { recursive: true })
|
||||
await writeFile (path.join (articleRoot, 'article.yml'), [
|
||||
`identifier: ${ identifier }`, 'canonical_language: ja', 'title: stale YAML title',
|
||||
'languages:', ' ja:', ` current_edition: ${ current }`].join ('\n'))
|
||||
await Promise.all (Object.entries (editions).map (([edition, source]) =>
|
||||
writeFile (path.join (editionRoot, `${ edition }.mdx`), source)))
|
||||
}
|
||||
|
||||
it ('generates current search and ordered published history from each edition H1', async () => {
|
||||
await writeArticle ('guide', {
|
||||
'2026-10-01': '# 旧ホーム\n\n過去本文',
|
||||
'2026-10-02': '# ホーム\n\n最初の同日版',
|
||||
'2026-10-02.2': '# ホーム\n\n第二版',
|
||||
'2026-10-02.3': '# ホーム\n\n第三版',
|
||||
'2026-10-02.10': '\uFEFF# 最新ホーム\r\n\r\n現在本文\r\n# 別見出し',
|
||||
'2026-10-02.11': '# 未公開\n\n検索に含めない' })
|
||||
const { modules } = await generateHelpAssets (root)
|
||||
const articles = readAsset<Record<string, ArticleMeta>> (modules, 'articles')
|
||||
expect (articles.guide.languages.ja).toEqual ({
|
||||
currentEdition: '2026-10-02.10', publishedEditions: [
|
||||
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] })
|
||||
expect (readAsset<Record<string, HelpSearchEntry[]>> (modules, 'search').ja).toEqual ([{
|
||||
identifier: 'guide', edition: '2026-10-02.10', title: '最新ホーム',
|
||||
body: '\r\n現在本文\r\n# 別見出し' }])
|
||||
const changes = readAsset<Record<string, HelpChange[]>> (modules, 'changes').ja
|
||||
expect (changes.map (row => [row.edition, row.title, row.pred, row.change, row.date]))
|
||||
.toEqual ([
|
||||
['2026-10-02.10', '最新ホーム', '2026-10-02.3', 'update', '2026-10-02'],
|
||||
['2026-10-02.3', 'ホーム', '2026-10-02.2', 'update', '2026-10-02'],
|
||||
['2026-10-02.2', 'ホーム', '2026-10-02', 'update', '2026-10-02'],
|
||||
['2026-10-02', 'ホーム', '2026-10-01', 'update', '2026-10-02'],
|
||||
['2026-10-01', '旧ホーム', null, 'new', '2026-10-01']])
|
||||
const titles = readAsset<Record<string, HelpTitleIndex>> (modules, 'titles').ja
|
||||
expect (titles).toEqual ({ '旧ホーム': ['guide'], 'ホーム': ['guide'],
|
||||
'最新ホーム': ['guide'] })
|
||||
expect (modules.get ('virtual:help/loaders')).not.toContain ('2026-10-02.11')
|
||||
expect (modules.get ('virtual:help/loaders')).toContain ('2026-10-02.10.mdx?raw')
|
||||
expect (modules.get ('virtual:help/search')).not.toContain ('import(')
|
||||
})
|
||||
|
||||
it ('retains ambiguous title identifiers and normalises width and case', async () => {
|
||||
await writeArticle ('alpha', { '2026-10-02': '# HOME\n本文' }, '2026-10-02')
|
||||
await writeArticle ('beta', { '2026-10-02': '# Home\n本文' }, '2026-10-02')
|
||||
const { modules } = await generateHelpAssets (root)
|
||||
expect (readAsset<Record<string, HelpTitleIndex>> (modules, 'titles').ja.home)
|
||||
.toEqual (['alpha', 'beta'])
|
||||
expect (readAsset<Record<string, HelpChange[]>> (modules, 'changes').ja
|
||||
.map (row => row.identifier)).toEqual (['alpha', 'beta'])
|
||||
})
|
||||
|
||||
it ('rejects missing current files during generation', async () => {
|
||||
await writeArticle ('guide', { '2026-10-02': '# ホーム' })
|
||||
await expect (generateHelpAssets (root)).rejects.toThrow ('Missing current Help edition')
|
||||
})
|
||||
|
||||
it.each (['本文\n# 遅い見出し', '# \n本文']) (
|
||||
'rejects missing leading H1 at build time: %s', async source => {
|
||||
await writeArticle ('guide', { '2026-10-02.10': source })
|
||||
await expect (generateHelpAssets (root)).rejects.toThrow ('Missing leading Help H1')
|
||||
})
|
||||
|
||||
it ('rejects absent canonical metadata instead of generating incomplete assets', async () => {
|
||||
await writeArticle ('guide', { '2026-10-02.10': '# ホーム' })
|
||||
await writeFile (path.join (root, 'guide', 'article.yml'),
|
||||
'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02.10')
|
||||
await expect (generateHelpAssets (root)).rejects.toThrow ('Invalid Help metadata')
|
||||
})
|
||||
|
||||
it ('rejects an empty Help checkout', async () => {
|
||||
await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { normalizePath as normalisePath } from 'vite'
|
||||
import { parse as parseYAML } from 'yaml'
|
||||
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
type Language = { currentEdition: string; publishedEditions: string[] }
|
||||
type Article = {
|
||||
identifier: string
|
||||
canonicalLanguage: string
|
||||
languages: Record<string, Language> }
|
||||
type Text = { title: string; body: string }
|
||||
type SearchEntry = Text & { identifier: string; edition: string }
|
||||
type Change = {
|
||||
identifier: string
|
||||
edition: string
|
||||
title: string
|
||||
pred: string | null
|
||||
change: 'new' | 'update'
|
||||
date: string }
|
||||
type RawArticle = {
|
||||
identifier: string
|
||||
canonical_language: string
|
||||
languages: Record<string, { current_edition: string }> }
|
||||
|
||||
const prefix = 'virtual:help/'
|
||||
|
||||
|
||||
const compareEditions = (a: string, b: string): number => {
|
||||
const [dateA, revisionA = '1'] = a.split ('.')
|
||||
const [dateB, revisionB = '1'] = b.split ('.')
|
||||
return dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB)
|
||||
}
|
||||
|
||||
|
||||
const validateEdition = (edition: string): void => {
|
||||
const [date, revision = '1'] = edition.split ('.')
|
||||
if (!(/^\d{4}-\d{2}-\d{2}(?:\.[1-9]\d*)?$/.test (edition))
|
||||
|| new Date (date).toISOString ().slice (0, 10) !== date
|
||||
|| !(Number.isSafeInteger (Number (revision))))
|
||||
throw new Error (`Invalid Help edition: ${ edition }`)
|
||||
}
|
||||
|
||||
|
||||
export const generateHelpAssets = async (
|
||||
root: string,
|
||||
): Promise<{ modules: Map<string, string>; files: string[] }> => {
|
||||
const modules = new Map<string, string> ()
|
||||
const files: string[] = [root]
|
||||
const articles: Record<string, Article> = Object.create (null)
|
||||
const search: Record<string, SearchEntry[]> = Object.create (null)
|
||||
const changes: Record<string, Change[]> = Object.create (null)
|
||||
const titles: Record<string, Record<string, string[]>> = Object.create (null)
|
||||
const components: string[] = []
|
||||
const sources: string[] = []
|
||||
const texts: string[] = []
|
||||
const directories = (await readdir (root, { withFileTypes: true }))
|
||||
.filter (entry => entry.isDirectory () && !(entry.name.startsWith ('.')))
|
||||
.sort ((a, b) => a.name.localeCompare (b.name))
|
||||
|
||||
for (const directory of directories)
|
||||
{
|
||||
const articlePath = path.join (root, directory.name, 'article.yml')
|
||||
files.push (articlePath)
|
||||
const raw = parseYAML (await readFile (articlePath, 'utf8')) as RawArticle | null
|
||||
if (raw == null || raw.identifier !== directory.name
|
||||
|| typeof raw.canonical_language !== 'string'
|
||||
|| raw.languages == null || typeof raw.languages !== 'object'
|
||||
|| !(Object.hasOwn (raw.languages, raw.canonical_language)))
|
||||
throw new Error (`Invalid Help metadata: ${ articlePath }`)
|
||||
|
||||
const article: Article = {
|
||||
identifier: raw.identifier,
|
||||
canonicalLanguage: raw.canonical_language,
|
||||
languages: Object.create (null) }
|
||||
articles[raw.identifier] = article
|
||||
|
||||
for (const [language, metadata] of Object.entries (raw.languages))
|
||||
{
|
||||
if (!(/^[a-zA-Z0-9-]+$/.test (language))
|
||||
|| typeof metadata?.current_edition !== 'string')
|
||||
throw new Error (`Invalid Help language: ${ articlePath }/${ language }`)
|
||||
|
||||
const currentEdition = metadata.current_edition
|
||||
validateEdition (currentEdition)
|
||||
const editionRoot = path.join (root, directory.name, 'editions', language)
|
||||
files.push (editionRoot)
|
||||
const editions = (await readdir (editionRoot)).filter (name => name.endsWith ('.mdx'))
|
||||
.map (name => name.slice (0, -4))
|
||||
editions.forEach (validateEdition)
|
||||
if (!(editions.includes (currentEdition)))
|
||||
throw new Error (`Missing current Help edition: ${ editionRoot }/${ currentEdition }`)
|
||||
|
||||
const publishedEditions = editions
|
||||
.filter (edition => compareEditions (edition, currentEdition) <= 0)
|
||||
.sort (compareEditions)
|
||||
article.languages[language] = { currentEdition, publishedEditions }
|
||||
search[language] ??= []
|
||||
changes[language] ??= []
|
||||
titles[language] ??= Object.create (null)
|
||||
|
||||
for (const [index, edition] of publishedEditions.entries ())
|
||||
{
|
||||
const sourcePath = path.join (editionRoot, `${ edition }.mdx`)
|
||||
files.push (sourcePath)
|
||||
const mdx = (await readFile (sourcePath, 'utf8')).replace (/^\uFEFF/, '')
|
||||
const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/)
|
||||
if (heading == null || !(heading[1].trim ()))
|
||||
throw new Error (`Missing leading Help H1: ${ sourcePath }`)
|
||||
|
||||
const text = { title: heading[1].trim (), body: mdx.slice (heading[0].length) }
|
||||
const key = JSON.stringify (`${ raw.identifier }/${ language }/${ edition }`)
|
||||
const textId = `${ prefix }text/${ raw.identifier }/${ language }/${ edition }`
|
||||
const componentId = JSON.stringify (normalisePath (sourcePath))
|
||||
const sourceId = JSON.stringify (normalisePath (sourcePath) + '?raw')
|
||||
modules.set (textId, `export default ${ JSON.stringify (text) }`)
|
||||
texts.push (`${ key }: () => import(${ JSON.stringify (textId) })`)
|
||||
components.push (`${ key }: () => import(${ componentId })`)
|
||||
sources.push (`${ key }: () => import(${ sourceId })`)
|
||||
|
||||
if (edition === currentEdition)
|
||||
search[language].push ({ identifier: raw.identifier, edition, ...text })
|
||||
changes[language].push ({ identifier: raw.identifier, edition, title: text.title,
|
||||
pred: publishedEditions[index - 1] ?? null,
|
||||
change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] })
|
||||
const title = text.title.trim ().normalize ('NFKC').toLowerCase ()
|
||||
const identifiers = titles[language][title] ??= []
|
||||
if (!(identifiers.includes (raw.identifier)))
|
||||
identifiers.push (raw.identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys (articles).length === 0)
|
||||
throw new Error (`No Help articles found: ${ root }`)
|
||||
|
||||
Object.values (changes).forEach (rows => rows.sort ((a, b) =>
|
||||
compareEditions (b.edition, a.edition) || a.identifier.localeCompare (b.identifier)))
|
||||
for (const [name, data] of Object.entries ({ articles, search, changes, titles }))
|
||||
{
|
||||
modules.set (`${ prefix }${ name }`,
|
||||
`export default JSON.parse(${ JSON.stringify (JSON.stringify (data)) })`)
|
||||
}
|
||||
modules.set (`${ prefix }loaders`, [
|
||||
`export const components = { ${ components.join (',\n') } };`,
|
||||
`export const sources = { ${ sources.join (',\n') } };`,
|
||||
`export const texts = { ${ texts.join (',\n') } };`].join ('\n'))
|
||||
return { modules, files }
|
||||
}
|
||||
|
||||
|
||||
export const helpAssets = (): Plugin => {
|
||||
const state: { root: string; data?: ReturnType<typeof generateHelpAssets> } = { root: '' }
|
||||
const data = () => state.data ??= generateHelpAssets (state.root)
|
||||
|
||||
return {
|
||||
name: 'help-assets',
|
||||
configResolved (config) {
|
||||
state.root = path.resolve (config.root, 'help')
|
||||
},
|
||||
async buildStart () {
|
||||
state.data = generateHelpAssets (state.root)
|
||||
const generated = await data ()
|
||||
generated.files.forEach (file => this.addWatchFile (file))
|
||||
},
|
||||
resolveId (id) {
|
||||
return id.startsWith (prefix) ? `\0${ id }` : null
|
||||
},
|
||||
async load (id) {
|
||||
if (!(id.startsWith (`\0${ prefix }`)))
|
||||
return null
|
||||
const source = (await data ()).modules.get (id.slice (1))
|
||||
if (source == null)
|
||||
throw new Error (`Unknown Help asset: ${ id.slice (1) }`)
|
||||
return source
|
||||
},
|
||||
configureServer (server) {
|
||||
server.watcher.add (state.root)
|
||||
server.watcher.on ('all', (_event, file) => {
|
||||
if (!(file.startsWith (state.root + path.sep)) || !(/\.(mdx|yml)$/.test (file)))
|
||||
return
|
||||
state.data = undefined
|
||||
for (const module of server.moduleGraph.idToModuleMap.values ())
|
||||
{
|
||||
if (module.id?.startsWith (`\0${ prefix }`))
|
||||
server.moduleGraph.invalidateModule (module)
|
||||
}
|
||||
server.ws.send ({ type: 'full-reload' })
|
||||
})
|
||||
} }
|
||||
}
|
||||
+19
-43
@@ -15,7 +15,6 @@ import { Toaster } from '@/components/ui/toaster'
|
||||
import { apiPost, isApiError } from '@/lib/api'
|
||||
import { applyClientAnimationMode,
|
||||
applyClientAppearance,
|
||||
type ClientAnimationMode,
|
||||
fetchUserThemeSlots,
|
||||
fetchUserSettings,
|
||||
getClientThemeMode,
|
||||
@@ -25,7 +24,6 @@ import { applyClientAnimationMode,
|
||||
seedClientThemeMode } from '@/lib/settings'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
|
||||
|
||||
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
||||
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
||||
@@ -34,12 +32,15 @@ import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
|
||||
import MaterialListPage from '@/pages/materials/MaterialListPage'
|
||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
|
||||
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
||||
import MorePage from '@/pages/MorePage'
|
||||
import GekanatorPage from '@/pages/GekanatorPage'
|
||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
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 PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
@@ -60,6 +61,7 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
||||
|
||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||
|
||||
import type { ClientAnimationMode } from '@/lib/settings'
|
||||
import type { User } from '@/types'
|
||||
|
||||
|
||||
@@ -69,9 +71,7 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||
setUser: Dispatch<SetStateAction<User | null>> }) => {
|
||||
const location = useLocation ()
|
||||
|
||||
if (animationMode === 'off')
|
||||
{
|
||||
return (
|
||||
const routes = (
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<PostListPage/>}/>
|
||||
@@ -99,6 +99,10 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
||||
<Route path="/wiki/:id/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/>}/>
|
||||
@@ -106,44 +110,13 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||
<Route path="/more" element={<MorePage/>}/>
|
||||
<Route path="*" element={<NotFound/>}/>
|
||||
</Routes>)
|
||||
}
|
||||
|
||||
if (animationMode === 'off')
|
||||
return routes
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<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>
|
||||
{routes}
|
||||
</AnimatePresence>)
|
||||
}
|
||||
|
||||
@@ -166,9 +139,9 @@ const RoutedApp: FC = () => {
|
||||
() => (
|
||||
animationMode === 'off'
|
||||
? { duration: 0 }
|
||||
: animationMode === 'reduced'
|
||||
: (animationMode === 'reduced'
|
||||
? { duration: .08, ease: 'linear' as const }
|
||||
: { duration: .2, ease: 'easeOut' as const }
|
||||
: { duration: .2, ease: 'easeOut' as const })
|
||||
),
|
||||
[animationMode],
|
||||
)
|
||||
@@ -285,10 +258,12 @@ const RoutedApp: FC = () => {
|
||||
</DialogueProvider>)
|
||||
}
|
||||
|
||||
|
||||
const router = createBrowserRouter ([{
|
||||
path: '*',
|
||||
element: <RoutedApp/> }])
|
||||
|
||||
|
||||
const App: FC = () => (
|
||||
<>
|
||||
<RouteBlockerOverlay/>
|
||||
@@ -296,4 +271,5 @@ const App: FC = () => (
|
||||
<RouterProvider router={router}/>
|
||||
</>)
|
||||
|
||||
|
||||
export default App
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { FC, MouseEvent } from 'react'
|
||||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
||||
|
||||
|
||||
export const menuOutline = (
|
||||
@@ -40,6 +41,9 @@ export const menuOutline = (
|
||||
|
||||
const materialFlg = /^\/materials\/\d+/.test (pathName)
|
||||
|
||||
const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName)
|
||||
const helpIdentifier = pathName.split ('/')[2] ?? ''
|
||||
|
||||
return [
|
||||
{ name: '広場', to: '/posts', subMenu: [
|
||||
{ name: '一覧', to: '/posts' },
|
||||
@@ -71,7 +75,7 @@ export const menuOutline = (
|
||||
visible: materialFlg && Boolean (material?.tag) },
|
||||
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
|
||||
visible: materialFlg }] },
|
||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||||
{ name: 'Wiki', to: '/wiki', subMenu: [
|
||||
{ name: '検索', to: '/wiki' },
|
||||
{ name: '新規', to: '/wiki/new', visible: editable },
|
||||
{ name: '全体履歴', to: '/wiki/changes' },
|
||||
@@ -82,6 +86,13 @@ export const menuOutline = (
|
||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||
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: '上映会 (β)', to: '/theatres/1' },
|
||||
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
||||
@@ -104,6 +115,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
const dirRef = useRef<(-1) | 1> (1)
|
||||
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
||||
const navRef = useRef<HTMLDivElement | null> (null)
|
||||
const layoutRef = useRef<HTMLDivElement | null> (null)
|
||||
const barRef = useRef<HTMLElement | null> (null)
|
||||
|
||||
const measure = (idx: number) => {
|
||||
const nav = navRef.current
|
||||
@@ -128,6 +141,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
width: 0,
|
||||
visible: false })
|
||||
const [menuOpen, setMenuOpen] = useState (false)
|
||||
const [desktop, setDesktop] = useState (false)
|
||||
const [density, setDensity] = useState<NavDensity> ('auto')
|
||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||||
@@ -194,15 +209,81 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
const dir = dirRef.current
|
||||
|
||||
useLayoutEffect (() => {
|
||||
const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
||||
const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
||||
const layout = layoutRef.current
|
||||
const bar = barRef.current
|
||||
const primary = bar?.querySelector<HTMLElement> ('.top-nav-primary')
|
||||
if (layout == null || bar == null || primary == null)
|
||||
return
|
||||
|
||||
addEventListener ('resize', onResize)
|
||||
return () => {
|
||||
cancelAnimationFrame (raf)
|
||||
removeEventListener ('resize', onResize)
|
||||
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)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
cancelAnimationFrame (raf)
|
||||
observer?.disconnect ()
|
||||
removeEventListener ('resize', schedule)
|
||||
document.fonts?.removeEventListener ('loadingdone', schedule)
|
||||
}
|
||||
}, [activeIdx, moreVsbl, desktop, density, user?.name])
|
||||
|
||||
useEffect (() => {
|
||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||
@@ -215,19 +296,22 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
}, [activeIdx, location])
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="top-nav-root px-3 flex justify-between items-center w-full">
|
||||
<div className="flex items-center gap-2 h-12">
|
||||
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
||||
data-desktop={desktop} data-density={density}>
|
||||
<nav ref={barRef}
|
||||
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
|
||||
<div className="top-nav-primary flex shrink-0 items-center h-12">
|
||||
<PrefetchLink
|
||||
to="/posts"
|
||||
className="top-nav-brand-link mx-4 text-xl font-bold"
|
||||
className="top-nav-brand-link shrink-0 whitespace-nowrap font-bold"
|
||||
onClick={() => {
|
||||
scroll (0, 0)
|
||||
}}>
|
||||
ぼざクリ タグ広場
|
||||
</PrefetchLink>
|
||||
|
||||
<div ref={navRef} className="relative hidden md:flex h-12 items-center">
|
||||
<div ref={navRef}
|
||||
className="top-nav-desktop-menu relative shrink-0 h-12 items-center">
|
||||
<div aria-hidden
|
||||
className={cn ('absolute inset-y-0 h-12',
|
||||
'top-nav-highlight',
|
||||
@@ -239,6 +323,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
{visibleMenu.map ((item, i) => (
|
||||
<motion.div
|
||||
key={item.to}
|
||||
className="shrink-0 whitespace-nowrap"
|
||||
{...(animationsOff
|
||||
? { }
|
||||
: { layoutId: `menu-${ item.name }` })}
|
||||
@@ -252,7 +337,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
ref={(el: (HTMLAnchorElement | null)) => {
|
||||
itemsRef.current[i] = el
|
||||
}}
|
||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
|
||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
||||
(i === openItemIdx) && 'font-bold')}>
|
||||
{item.name}
|
||||
</PrefetchLink>
|
||||
@@ -267,7 +352,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
setMoreVsbl (true)
|
||||
measure (-1)
|
||||
}}
|
||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
|
||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
||||
'shrink-0 whitespace-nowrap',
|
||||
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
||||
その他 »
|
||||
</PrefetchLink>
|
||||
@@ -278,7 +364,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4"
|
||||
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||
ml-auto border-0 bg-transparent pr-4"
|
||||
onClick={() => {
|
||||
setMenuOpen (!(menuOpen))
|
||||
}}>
|
||||
@@ -291,7 +378,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
key="submenu-shell"
|
||||
{...(animationsOff ? { }
|
||||
: { layout: true })}
|
||||
className="relative z-20 hidden md:block overflow-hidden top-nav-submenu"
|
||||
className="top-nav-desktop-submenu relative z-20 shrink-0
|
||||
overflow-hidden top-nav-submenu"
|
||||
animate={{ height: submenuHeight }}
|
||||
onMouseLeave={() => {
|
||||
if (moreVsbl)
|
||||
@@ -305,7 +393,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? (
|
||||
moreMenu.map ((item, i) => (
|
||||
<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
|
||||
transition={topNavTransition}
|
||||
{...(animationsOff
|
||||
@@ -315,7 +404,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||
animate: { x: 0, y: 0, opacity: 1 },
|
||||
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||
className="z-10 h-full flex items-center px-3 font-bold w-28">
|
||||
className="z-10 h-full flex shrink-0 items-center px-3 font-bold w-28">
|
||||
<h2>{item.name}</h2>
|
||||
</motion.div>
|
||||
{item.subMenu
|
||||
@@ -363,7 +452,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
<div className="relative h-[40px]">
|
||||
{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 ?? [])
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => (
|
||||
@@ -392,7 +482,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||
centre: { y: 0, opacity: 1 },
|
||||
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"
|
||||
animate="centre"
|
||||
exit="exit"
|
||||
@@ -432,7 +523,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? (
|
||||
menuOpen && (
|
||||
<div
|
||||
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
|
||||
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
|
||||
'items-start')}>
|
||||
<Separator/>
|
||||
{visibleMenu.map ((item, i) => (
|
||||
@@ -476,9 +567,6 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
</Fragment>))}
|
||||
<PrefetchLink
|
||||
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',
|
||||
((openItemIdx < 0)
|
||||
&& 'top-nav-mobile-active font-bold'))}>
|
||||
@@ -492,7 +580,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
key="spmenu"
|
||||
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
|
||||
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
|
||||
'items-start')}
|
||||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||||
height: 0 },
|
||||
@@ -558,9 +646,6 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
</Fragment>))}
|
||||
<PrefetchLink
|
||||
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',
|
||||
((openItemIdx < 0)
|
||||
&& 'top-nav-mobile-active font-bold'))}>
|
||||
@@ -570,7 +655,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
<Separator/>
|
||||
</motion.div>)}
|
||||
</AnimatePresence>)}
|
||||
</>)
|
||||
</div>)
|
||||
}
|
||||
|
||||
export default TopNav
|
||||
|
||||
@@ -15,8 +15,8 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
|
||||
return
|
||||
|
||||
const className = cn ((sp
|
||||
? 'md:hidden w-full min-h-[40px] flex items-center pl-8'
|
||||
: 'hidden md:block ml-auto pr-4'),
|
||||
? 'top-nav-user-mobile w-full min-h-[40px] flex items-center pl-8'
|
||||
: 'top-nav-user-desktop shrink-0 whitespace-nowrap ml-auto'),
|
||||
'font-bold text-red-600 hover:text-red-400',
|
||||
'dark:text-yellow-400 dark:hover:text-yellow-200')
|
||||
|
||||
|
||||
+233
-1
@@ -197,12 +197,244 @@ body
|
||||
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)
|
||||
{
|
||||
.top-nav-root
|
||||
.top-nav-layout .top-nav-brand-link
|
||||
{
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.top-nav-layout[data-desktop='true'] .top-nav-root
|
||||
{
|
||||
background: var(--top-nav-root-bg-desktop);
|
||||
}
|
||||
|
||||
.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,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createHelpFixture } from '@/test/helpFixtures'
|
||||
|
||||
|
||||
describe ('Help file catalogue', () => {
|
||||
it.each ([['Éclair', 'eclair'], ['home', 'HOME'], ['e\u0301clair', 'Éclair']]) (
|
||||
'compares title %s and %s equally at primary strength', (a, b) => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.compareHelpTitles (a, b)).toBe (0)
|
||||
})
|
||||
|
||||
it ('orders distinct Latin titles in both directions', () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.compareHelpTitles ('Apple', 'Zebra')).toBeLessThan (0)
|
||||
expect (api.compareHelpTitles ('Zebra', 'Apple')).toBeGreaterThan (0)
|
||||
})
|
||||
|
||||
it.each ([['ほーむ', 'ホーム'], ['ホーム', 'ほーむ'], ['ホーム', 'ホーム'],
|
||||
['ABC', 'abc'], ['ガイド', 'がいど']]) (
|
||||
'normalises search text %s and %s identically', (query, text) => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.normaliseHelpSearchText (query)).toBe (api.normaliseHelpSearchText (text))
|
||||
})
|
||||
|
||||
it.each ([['2026-10-02', null], ['2026-10-02.2', 2],
|
||||
['2026-10-02.3', 3], ['2026-10-02.10', 10]] as const) (
|
||||
'separates the date and revision of %s', (edition, revision) => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.parseHelpEdition (edition)).toEqual ({ date: '2026-10-02', revision })
|
||||
const date = api.helpEditionDate (edition)!
|
||||
expect ([date.getFullYear (), date.getMonth (), date.getDate ()]).toEqual ([2026, 9, 2])
|
||||
})
|
||||
|
||||
it.each (['missing', '2026-02-30', '2026-10-02.0', '2026-10-02.2.extra',
|
||||
'2026-10-02.9007199254740992']) ('rejects invalid edition %s', edition => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (api.parseHelpEdition (edition)).toBeNull ()
|
||||
expect (api.helpEditionDate (edition)).toBeNull ()
|
||||
})
|
||||
|
||||
it ('compares numeric revision suffixes without losing the edition identifiers', () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (['2026-10-02.10', '2026-10-02.3', '2026-10-02', '2026-10-02.2']
|
||||
.sort (api.compareHelpEditions)).toEqual ([
|
||||
'2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
|
||||
})
|
||||
|
||||
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition)
|
||||
.toBe ('2026-10-02.10')
|
||||
expect (await api.getHelpSearchEntries ('ja')).toEqual ([
|
||||
{ identifier: 'guide', edition: '2026-10-02.10',
|
||||
title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' },
|
||||
{ identifier: '設定 手順', edition: '2026-10-03',
|
||||
title: '設定', body: '\nタグを設定する' }])
|
||||
})
|
||||
|
||||
it ('orders published editions numerically and separates languages', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (await api.getHelpEditions ('guide', 'ja')).toEqual ([
|
||||
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
|
||||
expect (await api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
|
||||
expect (await api.getHelpPublishedEditions ('guide', 'ja'))
|
||||
.not.toContain ('2026-10-02.11')
|
||||
})
|
||||
|
||||
it ('uses generated edition titles without reparsing raw sources', async () => {
|
||||
const { api, raw } = createHelpFixture ()
|
||||
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
|
||||
.toEqual ({ title: '旧ホーム', body: '\n過去限定本文' })
|
||||
raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
.mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
|
||||
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-02'))
|
||||
.toEqual ({ title: 'ホーム', body: '\nタグを検索する' })
|
||||
expect (raw['../../help/guide/editions/ja/2026-10-02.mdx']).not.toHaveBeenCalled ()
|
||||
expect ((await api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))?.source)
|
||||
.toBe ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
|
||||
})
|
||||
|
||||
it ('returns missing resources without substituting another edition', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
expect (await api.getHelpArticle ('absent')).toBeNull ()
|
||||
expect (await api.getHelpEditions ('absent', 'ja')).toEqual ([])
|
||||
expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull ()
|
||||
expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('reports generated search asset failures instead of hiding articles', async () => {
|
||||
const { api, loads } = createHelpFixture ()
|
||||
loads.search.mockImplementationOnce (() => { throw new Error ('asset failure') })
|
||||
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('asset failure')
|
||||
})
|
||||
|
||||
it ('reports raw loading failures and excludes unpublished editions', async () => {
|
||||
const { api, raw } = createHelpFixture ()
|
||||
const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||
loader.mockRejectedValueOnce (new Error ('fixture failure'))
|
||||
await expect (api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))
|
||||
.rejects.toThrow ('fixture failure')
|
||||
expect (await api.getHelpEdition ('guide', 'ja', '2026-10-02.11')).toBeNull ()
|
||||
expect (await api.getHelpEditionSource ('guide', 'ja', '2026-10-02.11')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('compares raw H1 and body lines in the requested direction', async () => {
|
||||
const { api } = createHelpFixture ()
|
||||
const forward = await api.getHelpDiff ('guide', 'ja', '2026-10-01', '2026-10-02.10')
|
||||
expect (forward?.from.title).toBe ('旧ホーム')
|
||||
expect (forward?.to.title).toBe ('ホーム第十版')
|
||||
expect (forward?.lines).toEqual (expect.arrayContaining ([
|
||||
{ type: 'removed', content: '# 旧ホーム\n' },
|
||||
{ type: 'added', content: '# ホーム第十版\n' },
|
||||
{ type: 'unchanged', content: '\n' }]))
|
||||
const reverse = await api.getHelpDiff ('guide', 'ja', '2026-10-02.10', '2026-10-01')
|
||||
expect (reverse?.lines).toEqual (expect.arrayContaining ([
|
||||
{ type: 'added', content: '# 旧ホーム\n' },
|
||||
{ type: 'removed', content: '# ホーム第十版\n' }]))
|
||||
})
|
||||
|
||||
it.each ([['missing', 'ja', '2026-10-01', '2026-10-02.10'],
|
||||
['guide', 'ja', '2026-10-01', '2026-10-02.11'],
|
||||
['guide', 'en', '2026-10-01', '2026-10-02']]) (
|
||||
'rejects invalid diff editions for %s/%s without reading sources',
|
||||
async (id, language, from, to) => {
|
||||
const { api, raw } = createHelpFixture ()
|
||||
expect (await api.getHelpDiff (id, language, from, to)).toBeNull ()
|
||||
Object.values (raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
type ArticleLanguage = { currentEdition: string; publishedEditions: string[] }
|
||||
|
||||
export type ArticleMeta = {
|
||||
identifier: string
|
||||
canonicalLanguage: string
|
||||
languages: Record<string, ArticleLanguage> }
|
||||
|
||||
export type HelpEditionText = {
|
||||
title: string
|
||||
body: string }
|
||||
|
||||
export type HelpSearchEntry = HelpEditionText & {
|
||||
identifier: string
|
||||
edition: string }
|
||||
|
||||
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
|
||||
|
||||
export type HelpEditionSource = HelpEditionText & { source: string }
|
||||
export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & {
|
||||
pred: string | null
|
||||
change: 'new' | 'update'
|
||||
date: string }
|
||||
export type HelpTitleIndex = Record<string, string[]>
|
||||
export type HelpDiffLine = {
|
||||
type: 'added' | 'removed' | 'unchanged'
|
||||
content: string }
|
||||
export type HelpDiff = {
|
||||
from: HelpEditionSource
|
||||
to: HelpEditionSource
|
||||
lines: HelpDiffLine[] }
|
||||
|
||||
export type HelpEditionIdentifier = { date: string; revision: number | null }
|
||||
|
||||
// Fix the locale and primary strength; ICU versions still differ from MySQL UCA 9.0.
|
||||
const helpTitleCollator = new Intl.Collator ('en', {
|
||||
usage: 'sort', sensitivity: 'base', ignorePunctuation: false, numeric: false })
|
||||
|
||||
|
||||
export const compareHelpTitles = (a: string, b: string): number =>
|
||||
helpTitleCollator.compare (a, b)
|
||||
|
||||
|
||||
export const normaliseHelpSearchText = (value: string): string =>
|
||||
value.normalize ('NFKC').toLowerCase ().replace (/[ァ-ヶヽヾ]/g,
|
||||
character => String.fromCodePoint (character.codePointAt (0)! - 0x60))
|
||||
|
||||
|
||||
export const parseHelpEdition = (edition: string): HelpEditionIdentifier | null => {
|
||||
const matched = edition.match (/^(\d{4}-\d{2}-\d{2})(?:\.([1-9]\d*))?$/)
|
||||
if (matched == null)
|
||||
return null
|
||||
const date = new Date (matched[1])
|
||||
const revision = matched[2] == null ? null : Number (matched[2])
|
||||
if (Number.isNaN (date.getTime ()) || date.toISOString ().slice (0, 10) !== matched[1]
|
||||
|| (revision != null && !(Number.isSafeInteger (revision))))
|
||||
return null
|
||||
return { date: matched[1], revision }
|
||||
}
|
||||
|
||||
|
||||
export const compareHelpEditions = (a: string, b: string): number => {
|
||||
const before = parseHelpEdition (a)
|
||||
const after = parseHelpEdition (b)
|
||||
if (before == null || after == null)
|
||||
return a.localeCompare (b)
|
||||
return before.date.localeCompare (after.date)
|
||||
|| (before.revision ?? 1) - (after.revision ?? 1)
|
||||
}
|
||||
|
||||
|
||||
export const helpEditionDate = (edition: string): Date | null => {
|
||||
const parsed = parseHelpEdition (edition)
|
||||
return parsed == null ? null : new Date (`${ parsed.date }T00:00:00`)
|
||||
}
|
||||
|
||||
|
||||
export const getHelpArticle = async (identifier: string): Promise<ArticleMeta | null> => {
|
||||
const { default: articles } = await import ('virtual:help/articles')
|
||||
return Object.hasOwn (articles, identifier) ? articles[identifier] : null
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditions = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
): Promise<string[]> => {
|
||||
const article = await getHelpArticle (identifier)
|
||||
return article?.languages[language]?.publishedEditions ?? []
|
||||
}
|
||||
|
||||
|
||||
export const getHelpPublishedEditions = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
): Promise<string[]> => await getHelpEditions (identifier, language)
|
||||
|
||||
|
||||
export const getHelpEdition = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<ComponentType | null> => {
|
||||
const { components } = await import ('virtual:help/loaders')
|
||||
const loader = components[`${ identifier }/${ language }/${ edition }`]
|
||||
|
||||
if (!(loader))
|
||||
return null
|
||||
|
||||
return (await loader ()).default
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditionSource = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<HelpEditionSource | null> => {
|
||||
const { sources } = await import ('virtual:help/loaders')
|
||||
const loader = sources[`${ identifier }/${ language }/${ edition }`]
|
||||
|
||||
if (!(loader))
|
||||
return null
|
||||
|
||||
const [source, text] = await Promise.all ([
|
||||
loader (), getHelpEditionText (identifier, language, edition)])
|
||||
return text == null ? null : { source: source.default, ...text }
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditionText = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<HelpEditionText | null> => {
|
||||
const { texts } = await import ('virtual:help/loaders')
|
||||
const loader = texts[`${ identifier }/${ language }/${ edition }`]
|
||||
return loader == null ? null : (await loader ()).default
|
||||
}
|
||||
|
||||
|
||||
export const getHelpSearchEntries = async (
|
||||
language: string,
|
||||
): Promise<HelpSearchEntry[]> => {
|
||||
const { default: search } = await import ('virtual:help/search')
|
||||
return search[language] ?? []
|
||||
}
|
||||
|
||||
|
||||
export const getHelpEditionContent = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
edition: string,
|
||||
): Promise<HelpEditionContent | null> => {
|
||||
const [content, text] = await Promise.all ([
|
||||
getHelpEdition (identifier, language, edition),
|
||||
getHelpEditionText (identifier, language, edition)])
|
||||
|
||||
return content != null && text != null ? { content, ...text } : null
|
||||
}
|
||||
|
||||
|
||||
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
|
||||
const { default: changes } = await import ('virtual:help/changes')
|
||||
return changes[language] ?? []
|
||||
}
|
||||
|
||||
|
||||
export const getHelpTitleIndex = async (language: string): Promise<HelpTitleIndex> => {
|
||||
const { default: titles } = await import ('virtual:help/titles')
|
||||
return titles[language] ?? { }
|
||||
}
|
||||
|
||||
|
||||
const diffHelpLines = (from: string, to: string): HelpDiffLine[] => {
|
||||
const before = from.match (/[^\n]*\n|[^\n]+$/g) ?? []
|
||||
const after = to.match (/[^\n]*\n|[^\n]+$/g) ?? []
|
||||
const lengths = Array.from ({ length: before.length + 1 },
|
||||
() => new Uint32Array (after.length + 1))
|
||||
|
||||
for (let i = before.length - 1; i >= 0; --i)
|
||||
{
|
||||
for (let j = after.length - 1; j >= 0; --j)
|
||||
{
|
||||
lengths[i][j] =
|
||||
before[i] === after[j]
|
||||
? lengths[i + 1][j + 1] + 1
|
||||
: Math.max (lengths[i + 1][j], lengths[i][j + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const lines: HelpDiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < before.length || j < after.length)
|
||||
{
|
||||
if (i < before.length && j < after.length && before[i] === after[j])
|
||||
{
|
||||
lines.push ({ type: 'unchanged', content: before[i] })
|
||||
++i
|
||||
++j
|
||||
}
|
||||
else if (i < before.length
|
||||
&& (j === after.length || lengths[i + 1][j] >= lengths[i][j + 1]))
|
||||
{
|
||||
lines.push ({ type: 'removed', content: before[i] })
|
||||
++i
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.push ({ type: 'added', content: after[j] })
|
||||
++j
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
|
||||
export const getHelpDiff = async (
|
||||
identifier: string,
|
||||
language: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<HelpDiff | null> => {
|
||||
const editions = await getHelpPublishedEditions (identifier, language)
|
||||
if (!(editions.includes (from)) || !(editions.includes (to)))
|
||||
return null
|
||||
|
||||
const [before, after] = await Promise.all ([
|
||||
getHelpEditionSource (identifier, language, from),
|
||||
getHelpEditionSource (identifier, language, to)])
|
||||
if (before == null || after == null)
|
||||
return null
|
||||
|
||||
return { from: before, to: after, lines: diffHelpLines (before.source, after.source) }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import * as prefetchers from '@/lib/prefetchers'
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import HelpDiffPage from '@/pages/help/HelpDiffPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const settings = vi.hoisted (() => ({ linkPreload: 'intent' }))
|
||||
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
||||
useClientBehaviourSettings: () => settings }))
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
|
||||
const Location = () => {
|
||||
const location = useLocation ()
|
||||
return <output data-testid="location">{location.pathname}</output>
|
||||
}
|
||||
|
||||
|
||||
describe ('Help prefetch integration', () => {
|
||||
beforeEach (() => {
|
||||
helpFixture.reset ()
|
||||
settings.linkPreload = 'intent'
|
||||
vi.clearAllMocks ()
|
||||
})
|
||||
|
||||
it.each (['/help', '/help?title=ホーム&text=検索&page=2', '/help/guide', '/help/changes',
|
||||
'/help/guide/diff?from=2026-10-01&to=2026-10-02.10',
|
||||
'/help/%E8%A8%AD%E5%AE%9A%20%E6%89%8B%E9%A0%86?edition=2026-10-03']) (
|
||||
'registers a unique prefetcher for %s', path => {
|
||||
const url = new URL (path, window.location.origin)
|
||||
expect (prefetchers.routePrefetchers.filter (entry => entry.test (url))).toHaveLength (1)
|
||||
})
|
||||
|
||||
it ('loads and reuses the same MDX module for repeated prefetch and display', async () => {
|
||||
const { queryClient } = renderWithProviders (
|
||||
<>
|
||||
<PrefetchLink to="/help/guide">Help</PrefetchLink>
|
||||
<Routes>
|
||||
<Route path="/" element={null}/>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
</Routes>
|
||||
</>)
|
||||
const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||
await Promise.all ([
|
||||
prefetchers.prefetchForURL (queryClient, '/help/guide'),
|
||||
prefetchers.prefetchForURL (queryClient, '/help/guide')])
|
||||
expect (loader).toHaveBeenCalledTimes (1)
|
||||
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
|
||||
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||
expect (loader).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
|
||||
it ('prefetches the requested identifier and edition independently', async () => {
|
||||
const client = new QueryClient ()
|
||||
await prefetchers.prefetchForURL (client, '/help/guide?edition=2026-10-02.3')
|
||||
await prefetchers.prefetchForURL (client, `/help/${encodeURIComponent ('設定 手順')}`)
|
||||
expect (helpFixture.modules['../../help/guide/editions/ja/2026-10-02.3.mdx'])
|
||||
.toHaveBeenCalledTimes (1)
|
||||
expect (helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx'])
|
||||
.toHaveBeenCalledTimes (1)
|
||||
expect (helpFixture.modules['../../help/guide/editions/en/2026-10-02.mdx'])
|
||||
.not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('loads history editions without treating changes as an article', async () => {
|
||||
const article = vi.spyOn (helpFixture.api, 'getHelpArticle')
|
||||
await prefetchers.prefetchForURL (new QueryClient (), '/help/changes')
|
||||
expect (article).not.toHaveBeenCalledWith ('changes')
|
||||
expect (helpFixture.loads.changes).toHaveBeenCalledTimes (1)
|
||||
Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
})
|
||||
|
||||
it ('reuses two prefetched raw editions when displaying their diff', async () => {
|
||||
const path = '/help/guide/diff?from=2026-10-01&to=2026-10-02.10'
|
||||
const { queryClient } = renderWithProviders (
|
||||
<>
|
||||
<PrefetchLink to={path}>diff</PrefetchLink>
|
||||
<Routes>
|
||||
<Route path="/" element={null}/>
|
||||
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
|
||||
</Routes>
|
||||
</>)
|
||||
await Promise.all ([prefetchers.prefetchForURL (queryClient, path),
|
||||
prefetchers.prefetchForURL (queryClient, path)])
|
||||
fireEvent.click (screen.getByRole ('link', { name: 'diff' }))
|
||||
expect (await screen.findByRole ('link', { name: '旧ホーム (2026-10-01)' }))
|
||||
.toHaveAttribute ('href', '/help/guide?edition=2026-10-01')
|
||||
expect (screen.getByText ('+ # ホーム第十版')).toBeInTheDocument ()
|
||||
Object.entries (helpFixture.raw).forEach (([name, load]) => {
|
||||
const expected = /\/ja\/(2026-10-01|2026-10-02\.10)\.mdx$/.test (name) ? 1 : 0
|
||||
expect (load).toHaveBeenCalledTimes (expected)
|
||||
})
|
||||
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
})
|
||||
|
||||
it.each (['intent', 'off']) ('respects %s intent settings and still prefetches clicks',
|
||||
async mode => {
|
||||
settings.linkPreload = mode
|
||||
const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue ()
|
||||
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
|
||||
const link = screen.getByRole ('link', { name: 'Help' })
|
||||
fireEvent.mouseEnter (link)
|
||||
fireEvent.touchStart (link)
|
||||
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 2 : 0)
|
||||
fireEvent.click (link)
|
||||
await waitFor (() => expect (screen.getByTestId ('location'))
|
||||
.toHaveTextContent ('/help/guide'))
|
||||
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 3 : 1)
|
||||
})
|
||||
|
||||
it ('continues navigation after prefetch failure', async () => {
|
||||
vi.spyOn (console, 'error').mockImplementation (() => {})
|
||||
vi.spyOn (prefetchers, 'prefetchForURL').mockRejectedValue (new Error ('offline'))
|
||||
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
|
||||
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
|
||||
await waitFor (() => expect (screen.getByTestId ('location'))
|
||||
.toHaveTextContent ('/help/guide'))
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,13 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { match } from 'path-to-regexp'
|
||||
|
||||
import { getHelpArticle,
|
||||
getHelpChanges,
|
||||
getHelpDiff,
|
||||
getHelpEditionContent,
|
||||
getHelpSearchEntries } from '@/lib/help'
|
||||
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
||||
import { fetchWikiPage,
|
||||
fetchWikiPageByTitle,
|
||||
@@ -15,12 +20,69 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
|
||||
const mPost = match<{ id: string }> ('/posts/:id')
|
||||
const mWiki = match<{ title: string }> ('/wiki/:title')
|
||||
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 =>
|
||||
['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 title = url.searchParams.get ('title') ?? ''
|
||||
|
||||
@@ -223,6 +285,13 @@ const prefetchTagChanges: Prefetcher = async (qc, url) => {
|
||||
|
||||
|
||||
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),
|
||||
run: prefetchPostsIndex },
|
||||
{ test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname))
|
||||
|
||||
@@ -61,3 +61,14 @@ export const wikiKeys = {
|
||||
root: ['wiki'] as const,
|
||||
index: (p: { title?: string }) => ['wiki', 'index', 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 }
|
||||
|
||||
@@ -72,11 +72,25 @@ describe ('tags API functions', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it ('returns null when tag fetches fail', async () => {
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||
it ('returns null for a missing named tag while retaining the ID helper contract', async () => {
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||
api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
|
||||
|
||||
await expect (fetchTag ('1')).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 ('名前/#%') }`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { Deerjikist,
|
||||
@@ -68,11 +70,13 @@ export const fetchExternalTag = async (id: string): Promise<Tag | null> => {
|
||||
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/tags/name/${ name }`)
|
||||
return await apiGet (`/tags/name/${ encodeURIComponent (name) }`)
|
||||
}
|
||||
catch
|
||||
catch (error)
|
||||
{
|
||||
if (isAxiosError (error) && error.response?.status === 404)
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-10
@@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
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)
|
||||
|
||||
@@ -23,18 +21,16 @@ describe ('wiki API functions', () => {
|
||||
expect (api.apiGet).toHaveBeenNthCalledWith (
|
||||
1,
|
||||
'/wiki',
|
||||
{ params: { title: '虹' } },
|
||||
)
|
||||
{ params: { title: '虹' } })
|
||||
expect (api.apiGet).toHaveBeenNthCalledWith (
|
||||
2,
|
||||
'/wiki/1',
|
||||
{ params: { version: '3' } },
|
||||
)
|
||||
{ params: { version: '3' } })
|
||||
})
|
||||
|
||||
it ('encodes title path segments and returns null on misses', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ({ id: 2 })
|
||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||
api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
|
||||
|
||||
await fetchWikiPageByTitle ('a/b c', { version: undefined })
|
||||
await expect (fetchWikiPageByTitle ('missing', { })).resolves.toBeNull ()
|
||||
@@ -42,7 +38,18 @@ describe ('wiki API functions', () => {
|
||||
expect (api.apiGet).toHaveBeenNthCalledWith (
|
||||
1,
|
||||
'/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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { WikiPage } from '@/types'
|
||||
@@ -21,8 +23,10 @@ export const fetchWikiPageByTitle = async (
|
||||
{
|
||||
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
|
||||
}
|
||||
catch
|
||||
catch (error)
|
||||
{
|
||||
if (isAxiosError (error) && error.response?.status === 404)
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, screen, within } from '@testing-library/react'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import { compileMdx, transformMdx } from '@/test/compileMdx'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const prefetch = vi.hoisted (() => vi.fn (async () => {}))
|
||||
vi.hoisted (() => {
|
||||
vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor)
|
||||
})
|
||||
afterAll (() => vi.unstubAllGlobals ())
|
||||
vi.mock ('@/lib/prefetchers', () => ({ prefetchForURL: prefetch }))
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
const tableSource = [
|
||||
'# 表のある記事', '', '| 項目 | 説明 |', '| --- | --- |',
|
||||
'| [ヘルプ](/help/guide) | **強調** |', '| 通常 | 本文 |'].join ('\n')
|
||||
|
||||
|
||||
describe ('Help MDX with the application compiler', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it ('compiles GFM table structure, links and emphasis', async () => {
|
||||
const Content = await compileMdx (tableSource)
|
||||
renderWithProviders (<Content/>)
|
||||
const table = screen.getByRole ('table')
|
||||
expect (table.querySelector ('thead')).not.toBeNull ()
|
||||
expect (table.querySelector ('tbody')).not.toBeNull ()
|
||||
expect (within (table).getAllByRole ('columnheader')).toHaveLength (2)
|
||||
expect (within (table).getAllByRole ('cell')).toHaveLength (4)
|
||||
expect (within (table).getByRole ('link', { name: 'ヘルプ' }))
|
||||
.toHaveAttribute ('href', '/help/guide')
|
||||
expect (table.querySelector ('strong')).toHaveTextContent ('強調')
|
||||
})
|
||||
|
||||
it ('retains headings, paragraphs and lists', async () => {
|
||||
const Content = await compileMdx ('# 見出し\n\n本文\n\n- 第一\n- 第二')
|
||||
renderWithProviders (<Content/>)
|
||||
expect (screen.getByRole ('heading', { level: 1 })).toHaveTextContent ('見出し')
|
||||
expect (screen.getByText ('本文').tagName).toBe ('P')
|
||||
expect (screen.getAllByRole ('listitem')).toHaveLength (2)
|
||||
})
|
||||
|
||||
it ('keeps tables inside the Help dark-mode typography container', async () => {
|
||||
const Content = await compileMdx (tableSource)
|
||||
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||
.mockResolvedValue ({ default: Content })
|
||||
renderWithProviders (
|
||||
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
|
||||
{ route: '/help/guide' })
|
||||
const table = await screen.findByRole ('table')
|
||||
expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert')
|
||||
})
|
||||
|
||||
it ('preserves long cell contents when rendering a wide table', async () => {
|
||||
const cell = 'wide'.repeat (100)
|
||||
const Content = await compileMdx (`| 項目 |\n| --- |\n| ${ cell } |`)
|
||||
renderWithProviders (<Content/>)
|
||||
expect (screen.getByRole ('cell')).toHaveTextContent (cell)
|
||||
})
|
||||
|
||||
it.each (['.mdx?raw', '.mdx?url']) ('preserves the %s transform bypass', async suffix => {
|
||||
expect (await transformMdx (tableSource, suffix)).toBeNull ()
|
||||
})
|
||||
|
||||
it ('uses the common PrefetchLink interaction for internal MDX links', async () => {
|
||||
prefetch.mockClear ()
|
||||
const Content = await compileMdx ('[内部リンク](/help/guide)')
|
||||
renderWithProviders (<Content/>)
|
||||
fireEvent.mouseEnter (screen.getByRole ('link', { name: '内部リンク' }))
|
||||
expect (prefetch).toHaveBeenCalledWith (expect.anything (),
|
||||
new URL ('/help/guide', window.location.origin).href)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,52 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
|
||||
import type { MDXComponents } from 'mdx/types'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
|
||||
export const useMDXComponents = (): MDXComponents => ({ })
|
||||
const MDXLink = (
|
||||
{ href, children, ...props }: ComponentProps<'a'>,
|
||||
) => {
|
||||
const location = useLocation ()
|
||||
const url = useMemo (() => {
|
||||
try
|
||||
{
|
||||
const base = new URL (location.pathname + location.search, window.location.origin)
|
||||
return href ? new URL (href, base) : null
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null
|
||||
}
|
||||
}, [href, location.pathname, location.search])
|
||||
const internal = url != null
|
||||
&& url.origin === window.location.origin
|
||||
&& (url.protocol === 'http:' || url.protocol === 'https:')
|
||||
const anchor = href?.startsWith ('#')
|
||||
|| (url?.pathname === location.pathname
|
||||
&& url.search === location.search && Boolean (url.hash))
|
||||
const external = url != null && !(internal)
|
||||
&& (url.protocol === 'http:' || url.protocol === 'https:')
|
||||
|
||||
if (internal && !(anchor) && props.download == null)
|
||||
{
|
||||
return (
|
||||
<PrefetchLink to={url.pathname + url.search + url.hash} {...props}>
|
||||
{children}
|
||||
</PrefetchLink>)
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href}
|
||||
{...props}
|
||||
target={external ? props.target ?? '_blank' : props.target}
|
||||
rel={props.rel ?? 'noopener noreferrer'}>
|
||||
{children}
|
||||
</a>)
|
||||
}
|
||||
|
||||
|
||||
export const useMDXComponents = (): MDXComponents => ({ a: MDXLink })
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import App from '@/App'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
|
||||
vi.hoisted (() => {
|
||||
window.history.replaceState (null, '', '/help/changes')
|
||||
})
|
||||
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn (), apiPost: async () => ({}),
|
||||
isApiError: () => false }))
|
||||
vi.mock ('@/components/TopNav', () => ({ default: () => null }))
|
||||
vi.mock ('@/components/DevModeWatermark', () => ({ default: () => null }))
|
||||
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
||||
useClientBehaviourSettings: () => ({ animation: 'off', linkPreload: 'off' }) }))
|
||||
|
||||
|
||||
describe ('Help global edition history', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it ('routes /help/changes to published editions in date and numeric order', async () => {
|
||||
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
|
||||
const { container } = render (
|
||||
<QueryClientProvider client={client}>
|
||||
<HelmetProvider><App/></HelmetProvider>
|
||||
</QueryClientProvider>)
|
||||
const links = await screen.findAllByRole ('link')
|
||||
const editions = links.map (link => link.getAttribute ('href'))
|
||||
.filter (href => href?.startsWith ('/help/') && href.includes ('edition='))
|
||||
expect (editions).toEqual ([
|
||||
`/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`,
|
||||
'/help/guide?edition=2026-10-02.10', '/help/guide?edition=2026-10-02.3',
|
||||
'/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02',
|
||||
'/help/guide?edition=2026-10-01'])
|
||||
expect (container).toHaveTextContent ('旧ホーム')
|
||||
expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent))
|
||||
.toEqual (['差分', 'タイトル', '有効開始年月日'])
|
||||
expect (container).not.toHaveTextContent ('未公開ホーム')
|
||||
expect (container).not.toHaveTextContent ('Invalid Date')
|
||||
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
|
||||
expect (screen.getAllByRole ('link', { name: '差分' }).map (link =>
|
||||
link.getAttribute ('href'))).toEqual ([
|
||||
'/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10',
|
||||
'/help/guide/diff?from=2026-10-02.2&to=2026-10-02.3',
|
||||
'/help/guide/diff?from=2026-10-02&to=2026-10-02.2',
|
||||
'/help/guide/diff?from=2026-10-01&to=2026-10-02'])
|
||||
expect (apiGet).not.toHaveBeenCalled ()
|
||||
expect (window.location.pathname).toBe ('/help/changes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpChanges, helpEditionDate } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
||||
import { dateString } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
const HelpChangesPage: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams ()
|
||||
const identifier = searchParams.get ('identifier') ?? ''
|
||||
const pageValue = searchParams.get ('page') ?? '1'
|
||||
const pageNumber = Number (pageValue)
|
||||
const requestedPage =
|
||||
(/^[1-9]\d*$/.test (pageValue) && Number.isSafeInteger (pageNumber))
|
||||
? pageNumber : 1
|
||||
const limit = LIST_LIMIT_OPTIONS.find (
|
||||
value => String (value) === searchParams.get ('limit')) ?? 20
|
||||
const { data: changes = [], isLoading, error } = useQuery ({
|
||||
queryKey: helpKeys.changes ('ja'),
|
||||
queryFn: () => getHelpChanges ('ja'),
|
||||
staleTime: Infinity })
|
||||
|
||||
const results = useMemo (() => identifier
|
||||
? changes.filter (entry => entry.identifier === identifier)
|
||||
: changes, [changes, identifier])
|
||||
const totalPages = Math.ceil (results.length / limit)
|
||||
const page = requestedPage <= Math.max (1, totalPages) ? requestedPage : 1
|
||||
const pageResults = results.slice ((page - 1) * limit, page * limit)
|
||||
|
||||
const handleLimit = (value: string) => {
|
||||
const next = new URLSearchParams (searchParams)
|
||||
next.set ('limit', value)
|
||||
next.set ('page', '1')
|
||||
setSearchParams (next)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainArea className="min-w-0 max-w-full">
|
||||
<Helmet>
|
||||
<title>{`ヘルプ改定履歴 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageTitle>ヘルプ改定履歴</PageTitle>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground">
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>{value}</option>))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isLoading
|
||||
? <p className="mt-4">読み込み中...</p>
|
||||
: (error
|
||||
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
|
||||
: (
|
||||
<div className="mt-4">
|
||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||
<table className="table-auto w-full min-w-[900px] whitespace-nowrap
|
||||
border-collapse">
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left">差分</th>
|
||||
<th className="p-2 text-left">タイトル</th>
|
||||
<th className="p-2 text-left">有効開始年月日</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageResults.map (entry => {
|
||||
const path = `/help/${ encodeURIComponent (entry.identifier) }`
|
||||
const diffQuery = new URLSearchParams ({
|
||||
from: entry.pred ?? '', to: entry.edition })
|
||||
const diffTo = `${ path }/diff?${ diffQuery.toString () }`
|
||||
const updatedAt = helpEditionDate (entry.edition)
|
||||
|
||||
return (
|
||||
<tr key={`${ entry.identifier }/${ entry.edition }`}
|
||||
className="even:bg-gray-100 text-gray-900
|
||||
dark:even:bg-gray-700 dark:text-gray-100">
|
||||
<td className="p-2">
|
||||
{entry.pred != null && (
|
||||
<PrefetchLink to={diffTo}>
|
||||
差分
|
||||
</PrefetchLink>)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<PrefetchLink to={`${ path }?edition=${ entry.edition }`}>
|
||||
{entry.title}
|
||||
</PrefetchLink>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{updatedAt != null && dateString (updatedAt, 'hour')}
|
||||
</td>
|
||||
</tr>)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 0 && <Pagination page={page} totalPages={totalPages}/>}
|
||||
</div>))}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpChangesPage
|
||||
@@ -0,0 +1,128 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
|
||||
const Navigation = () => {
|
||||
const navigate = useNavigate ()
|
||||
const location = useLocation ()
|
||||
return (
|
||||
<>
|
||||
<output data-testid="location">{location.pathname}{location.search}</output>
|
||||
<button onClick={() => navigate ('/help/設定%20手順')}>switch article</button>
|
||||
<button onClick={() => navigate ('/help/guide?edition=2026-10-02.3')}>
|
||||
switch edition
|
||||
</button>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
const renderDetail = (route = '/help/guide') => renderWithProviders (
|
||||
<>
|
||||
<Navigation/>
|
||||
<Routes>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
</Routes>
|
||||
</>, { route })
|
||||
|
||||
|
||||
describe ('Help details', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it.each (['2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']) (
|
||||
'displays a date without inventing a time for edition %s', async edition => {
|
||||
const { container } = renderDetail (`/help/guide?edition=${ edition }`)
|
||||
await screen.findByRole ('heading')
|
||||
expect (container).toHaveTextContent ('10月2日')
|
||||
expect (container).not.toHaveTextContent ('Invalid Date')
|
||||
expect (container.textContent).not.toMatch (/\d{1,2}:\d{2}/)
|
||||
const revision = edition.split ('.')[1]
|
||||
if (revision != null)
|
||||
expect (container).toHaveTextContent (` .${ revision }`)
|
||||
expect (helpFixture.modules[`../../help/guide/editions/ja/${ edition }.mdx`])
|
||||
.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('displays current_edition H1 and body even when newer files exist', async () => {
|
||||
renderDetail ()
|
||||
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
||||
.toBeInTheDocument ()
|
||||
expect (screen.getByText ('タグを検索する 第十版本文')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('未公開限定本文')).not.toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('ホーム第十版 ヘルプ'))
|
||||
})
|
||||
|
||||
it ('uses the historical H1 in both page and document titles', async () => {
|
||||
renderDetail ('/help/guide?edition=2026-10-01')
|
||||
expect (await screen.findByRole ('heading', { name: '旧ホーム' })).toBeInTheDocument ()
|
||||
expect (screen.getByText ('過去限定本文')).toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('旧ホーム ヘルプ'))
|
||||
expect (screen.getByText ('(最古)')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('link', { name: '新 >' }))
|
||||
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02')
|
||||
})
|
||||
|
||||
it ('navigates .2, .3 and .10 in numeric edition order', async () => {
|
||||
renderDetail ('/help/guide?edition=2026-10-02.3')
|
||||
await screen.findByRole ('heading', { name: 'ホーム第三版' })
|
||||
expect (screen.getByRole ('link', { name: '< 古' }))
|
||||
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02.2')
|
||||
const next = screen.getByRole ('link', { name: '新 >' })
|
||||
expect (next).toHaveAttribute ('href', '/help/guide?edition=2026-10-02.10')
|
||||
fireEvent.click (next)
|
||||
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||
expect (screen.getByText ('(最新)')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each (['/help/missing', '/help/guide?edition=missing',
|
||||
'/help/guide?edition=2026-02-30', '/help/guide?edition=2026-10-02.2.extra',
|
||||
'/help/guide?edition=2026-10-02.11']) (
|
||||
'shows NotFound without redirecting %s', async route => {
|
||||
renderDetail (route)
|
||||
expect (await screen.findByText ('404')).toBeInTheDocument ()
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
})
|
||||
|
||||
it ('hides the old body and title immediately while another article loads', async () => {
|
||||
let finish!: (value: { default: () => ReactElement }) => void
|
||||
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx']
|
||||
.mockReturnValueOnce (pending)
|
||||
renderDetail ()
|
||||
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch article' }))
|
||||
expect (screen.queryByText ('タグを検索する 第十版本文')).not.toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).not.toContain ('ホーム'))
|
||||
await act (async () => finish ({ default: () => <h1>設定</h1> }))
|
||||
await screen.findByRole ('heading', { name: '設定' })
|
||||
await waitFor (() => expect (document.title).toContain ('設定 ヘルプ'))
|
||||
})
|
||||
|
||||
it ('ignores a late response for the previous edition', async () => {
|
||||
let finish!: (value: { default: () => ReactElement }) => void
|
||||
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
const loader = helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||
loader.mockReturnValueOnce (pending)
|
||||
renderDetail ()
|
||||
await waitFor (() => expect (loader).toHaveBeenCalled ())
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch edition' }))
|
||||
await screen.findByRole ('heading', { name: 'ホーム第三版' })
|
||||
await act (async () => finish ({ default: () => <h1>遅延した旧本文</h1> }))
|
||||
expect (screen.queryByText ('遅延した旧本文')).not.toBeInTheDocument ()
|
||||
expect (screen.getByRole ('heading', { name: 'ホーム第三版' })).toBeInTheDocument ()
|
||||
await waitFor (() => expect (document.title).toContain ('ホーム第三版 ヘルプ'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation, useParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpArticle,
|
||||
getHelpEditionContent,
|
||||
helpEditionDate,
|
||||
parseHelpEdition } from '@/lib/help'
|
||||
import { dateString } from '@/lib/utils'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
const HelpDetailPage: FC = () => {
|
||||
const { identifier = '' } = useParams ()
|
||||
const location = useLocation ()
|
||||
|
||||
// Locale 対応が入るまでは ja 固定
|
||||
const language = 'ja'
|
||||
|
||||
const { data: article, isLoading: articleLoading } = useQuery ({
|
||||
queryKey: helpKeys.article (identifier),
|
||||
queryFn: () => getHelpArticle (identifier),
|
||||
staleTime: Infinity })
|
||||
|
||||
const query = new URLSearchParams (location.search)
|
||||
const requestedEdition = query.get ('edition')
|
||||
|
||||
const languageMeta = article?.languages[language]
|
||||
|
||||
const edition = requestedEdition ?? languageMeta?.currentEdition
|
||||
const editionInfo = parseHelpEdition (edition ?? '')
|
||||
const editionDate = helpEditionDate (edition ?? '')
|
||||
|
||||
const editions = languageMeta?.publishedEditions ?? []
|
||||
|
||||
const index = edition ? editions.indexOf (edition) : -1
|
||||
const pred = index > 0 ? editions[index - 1] : undefined
|
||||
const succ =
|
||||
index >= 0 && index < editions.length - 1
|
||||
? editions[index + 1]
|
||||
: undefined
|
||||
|
||||
const { data: current, isSuccess, isError } = useQuery ({
|
||||
enabled: edition != null && editionInfo != null && editions.includes (edition),
|
||||
queryKey: helpKeys.edition (identifier, language, edition ?? ''),
|
||||
queryFn: () => getHelpEditionContent (identifier, language, edition ?? ''),
|
||||
staleTime: Infinity })
|
||||
|
||||
if (!articleLoading
|
||||
&& (article == null
|
||||
|| languageMeta == null
|
||||
|| edition == null
|
||||
|| editionInfo == null
|
||||
|| !(editions.includes (edition))))
|
||||
return <NotFound/>
|
||||
|
||||
if (isError || (isSuccess && current == null))
|
||||
return <NotFound/>
|
||||
|
||||
const Content = current?.content
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{current?.title || identifier} ヘルプ | {SITE_TITLE}</title>
|
||||
</Helmet>
|
||||
|
||||
{requestedEdition && (
|
||||
<div className="text-sm flex gap-3 items-center justify-center
|
||||
border border-gray-700 rounded px-2 py-1 mb-4">
|
||||
{pred
|
||||
? (
|
||||
<PrefetchLink
|
||||
to={`/help/${ encodeURIComponent (identifier) }?edition=${ pred }`}>
|
||||
< 古
|
||||
</PrefetchLink>)
|
||||
: '(最古)'}
|
||||
|
||||
<span>
|
||||
{editionDate != null && dateString (editionDate, 'hour')}
|
||||
{editionInfo?.revision != null && ` .${ editionInfo.revision }`}
|
||||
</span>
|
||||
|
||||
{succ
|
||||
? (
|
||||
<PrefetchLink
|
||||
to={`/help/${ encodeURIComponent (identifier) }?edition=${ succ }`}>
|
||||
新 >
|
||||
</PrefetchLink>)
|
||||
: '(最新)'}
|
||||
</div>)}
|
||||
|
||||
<article className="prose dark:prose-invert mx-auto p-4">
|
||||
{Content ? <Content/> : <div>Loading...</div>}
|
||||
</article>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpDetailPage
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { getHelpDiff } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/utils'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
const markers = { added: '+ ', removed: '- ', unchanged: ' ' }
|
||||
|
||||
|
||||
const HelpDiffPage: FC = () => {
|
||||
const { identifier = '' } = useParams ()
|
||||
const [searchParams] = useSearchParams ()
|
||||
const from = searchParams.get ('from') ?? ''
|
||||
const to = searchParams.get ('to') ?? ''
|
||||
const validParams = Boolean (identifier && from && to)
|
||||
&& searchParams.getAll ('from').length === 1
|
||||
&& searchParams.getAll ('to').length === 1
|
||||
const { data: diff, isLoading, isError } = useQuery ({
|
||||
enabled: validParams,
|
||||
queryKey: helpKeys.diff (identifier, 'ja', from, to),
|
||||
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
|
||||
staleTime: Infinity })
|
||||
|
||||
if (!(validParams) || isError || (!(isLoading) && diff == null))
|
||||
return <NotFound/>
|
||||
|
||||
const title = `${ diff?.to.title ?? identifier } (${ from } → ${ to })`
|
||||
const path = `/help/${ encodeURIComponent (identifier) }`
|
||||
|
||||
return (
|
||||
<MainArea className="min-w-0 max-w-full">
|
||||
<Helmet>
|
||||
<title>{`ヘルプ 差分: ${ title } | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
<PageTitle>{title}</PageTitle>
|
||||
|
||||
{diff
|
||||
? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<PrefetchLink to={`${ path }?edition=${ encodeURIComponent (from) }`}>
|
||||
{diff.from.title} ({from})
|
||||
</PrefetchLink>
|
||||
<span>→</span>
|
||||
<PrefetchLink to={`${ path }?edition=${ encodeURIComponent (to) }`}>
|
||||
{diff.to.title} ({to})
|
||||
</PrefetchLink>
|
||||
</div>
|
||||
<pre className="mt-4 overflow-x-auto whitespace-pre">
|
||||
<code>
|
||||
{diff.lines.map ((line, index) => {
|
||||
const colour = cn (
|
||||
'block text-gray-900 dark:text-gray-100',
|
||||
line.type === 'added' && 'bg-green-200 dark:bg-green-800',
|
||||
line.type === 'added' && 'text-green-950 dark:text-green-50',
|
||||
line.type === 'removed' && 'bg-red-200 dark:bg-red-800',
|
||||
line.type === 'removed' && 'text-red-950 dark:text-red-50')
|
||||
|
||||
return (
|
||||
<span key={index} className={colour}>
|
||||
{markers[line.type]}{line.content.replace (/\r?\n$/, '')}
|
||||
</span>)
|
||||
})}
|
||||
</code>
|
||||
</pre>
|
||||
</>)
|
||||
: <div>Loading...</div>}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpDiffPage
|
||||
@@ -0,0 +1,130 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import HelpListPage from '@/pages/help/HelpListPage'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
|
||||
|
||||
describe ('Help search', () => {
|
||||
beforeEach (() => helpFixture.reset ())
|
||||
|
||||
it ('initially lists each current article once with encoded detail links', async () => {
|
||||
renderWithProviders (<HelpListPage/>, { route: '/help' })
|
||||
expect (await screen.findByRole ('link', { name: 'ホーム第十版' }))
|
||||
.toHaveAttribute ('href', '/help/guide')
|
||||
expect (screen.getByRole ('link', { name: '設定' }))
|
||||
.toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`)
|
||||
expect (screen.getByText ('2 件')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each ([
|
||||
['ホーム', '', ['ホーム第十版']],
|
||||
['', '検索', ['ホーム第十版']],
|
||||
['', 'タグ', ['設定', 'ホーム第十版']],
|
||||
['ホーム', '設定', []],
|
||||
['ホーム', '検索', ['ホーム第十版']],
|
||||
['旧ホーム', '', []],
|
||||
['', '過去限定本文', []]]) (
|
||||
'searches title %s and body %s together', async (title, body, expected) => {
|
||||
const { container } = renderWithProviders (<HelpListPage/>)
|
||||
await screen.findByText ('2 件')
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: 'タイトル' }), {
|
||||
target: { value: title } })
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '内容' }), {
|
||||
target: { value: body } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link => link.textContent))
|
||||
.toEqual (expected)
|
||||
if (expected.length === 0)
|
||||
expect (screen.getByText ('該当する記事がありません.')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('displays loading failures without stale results', async () => {
|
||||
helpFixture.loads.search.mockImplementationOnce (() => {
|
||||
throw new Error ('fixture loading failed')
|
||||
})
|
||||
renderWithProviders (<HelpListPage/>)
|
||||
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
|
||||
expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('sorts edition suffixes numerically and displays their date without a time', async () => {
|
||||
const editions = ['2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10']
|
||||
helpFixture.assets.search.default.ja = editions.map ((edition, index) => ({
|
||||
identifier: `article-${ index }`, edition, title: `article-${ index }`, body: '' }))
|
||||
const { container } = renderWithProviders (<HelpListPage/>, { route: '/help' })
|
||||
await screen.findByText ('4 件')
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link => link.textContent))
|
||||
.toEqual (['article-3', 'article-2', 'article-1', 'article-0'])
|
||||
const dates = Array.from (container.querySelectorAll ('tbody tr'), row =>
|
||||
row.children[1].textContent)
|
||||
expect (dates).toHaveLength (4)
|
||||
dates.forEach (date => {
|
||||
expect (date).toContain ('10月2日')
|
||||
expect (date).not.toContain ('Invalid Date')
|
||||
expect (date).not.toMatch (/\d{1,2}:\d{2}/)
|
||||
})
|
||||
})
|
||||
|
||||
it.each ([['ホーム', 'ほーむ', '', true], ['ほーむ', 'ホーム', '', true],
|
||||
['ホーム', 'ホーム', '', true], ['ホーム', '', 'ホーム', true],
|
||||
['ホーム', 'ほーむ', 'ホーム', true], ['ホーム', 'ほーむ', '不一致', false]] as const) (
|
||||
'searches title %s with %s and body with %s', async (title, query, text, found) => {
|
||||
const entry = { identifier: 'guide', edition: '2026-10-02.10',
|
||||
title, body: 'ほーむの案内' }
|
||||
helpFixture.assets.search.default.ja = [entry]
|
||||
const params = new URLSearchParams ({ title: query, text })
|
||||
renderWithProviders (<HelpListPage/>, { route: `/help?${ params }` })
|
||||
await screen.findByText (`${ found ? 1 : 0 } 件`)
|
||||
if (found)
|
||||
expect (screen.getByRole ('link', { name: title })).toHaveAttribute ('href', '/help/guide')
|
||||
else
|
||||
expect (screen.queryByRole ('link', { name: title })).not.toBeInTheDocument ()
|
||||
expect (entry).toEqual ({ identifier: 'guide', edition: '2026-10-02.10',
|
||||
title, body: 'ほーむの案内' })
|
||||
})
|
||||
|
||||
// ai_ci equality is documented by MySQL; these are not captured DB sort results.
|
||||
it.each (['asc', 'desc']) ('orders different titles before identifiers in order %s',
|
||||
async direction => {
|
||||
helpFixture.assets.search.default.ja = [
|
||||
{ identifier: 'alpha', edition: '2026-10-02', title: 'Zebra', body: '' },
|
||||
{ identifier: 'beta', edition: '2026-10-02', title: 'Apple', body: '' }]
|
||||
const { container } = renderWithProviders (<HelpListPage/>, {
|
||||
route: `/help?order=title:${ direction }` })
|
||||
await screen.findByText ('2 件')
|
||||
const expected = direction === 'asc'
|
||||
? ['/help/beta', '/help/alpha'] : ['/help/alpha', '/help/beta']
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
|
||||
link.getAttribute ('href'))).toEqual (expected)
|
||||
})
|
||||
|
||||
it.each (['asc', 'desc']) ('uses identifier ties for MySQL ai_ci title order %s',
|
||||
async direction => {
|
||||
helpFixture.assets.search.default.ja = [
|
||||
{ identifier: 'alpha', edition: '2026-10-02', title: 'Éclair', body: '' },
|
||||
{ identifier: 'beta', edition: '2026-10-02', title: 'eclair', body: '' }]
|
||||
const { container } = renderWithProviders (<HelpListPage/>, {
|
||||
route: `/help?order=title:${ direction }` })
|
||||
await screen.findByText ('2 件')
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
|
||||
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
|
||||
})
|
||||
|
||||
it.each (['asc', 'desc']) ('ignores title case before identifier ties in order %s',
|
||||
async direction => {
|
||||
helpFixture.assets.search.default.ja = [
|
||||
{ identifier: 'alpha', edition: '2026-10-02', title: 'home', body: '' },
|
||||
{ identifier: 'beta', edition: '2026-10-02', title: 'HOME', body: '' }]
|
||||
const { container } = renderWithProviders (<HelpListPage/>, {
|
||||
route: `/help?order=title:${ direction }` })
|
||||
await screen.findByText ('2 件')
|
||||
expect (Array.from (container.querySelectorAll ('tbody a'), link =>
|
||||
link.getAttribute ('href'))).toEqual (['/help/alpha', '/help/beta'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import SortHeader from '@/components/SortHeader'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { compareHelpEditions,
|
||||
compareHelpTitles,
|
||||
getHelpSearchEntries,
|
||||
helpEditionDate,
|
||||
normaliseHelpSearchText } from '@/lib/help'
|
||||
import { helpKeys } from '@/lib/queryKeys'
|
||||
import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
|
||||
import { dateString, inputClass } from '@/lib/utils'
|
||||
|
||||
import type { FormEvent, FC } from 'react'
|
||||
|
||||
type SearchConditions = { title: string; text: string }
|
||||
type HelpOrderField = 'title' | 'updated_at'
|
||||
type HelpOrder = `${ HelpOrderField }:${ 'asc' | 'desc' }`
|
||||
|
||||
const defaultDirection: Record<HelpOrderField, 'asc' | 'desc'> = {
|
||||
title: 'asc', updated_at: 'desc' }
|
||||
|
||||
|
||||
const HelpSearchPage: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams ()
|
||||
const pageValue = searchParams.get ('page') ?? '1'
|
||||
const pageNumber = Number (pageValue)
|
||||
const requestedPage =
|
||||
(/^[1-9]\d*$/.test (pageValue) && Number.isSafeInteger (pageNumber))
|
||||
? pageNumber : 1
|
||||
const limit = LIST_LIMIT_OPTIONS.find (
|
||||
value => String (value) === searchParams.get ('limit')) ?? 20
|
||||
const requestedOrder = searchParams.get ('order') ?? 'updated_at:desc'
|
||||
const order: HelpOrder =
|
||||
['title:asc', 'title:desc', 'updated_at:asc', 'updated_at:desc'].includes (requestedOrder)
|
||||
? requestedOrder as HelpOrder
|
||||
: 'updated_at:desc'
|
||||
const conditions = useMemo<SearchConditions> (() => ({
|
||||
title: searchParams.get ('title') ?? '',
|
||||
text: searchParams.get ('text') ?? '' }), [searchParams])
|
||||
const [title, setTitle] = useState (conditions.title)
|
||||
const [text, setText] = useState (conditions.text)
|
||||
const { data: entries = [], isLoading: loading, error } = useQuery ({
|
||||
queryKey: helpKeys.index ('ja'),
|
||||
queryFn: () => getHelpSearchEntries ('ja'),
|
||||
staleTime: Infinity })
|
||||
|
||||
useEffect (() => {
|
||||
setTitle (conditions.title)
|
||||
setText (conditions.text)
|
||||
}, [conditions.title, conditions.text])
|
||||
|
||||
const results = useMemo (() => {
|
||||
const titleQuery = normaliseHelpSearchText (conditions.title.trim ())
|
||||
const textQuery = normaliseHelpSearchText (conditions.text.trim ())
|
||||
const [field, direction] = order.split (':')
|
||||
|
||||
return (
|
||||
entries.filter (entry => (
|
||||
(!(titleQuery)
|
||||
|| normaliseHelpSearchText (entry.title).includes (titleQuery))
|
||||
&& (!(textQuery)
|
||||
|| normaliseHelpSearchText (entry.body).includes (textQuery))))
|
||||
.sort ((a, b) => {
|
||||
const comparison = field === 'title'
|
||||
? compareHelpTitles (a.title, b.title)
|
||||
: compareHelpEditions (a.edition, b.edition)
|
||||
|
||||
return (direction === 'asc' ? comparison : -comparison)
|
||||
|| a.identifier.localeCompare (b.identifier)
|
||||
}))
|
||||
}, [entries, conditions, order])
|
||||
|
||||
const totalPages = Math.ceil (results.length / limit)
|
||||
const page = requestedPage <= Math.max (1, totalPages) ? requestedPage : 1
|
||||
const pageResults = results.slice ((page - 1) * limit, page * limit)
|
||||
|
||||
const handleLimit = (value: string) => {
|
||||
const next = new URLSearchParams (searchParams)
|
||||
next.set ('limit', value)
|
||||
next.set ('page', '1')
|
||||
setSearchParams (next)
|
||||
}
|
||||
|
||||
const handleSearch = (ev: FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault ()
|
||||
const next = new URLSearchParams (searchParams)
|
||||
if (title)
|
||||
next.set ('title', title)
|
||||
else
|
||||
next.delete ('title')
|
||||
if (text)
|
||||
next.set ('text', text)
|
||||
else
|
||||
next.delete ('text')
|
||||
next.set ('page', '1')
|
||||
setSearchParams (next)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainArea className="min-w-0 max-w-full">
|
||||
<Helmet>
|
||||
<title>{`ヘルプ | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<PageTitle>ヘルプ検索</PageTitle>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>件数</span>
|
||||
<select value={limit} onChange={ev => handleLimit (ev.target.value)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm
|
||||
text-foreground">
|
||||
{LIST_LIMIT_OPTIONS.map (value => (
|
||||
<option key={value} value={value}>{value}</option>))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<form onSubmit={handleSearch} className="space-y-2">
|
||||
<FormField label="タイトル">
|
||||
{({ invalid }) => (
|
||||
<input type="text"
|
||||
aria-label="タイトル"
|
||||
value={title}
|
||||
onChange={e => setTitle (e.target.value)}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="内容">
|
||||
{({ invalid }) => (
|
||||
<input type="text"
|
||||
aria-label="内容"
|
||||
value={text}
|
||||
onChange={e => setText (e.target.value)}
|
||||
className={inputClass (invalid)}/>)}
|
||||
</FormField>
|
||||
|
||||
<div className="py-3">
|
||||
<button type="submit"
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded">
|
||||
検索
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading
|
||||
? <p className="mt-4">読み込み中...</p>
|
||||
: (error
|
||||
? <p className="mt-4 text-red-600" role="alert">{error.message}</p>
|
||||
: (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
|
||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||
<table className="table-auto w-full min-w-[640px] whitespace-nowrap
|
||||
border-collapse text-gray-900 dark:text-gray-100">
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left">
|
||||
<SortHeader<HelpOrderField>
|
||||
by="title"
|
||||
label="タイトル"
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
<th className="p-2 text-left">
|
||||
<SortHeader<HelpOrderField>
|
||||
by="updated_at"
|
||||
label="有効開始年月日"
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageResults.map (entry => {
|
||||
const updatedAt = helpEditionDate (entry.edition)
|
||||
|
||||
return (
|
||||
<tr key={entry.identifier}
|
||||
className="even:bg-gray-100 text-gray-900
|
||||
dark:even:bg-gray-700 dark:text-gray-100">
|
||||
<td className="p-2">
|
||||
<PrefetchLink
|
||||
to={`/help/${encodeURIComponent (entry.identifier)}`}>
|
||||
{entry.title}
|
||||
</PrefetchLink>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{updatedAt != null && dateString (updatedAt, 'hour')}
|
||||
</td>
|
||||
</tr>)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 0 && <Pagination page={page} totalPages={totalPages}/>}
|
||||
{results.length === 0 && <p className="mt-4">該当する記事がありません.</p>}
|
||||
</div>))}
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default HelpSearchPage
|
||||
@@ -1,14 +1,19 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiPost } from '@/lib/api'
|
||||
import { getHelpTitleIndex } from '@/lib/help'
|
||||
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||
import { buildTag, buildWikiPage } from '@/test/factories'
|
||||
import WikiNewPage from '@/pages/wiki/WikiNewPage'
|
||||
import { buildTag, buildUser, buildWikiPage } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const wikiApi = vi.hoisted (() => ({
|
||||
fetchWikiPage: vi.fn (),
|
||||
fetchWikiPageByTitle: vi.fn (),
|
||||
fetchWikiPages: vi.fn (),
|
||||
}))
|
||||
|
||||
const tagsApi = vi.hoisted (() => ({
|
||||
@@ -22,8 +27,140 @@ const postsApi = vi.hoisted (() => ({
|
||||
vi.mock ('@/lib/wiki', () => wikiApi)
|
||||
vi.mock ('@/lib/tags', () => tagsApi)
|
||||
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', () => {
|
||||
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 () => {
|
||||
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
|
||||
title: '旧タグ',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
@@ -13,10 +11,14 @@ import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||
import { getHelpTitleIndex } from '@/lib/help'
|
||||
import { fetchPosts } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { fetchTagByName } from '@/lib/tags'
|
||||
import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
@@ -33,15 +35,33 @@ const WikiDetailPage: FC = () => {
|
||||
const query = new URLSearchParams (location.search)
|
||||
const version = query.get ('version') || undefined
|
||||
|
||||
const { data: wikiPage, isLoading: loading } = useQuery ({
|
||||
const { data: wikiPage, isLoading: loading, isSuccess, isFetching, error } = useQuery ({
|
||||
enabled: Boolean (title) && !(/^\d+$/.test (title)),
|
||||
queryKey: wikiKeys.show (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 deprecated = wikiPage?.deprecatedAt != null
|
||||
|
||||
const { data: tag } = useQuery ({
|
||||
const { data: tag, isPending: tagPending,
|
||||
isFetching: tagFetching, error: tagError } = useQuery ({
|
||||
enabled: Boolean (effectiveTitle),
|
||||
queryKey: tagsKeys.show (effectiveTitle),
|
||||
queryFn: () => fetchTagByName (effectiveTitle) })
|
||||
@@ -73,10 +93,12 @@ const WikiDetailPage: FC = () => {
|
||||
if (!(/^\d+$/.test (title)))
|
||||
return
|
||||
|
||||
let active = true
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const data = await fetchWikiPage (title, { })
|
||||
if (active)
|
||||
navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true })
|
||||
}
|
||||
catch
|
||||
@@ -84,8 +106,23 @@ const WikiDetailPage: FC = () => {
|
||||
;
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [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 (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
@@ -119,7 +156,7 @@ const WikiDetailPage: FC = () => {
|
||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
||||
{deprecated && <span>(廃止)</span>}
|
||||
</h1>
|
||||
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
||||
{waiting ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
||||
|
||||
{(!(version) && posts.length > 0) && (
|
||||
<div className="not-prose">
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import * as help from '@/lib/help'
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||
import { buildWikiPage } from '@/test/factories'
|
||||
import { helpFixture } from '@/test/helpFixtures'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
||||
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||
vi.mock ('@/lib/tags', () => ({ fetchTagByName: async () => null }))
|
||||
vi.mock ('@/lib/posts', () => ({ fetchPosts: async () => ({ posts: [], count: 0 }) }))
|
||||
|
||||
|
||||
const Location = () => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
return (
|
||||
<>
|
||||
<output data-testid="location">{location.pathname}{location.search}</output>
|
||||
<button onClick={() => navigate (`/wiki/${ encodeURIComponent ('該当なし') }`)}>
|
||||
switch title
|
||||
</button>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
const renderWiki = (title: string) => {
|
||||
const route = `/wiki/${encodeURIComponent (title)}`
|
||||
const { queryClient } = renderWithProviders (
|
||||
<>
|
||||
<Location/>
|
||||
<Routes>
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
<Route path="/404" element={<div>unexpected redirect</div>}/>
|
||||
</Routes>
|
||||
</>, { route })
|
||||
return { route, queryClient }
|
||||
}
|
||||
|
||||
|
||||
describe ('Legacy Wiki to Help compatibility', () => {
|
||||
beforeEach (() => {
|
||||
helpFixture.reset ()
|
||||
vi.mocked (apiGet).mockReset ()
|
||||
vi.mocked (apiGet).mockImplementation (async path => {
|
||||
if (path === '/wiki')
|
||||
return []
|
||||
throw { isAxiosError: true, response: { status: 404 } }
|
||||
})
|
||||
})
|
||||
|
||||
it ('keeps a successful Wiki result even when Help has the same title', async () => {
|
||||
vi.mocked (apiGet).mockResolvedValueOnce (buildWikiPage ({
|
||||
title: 'ホーム', body: 'Wiki の現行本文' }))
|
||||
const { route } = renderWiki ('ホーム')
|
||||
await screen.findByText ('Wiki の現行本文')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム', 'ホーム第十版']) (
|
||||
'redirects a unique current or historical Japanese title: %s', async title => {
|
||||
renderWiki (title)
|
||||
await waitFor (() => {
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/help/guide')
|
||||
})
|
||||
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
||||
.toBeInTheDocument ()
|
||||
Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||
expect (apiGet).toHaveBeenCalledWith (
|
||||
`/wiki/title/${encodeURIComponent (title)}`, expect.anything ())
|
||||
})
|
||||
|
||||
it ('keeps the original URL and displays NotFound when no title matches', async () => {
|
||||
const { route, queryClient } = renderWiki ('該当なし')
|
||||
await screen.findByText ('404')
|
||||
await waitFor (() => {
|
||||
expect (helpFixture.loads.titles).toHaveBeenCalled ()
|
||||
expect (queryClient.isFetching ()).toBe (0)
|
||||
})
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not choose between articles with the same title', async () => {
|
||||
helpFixture.assets.titles.default.ja['ホーム'] = ['guide', '設定 手順']
|
||||
const { route, queryClient } = renderWiki ('ホーム')
|
||||
await screen.findByText ('404')
|
||||
await waitFor (() => {
|
||||
expect (helpFixture.loads.titles).toHaveBeenCalled ()
|
||||
expect (queryClient.isFetching ()).toBe (0)
|
||||
})
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
})
|
||||
|
||||
it ('waits for title lookup and does not redirect a later Wiki URL using an old title',
|
||||
async () => {
|
||||
let finish!: (index: Record<string, string[]>) => void
|
||||
const pending = new Promise<Record<string, string[]>> (resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
const lookup = vi.spyOn (help, 'getHelpTitleIndex').mockReturnValueOnce (pending)
|
||||
renderWiki ('ホーム')
|
||||
await waitFor (() => expect (lookup).toHaveBeenCalled ())
|
||||
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'switch title' }))
|
||||
await waitFor (() => expect (apiGet).toHaveBeenCalledWith (
|
||||
`/wiki/title/${ encodeURIComponent ('該当なし') }`, expect.anything ()))
|
||||
await act (async () => finish ({ 'ホーム': ['guide'] }))
|
||||
await screen.findByText ('404')
|
||||
expect (screen.getByTestId ('location'))
|
||||
.toHaveTextContent (`/wiki/${ encodeURIComponent ('該当なし') }`)
|
||||
})
|
||||
|
||||
it ('does not treat a Wiki network failure as a missing Wiki', async () => {
|
||||
vi.mocked (apiGet).mockRejectedValueOnce (new Error ('offline'))
|
||||
const { route } = renderWiki ('ホーム')
|
||||
expect (await screen.findByRole ('alert')).toHaveTextContent ('offline')
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
|
||||
expect (screen.queryByRole ('link', { name: '新規作成してください' }))
|
||||
.not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it.each ([403, 500]) ('does not redirect HTTP %s to Help', async status => {
|
||||
vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } })
|
||||
const { route, queryClient } = renderWiki ('ホーム')
|
||||
await waitFor (() => {
|
||||
expect (apiGet).toHaveBeenCalled ()
|
||||
expect (queryClient.isFetching ()).toBe (0)
|
||||
})
|
||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import MdEditor from 'react-markdown-editor-lite'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import 'react-markdown-editor-lite/lib/index.css'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import FormField from '@/components/common/FormField'
|
||||
@@ -12,12 +12,13 @@ import MainArea from '@/components/layout/MainArea'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPost } from '@/lib/api'
|
||||
import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import 'react-markdown-editor-lite/lib/index.css'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User, WikiPage } from '@/types'
|
||||
|
||||
@@ -38,6 +39,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
||||
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const qc = useQueryClient ()
|
||||
|
||||
const query = new URLSearchParams (location.search)
|
||||
const titleQuery = query.get ('title') ?? ''
|
||||
@@ -58,8 +60,13 @@ const WikiNewPage: FC<Props> = ({ user }) => {
|
||||
{
|
||||
const data = await apiPost<WikiPage> ('/wiki', formData,
|
||||
{ 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: '投稿成功!' })
|
||||
navigate (`/wiki/${ data.title }`)
|
||||
navigate (`/wiki/${ encodeURIComponent (data.title) }`)
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
|
||||
@@ -1,62 +1,58 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
||||
import { buildWikiPage } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
}))
|
||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
describe ('WikiSearchPage', () => {
|
||||
describe ('Wiki resource identity', () => {
|
||||
beforeEach (() => vi.mocked (apiGet).mockReset ())
|
||||
|
||||
it ('loads initial wiki pages and renders links', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
|
||||
|
||||
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
|
||||
expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute (
|
||||
'href',
|
||||
'/wiki/%E8%99%B9%E5%A4%8F',
|
||||
)
|
||||
expect (api.apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
|
||||
expect (await screen.findByRole ('link', { name: '虹夏' }))
|
||||
.toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F')
|
||||
expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
|
||||
})
|
||||
|
||||
it ('searches by title on submit', async () => {
|
||||
api.apiGet
|
||||
.mockResolvedValueOnce ([])
|
||||
vi.mocked (apiGet).mockResolvedValueOnce ([])
|
||||
.mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })])
|
||||
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
|
||||
await waitFor (() => expect (apiGet).toHaveBeenCalledTimes (1))
|
||||
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiGet).toHaveBeenLastCalledWith (
|
||||
'/wiki',
|
||||
{ params: { title: '検索' } },
|
||||
)
|
||||
})
|
||||
await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith (
|
||||
'/wiki', { params: { title: '検索' } }))
|
||||
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('marks deprecated wiki tags in the result title', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ([
|
||||
buildWikiPage ({
|
||||
title: '旧タグ',
|
||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||
}),
|
||||
])
|
||||
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({
|
||||
title: '旧タグ', deprecatedAt: '2026-06-01T00:00:00.000Z' })])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
expect (await screen.findByRole ('link', { name: '旧タグ' })).toBeInTheDocument ()
|
||||
expect (screen.getByText ('(廃止)').closest ('a')).toBeNull ()
|
||||
})
|
||||
|
||||
it ('renders colliding description and group IDs with stable distinct keys', async () => {
|
||||
const errors = vi.spyOn (console, 'error').mockImplementation (() => {})
|
||||
const description = buildWikiPage ({ id: 51, title: '説明', resourceType: 'tag_description' })
|
||||
const group = buildWikiPage ({ id: 51, title: 'タググループ:一覧', resourceType: 'tag_group' })
|
||||
vi.mocked (apiGet).mockResolvedValue ([description, group])
|
||||
renderWithProviders (<WikiSearchPage/>)
|
||||
|
||||
const link = await screen.findByRole ('link', { name: '旧タグ' })
|
||||
const marker = screen.getByText ('(廃止)')
|
||||
|
||||
expect (link).toBeInTheDocument ()
|
||||
expect (marker.closest ('a')).toBeNull ()
|
||||
await screen.findByRole ('link', { name: '説明' })
|
||||
expect (screen.getByRole ('link', { name: 'タググループ:一覧' }))
|
||||
.toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`)
|
||||
await waitFor (() => {
|
||||
expect (errors.mock.calls.some (args => args.some (value =>
|
||||
String (value).includes ('same key')))).toBe (false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,7 +81,8 @@ const WikiSearchPage: FC = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map (page => (
|
||||
<tr key={page.id} className="even:bg-gray-100 dark:even:bg-gray-700">
|
||||
<tr key={`${ page.resourceType }:${ page.id }`}
|
||||
className="even:bg-gray-100 dark:even:bg-gray-700">
|
||||
<td className="p-2">
|
||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
|
||||
{page.title}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
|
||||
|
||||
import { useMDXComponents } from '@/mdx-components'
|
||||
import viteConfig from '../../vite.config'
|
||||
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
type Transform = (source: string, id: string) => Promise<{ code: string } | null>
|
||||
|
||||
|
||||
export const transformMdx = async (source: string, suffix = '.mdx') => {
|
||||
const plugins = viteConfig.plugins?.flat () as { name: string; transform: Transform }[]
|
||||
const plugin = plugins.find (entry => entry.name === '@mdx-js/rollup')
|
||||
if (plugin == null)
|
||||
throw new Error ('The application MDX compiler is not registered')
|
||||
return plugin.transform.call (plugin, source, `/test/help-table${suffix}`)
|
||||
}
|
||||
|
||||
|
||||
export const compileMdx = async (source: string): Promise<ComponentType> => {
|
||||
const result = await transformMdx (source)
|
||||
if (result == null)
|
||||
throw new Error ('MDX was not compiled')
|
||||
const code = transpileModule (result.code, {
|
||||
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
|
||||
const exports = { } as { default: ComponentType }
|
||||
const requireFixture = (name: string) => {
|
||||
if (name === 'react/jsx-runtime')
|
||||
return jsxRuntime
|
||||
if (name === '@/mdx-components')
|
||||
return { useMDXComponents }
|
||||
throw new Error (`Unexpected MDX dependency: ${name}`)
|
||||
}
|
||||
new Function ('require', 'exports', code) (requireFixture, exports)
|
||||
return exports.default
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export const buildUser = (overrides: Partial<User> = {}): User => ({
|
||||
})
|
||||
|
||||
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
|
||||
resourceType: 'tag_description',
|
||||
id: 1,
|
||||
title: 'テストWiki',
|
||||
deprecatedAt: null,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createElement } from 'react'
|
||||
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import source from '@/lib/help.ts?raw'
|
||||
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
import type * as Help from '@/lib/help'
|
||||
|
||||
export const editionRows = [
|
||||
['guide', 'ja', '2026-10-01', '旧ホーム', '過去限定本文'],
|
||||
['guide', 'ja', '2026-10-02', 'ホーム', 'タグを検索する'],
|
||||
['guide', 'ja', '2026-10-02.2', 'ホーム第二版', '第二版本文'],
|
||||
['guide', 'ja', '2026-10-02.3', 'ホーム第三版', '第三版本文'],
|
||||
['guide', 'ja', '2026-10-02.10', 'ホーム第十版', 'タグを検索する 第十版本文'],
|
||||
['guide', 'ja', '2026-10-02.11', '未公開ホーム', '未公開限定本文'],
|
||||
['guide', 'en', '2026-10-02', 'Home', 'English body'],
|
||||
['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const
|
||||
|
||||
const compiled = transpileModule (source, {
|
||||
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
|
||||
|
||||
|
||||
// Supply generated virtual modules, executing the actual runtime Help module.
|
||||
export const createHelpFixture = () => {
|
||||
const articles: Record<string, Help.ArticleMeta> = {
|
||||
guide: { identifier: 'guide', canonicalLanguage: 'ja', languages: {
|
||||
ja: { currentEdition: '2026-10-02.10', publishedEditions: [
|
||||
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] },
|
||||
en: { currentEdition: '2026-10-02', publishedEditions: ['2026-10-02'] } } },
|
||||
'設定 手順': { identifier: '設定 手順', canonicalLanguage: 'ja', languages: {
|
||||
ja: { currentEdition: '2026-10-03', publishedEditions: ['2026-10-03'] } } } }
|
||||
const published = editionRows.filter (([id, language, edition]) =>
|
||||
articles[id].languages[language].publishedEditions.includes (edition))
|
||||
const raw = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
|
||||
`../../help/${id}/editions/${language}/${edition}.mdx`,
|
||||
vi.fn (async () => `# ${title}\n\n${body}`)]))
|
||||
const moduleLoads = Object.fromEntries (published.map (([id, language, edition, title, body]) => [
|
||||
`../../help/${id}/editions/${language}/${edition}.mdx`,
|
||||
vi.fn (async (): Promise<{ default: ComponentType }> => ({
|
||||
default: () => createElement ('div', null,
|
||||
createElement ('h1', null, title), createElement ('p', null, body)) }))]))
|
||||
// Dynamic imports reuse a module namespace even when called more than once.
|
||||
const caches: Record<string, { promise?: Promise<{ default: ComponentType }> }> = { }
|
||||
const modules = Object.fromEntries (Object.entries (moduleLoads).map (([path, load]) => {
|
||||
caches[path] = { }
|
||||
return [path, vi.fn (() => caches[path].promise ??= load ())]
|
||||
}))
|
||||
const texts = Object.fromEntries (published.map (([id, language, edition, title, body]) => [
|
||||
`${ id }/${ language }/${ edition }`,
|
||||
vi.fn (async () => ({ default: { title, body: `\n${ body }` } }))]))
|
||||
const components = Object.fromEntries (published.map (([id, language, edition]) => [
|
||||
`${ id }/${ language }/${ edition }`,
|
||||
modules[`../../help/${ id }/editions/${ language }/${ edition }.mdx`]]))
|
||||
const sources = Object.fromEntries (published.map (([id, language, edition]) => [
|
||||
`${ id }/${ language }/${ edition }`,
|
||||
async () => ({ default: await raw[
|
||||
`../../help/${ id }/editions/${ language }/${ edition }.mdx`] () })]))
|
||||
const search: Record<string, Help.HelpSearchEntry[]> = { }
|
||||
const changes: Record<string, Help.HelpChange[]> = { }
|
||||
const titles: Record<string, Help.HelpTitleIndex> = { }
|
||||
published.forEach (([id, language, edition, title, body]) => {
|
||||
const editions = articles[id].languages[language].publishedEditions
|
||||
const index = editions.indexOf (edition)
|
||||
const searchRows = search[language] ??= []
|
||||
if (articles[id].languages[language].currentEdition === edition)
|
||||
searchRows.push ({ identifier: id, edition, title, body: `\n${ body }` })
|
||||
const rows = changes[language] ??= []
|
||||
rows.push ({ identifier: id, edition, title,
|
||||
pred: editions[index - 1] ?? null,
|
||||
change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] })
|
||||
const lookup = (titles[language] ??= { })
|
||||
const identifiers = lookup[title.trim ().normalize ('NFKC').toLowerCase ()] ??= []
|
||||
if (!(identifiers.includes (id)))
|
||||
identifiers.push (id)
|
||||
})
|
||||
Object.values (changes).forEach (rows => rows.sort ((a, b) => {
|
||||
const [dateA, revisionA = '1'] = a.edition.split ('.')
|
||||
const [dateB, revisionB = '1'] = b.edition.split ('.')
|
||||
return dateB.localeCompare (dateA) || Number (revisionB) - Number (revisionA)
|
||||
|| a.identifier.localeCompare (b.identifier)
|
||||
}))
|
||||
const assets = {
|
||||
articles: { default: articles }, search: { default: search },
|
||||
changes: { default: changes }, titles: { default: titles } }
|
||||
const snapshot = JSON.stringify (assets)
|
||||
const loaders = { components, sources, texts }
|
||||
const loads = {
|
||||
articles: vi.fn (() => assets.articles), search: vi.fn (() => assets.search),
|
||||
changes: vi.fn (() => assets.changes), titles: vi.fn (() => assets.titles),
|
||||
loaders: vi.fn (() => loaders) }
|
||||
const api = { } as typeof Help
|
||||
const requireFixture = (name: string) => {
|
||||
const key = name.replace ('virtual:help/', '') as keyof typeof loads
|
||||
if (Object.hasOwn (loads, key))
|
||||
return loads[key] ()
|
||||
throw new Error (`Unexpected Help dependency: ${name}`)
|
||||
}
|
||||
new Function ('require', 'exports', compiled) (requireFixture, api)
|
||||
const reset = () => {
|
||||
Object.assign (assets, JSON.parse (snapshot))
|
||||
editionRows.forEach (([id, language, edition, title, body]) => {
|
||||
const path = `../../help/${ id }/editions/${ language }/${ edition }.mdx`
|
||||
raw[path].mockReset ().mockResolvedValue (`# ${ title }\n\n${ body }`)
|
||||
})
|
||||
published.forEach (([id, language, edition, title, body]) => {
|
||||
const path = `../../help/${ id }/editions/${ language }/${ edition }.mdx`
|
||||
caches[path].promise = undefined
|
||||
moduleLoads[path].mockClear ()
|
||||
modules[path].mockReset ().mockImplementation (() =>
|
||||
caches[path].promise ??= moduleLoads[path] ())
|
||||
texts[`${ id }/${ language }/${ edition }`].mockReset ().mockResolvedValue ({
|
||||
default: { title, body: `\n${ body }` } })
|
||||
})
|
||||
loads.articles.mockReset ().mockImplementation (() => assets.articles)
|
||||
loads.search.mockReset ().mockImplementation (() => assets.search)
|
||||
loads.changes.mockReset ().mockImplementation (() => assets.changes)
|
||||
loads.titles.mockReset ().mockImplementation (() => assets.titles)
|
||||
loads.loaders.mockReset ().mockImplementation (() => loaders)
|
||||
}
|
||||
return { api, assets, loads, raw, modules, moduleLoads, texts, reset }
|
||||
}
|
||||
|
||||
|
||||
export const helpFixture = createHelpFixture ()
|
||||
@@ -452,6 +452,7 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha
|
||||
|
||||
export type WikiPage = {
|
||||
id: number
|
||||
resourceType: 'tag_description' | 'tag_group'
|
||||
title: string
|
||||
deprecatedAt: string | null
|
||||
createdUserId: number
|
||||
|
||||
変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示
新しいイシューから参照
ユーザーをブロックする