コミットを比較

..
10 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 4f7c12b719 #425 2026-10-11 03:15:59 +09:00
みてるぞ aec65c36c6 #425 2026-10-11 03:10:35 +09:00
みてるぞ 02451e4caa #425 2026-10-11 01:32:46 +09:00
みてるぞ 20b5abbd2c #425 2026-10-10 13:42:24 +09:00
みてるぞ 7840e15d1a #425 2026-10-10 11:14:01 +09:00
みてるぞ 2052e14b18 #425 2026-10-09 22:25:59 +09:00
みてるぞ 890ffb57e1 #425 2026-10-09 20:38:14 +09:00
みてるぞ 08f3b6d716 #425 2026-10-09 20:27:51 +09:00
みてるぞ 343f33e1f9 #425 2026-10-08 23:11:32 +09:00
みてるぞ 9ca713d6e4 #425 2026-10-08 23:06:04 +09:00
39個のファイルの変更、579行の追加、1558行の削除
+71 -15
ファイルの表示
@@ -8,6 +8,7 @@ class WikiPagesController < ApplicationController
descriptions = descriptions =
TagDescription.where(language_code: 'ja') TagDescription.where(language_code: 'ja')
.includes(tag: :tag_names) .includes(tag: :tag_names)
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
tag_groups = TagGroup.all tag_groups = TagGroup.all
if title.present? if title.present?
@@ -18,10 +19,12 @@ class WikiPagesController < ApplicationController
.where('tag_names.name LIKE ?', pattern).limit(20) .where('tag_names.name LIKE ?', pattern).limit(20)
tag_groups = tag_groups.where( tag_groups = tag_groups.where(
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20) 'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
end end
json = descriptions.map { |description| WikiPageRepr.tag_description(description) } json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) }) json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
json.concat(WikiPageRepr.base(pages))
json = json.first(20) if title.present? json = json.first(20) if title.present?
render json: render json:
end end
@@ -34,7 +37,11 @@ class WikiPagesController < ApplicationController
tag_group = TagGroup.find_by(id: params[:id]) tag_group = TagGroup.find_by(id: params[:id])
return render_tag_group tag_group if tag_group return render_tag_group tag_group if tag_group
render_wiki_page_or_404 nil page = WikiPage.joins(:tag_name)
.includes(tag_name: :tag)
.find_by(id: params[:id])
render_wiki_page_or_404 page
end end
def show_by_title def show_by_title
@@ -51,14 +58,20 @@ class WikiPagesController < ApplicationController
return render_wiki_page_by_tag_description tag_description if tag_description return render_wiki_page_by_tag_description tag_description if tag_description
render_wiki_page_or_404 nil page =
WikiPage
.joins(:tag_name)
.includes(tag_name: :tag)
.find_by(tag_name: { name: title })
render_wiki_page_or_404 page
end end
def exists def exists
return head :no_content if TagDescription.exists?(id: params[:id]) return head :no_content if TagDescription.exists?(id: params[:id])
return head :no_content if TagGroup.exists?(id: params[:id]) return head :no_content if TagGroup.exists?(id: params[:id])
head :not_found head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
end end
def exists_by_title def exists_by_title
@@ -69,7 +82,8 @@ class WikiPagesController < ApplicationController
end end
return head :no_content if tag_description_by_title(title) return head :no_content if tag_description_by_title(title)
head :not_found exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
head exists ? :no_content : :not_found
end end
def diff def diff
@@ -98,7 +112,15 @@ class WikiPagesController < ApplicationController
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
'deprecated_at' => nil } 'deprecated_at' => nil }
else else
raise 'お前やりませんねぇスギ!' page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
return render_unprocessable_entity('差分を表示できない版です.')
end
from_body = from_rev&.body
to_body = to_rev.body
resource = WikiPageRepr.base(page)
end end
end end
@@ -138,17 +160,25 @@ class WikiPagesController < ApplicationController
status: :created) status: :created)
end end
page = page = if title.include?(':')
if title.include?(':') ApplicationRecord.transaction do
raise 'お前やりませんねぇスギ!' tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
else name.script_code = 'Jpan'
tag_name = TagName.find_by(language_code: 'ja', name: title) name.primary_flg = true
return render_unprocessable_entity field: :title unless tag_name&.tag name.auto_generated = false
TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user: current_user, message:)
end end
Wiki::Commit.create_legacy_content!(
tag_name:, body:, created_by_user: current_user, message:)
end
else
tag_name = TagName.find_by(language_code: 'ja', name: title)
return render_unprocessable_entity field: :title unless tag_name&.tag
TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user: current_user, message:)
end
render json: WikiPageRepr.base(page), status: :created render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record render_validation_error e.record
@@ -243,7 +273,23 @@ class WikiPagesController < ApplicationController
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id)) return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
end end
json = [] pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
revisions = WikiRevision.joins(wiki_page: :tag_name)
.where(wiki_page_id: pages.select(:id))
.includes(:created_user, wiki_page: { tag_name: :tag })
.order(id: :desc).limit(200)
json = revisions.map { |rev|
{ revision_id: rev.id,
pred: rev.base_revision_id,
succ: nil,
wiki_page: { id: rev.wiki_page_id,
title: rev.wiki_page.title,
deprecated_at: rev.wiki_page.deprecated_at },
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
kind: rev.kind,
message: rev.message,
timestamp: rev.created_at }
}
unless id unless id
json.concat(tag_group_changes(TagGroupVersion.all)) json.concat(tag_group_changes(TagGroupVersion.all))
json.concat(description_changes(TagDescription.where(language_code: 'ja'))) json.concat(description_changes(TagDescription.where(language_code: 'ja')))
@@ -407,6 +453,16 @@ class WikiPagesController < ApplicationController
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title }) .find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
end end
def unmigrated_wiki_pages
descriptions = TagDescription.where(language_code: 'ja')
WikiPage
.joins(:tag_name)
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
descriptions.select(:tag_id))
end
def description_changes descriptions def description_changes descriptions
versions = TagDescriptionVersion.where(language_code: 'ja', versions = TagDescriptionVersion.where(language_code: 'ja',
tag_id: descriptions.select(:tag_id)) tag_id: descriptions.select(:tag_id))
-51
ファイルの表示
@@ -1,51 +0,0 @@
namespace :help do
desc 'ヘルプ移行'
task migrate: :environment do
help_path = ENV.fetch('BTRC_HUB_HELP_PATH')
abort 'ヘルプ置場指定してね.' if help_path.blank?
# ファイルがすでに存在する場合は通さなぃ.
abort 'ファイル消してね.' if Dir.glob("#{ help_path }/*").present?
# 現状物理削除はなぃはず
pages =
WikiPage
.joins(:tag_name)
.where(tag_names: { tag_id: nil, language_code: 'ja' })
.where.not("tag_names.name LIKE 'タググループ:%'")
puts "移行対象: #{pages.count} 件"
abort '移行対象がありません.' if pages.empty?
pages.find_each do |page|
puts page.body
puts page.title
puts 'この identifier は何にする?'
identifier = $stdin.gets.chomp
Dir.mkdir "#{ help_path }/#{ identifier }"
Dir.mkdir "#{ help_path }/#{ identifier }/editions"
Dir.mkdir "#{ help_path }/#{ identifier }/editions/ja"
last_file_name = ''
WikiVersion.where(wiki_page_id: page.id).order(:version_no).each do |ver|
last_file_name, last_file_name_no = last_file_name.split('.')
file_name = ver.created_at.strftime('%Y-%m-%d')
if file_name == last_file_name
file_name += ".#{ (last_file_name_no || 1).to_i + 1 }"
end
body = "# #{ ver.title.sub(/^ヘルプ:/, '') }\n\n#{ ver.body }"
File.write "#{ help_path }/#{ identifier }/editions/ja/#{ file_name }.mdx", body
last_file_name = file_name
end
article_meta = { 'identifier' => identifier,
'canonical_language' => 'ja',
'languages' => { 'ja' => { 'current_edition' => last_file_name } } }
File.write "#{ help_path }/#{ identifier }/article.yml", article_meta.to_yaml
end
end
end
+4 -5
ファイルの表示
@@ -1,11 +1,10 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag description Wiki compatibility', type: :request do RSpec.describe 'Tag description Wiki compatibility', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'description_contract') } let!(:tag_name) { create(:tag_name, name: 'description_contract') }
let!(:page) do let!(:page) do
seed_wiki_page( Wiki::Commit.create_content!(
tag_name:, body: 'old-revision', created_by_user: user) tag_name:, body: 'old-revision', created_by_user: user)
end end
let!(:revisions) do let!(:revisions) do
@@ -243,8 +242,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
end end
describe 'mixed changes' do describe 'mixed changes' do
it 'excludes unmigrated history and migrated legacy-only revisions' do it 'includes unmigrated history and ignores migrated legacy-only revisions' do
legacy_page = seed_wiki_page( legacy_page = Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'Help_mixed'), tag_name: create(:tag_name, name: 'Help_mixed'),
body: 'help', created_by_user: user) body: 'help', created_by_user: user)
legacy_revision = legacy_page.current_revision legacy_revision = legacy_page.current_revision
@@ -257,7 +256,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id } migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id) expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id)
expect(json).not_to include(include( expect(json).to include(include(
'revision_id' => legacy_revision.id, 'revision_id' => legacy_revision.id,
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed'))) 'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
end end
+57 -24
ファイルの表示
@@ -1,7 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag description Wiki dual writes', type: :request do RSpec.describe 'Tag description Wiki dual writes', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'description_write') } let!(:tag_name) { create(:tag_name, name: 'description_write') }
let(:tag) { create(:tag, primary_tag_name: tag_name) } let(:tag) { create(:tag, primary_tag_name: tag_name) }
@@ -32,18 +31,25 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
headers: headers headers: headers
end end
it 'creates a description without implicitly creating Wiki records' do it 'creates both current records and histories with a shared revision ID' do
expect { create_description } expect { create_description }
.to change(WikiPage, :count).by(0) .to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(0) .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(0) .and change(WikiVersion, :count).by(1)
.and change(TagDescription, :count).by(1) .and change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
expect(description).to have_attributes(body: 'initial', version_no: 1) version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')
expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')) expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1)
.to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id) expect(version).to have_attributes(
id: page.current_revision.id, body: 'initial', version_no: 1,
event_type: 'create', created_by_user_id: user.id)
expect(page.body).to eq(description.body)
expect(page.current_revision.body).to eq(version.body)
expect(page.wiki_versions.last.body).to eq(version.body)
end end
['missing_description', 'orphan_description'].each do |title| ['missing_description', 'orphan_description'].each do |title|
@@ -59,19 +65,48 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
end end
end end
[false, true].each do |linked| it 'creates a namespaced legacy Wiki resource without a Tag' do
it "rejects namespaced creation with Tag present: #{ linked }" do expect do
name = create(:tag_name, name: 'Help:frozen') post '/wiki', params: { title: 'Help:description_write', body: 'legacy' },
create(:tag, primary_tag_name: name) if linked headers: headers
before_state = stored_state end.to change(WikiPage, :count).by(1)
post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers .and change(WikiRevision, :count).by(1)
expect(response.status).to be_between(400, 499) .and change(WikiVersion, :count).by(1)
expect(stored_state).to eq(before_state) .and change(TagDescription, :count).by(0)
end .and change(TagDescriptionVersion, :count).by(0)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
expect(page.tag_name.tag).to be_nil
expect(page.body).to eq('legacy')
expect(page.current_revision.body).to eq('legacy')
end end
[TagDescription, TagDescriptionVersion].each do |model| it 'creates only legacy Wiki for a namespaced title with an existing Tag' do
it "rolls back new description creation when #{ model.name } persistence fails" 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
before_state = stored_state before_state = stored_state
aggregate_failures do aggregate_failures do
fail_save(model) { create_description } fail_save(model) { create_description }
@@ -84,7 +119,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
describe 'PUT /wiki/:id' do describe 'PUT /wiki/:id' do
let!(:page) do let!(:page) do
tag tag
seed_wiki_page( Wiki::Commit.create_content!(
tag_name:, body: 'before', created_by_user: user) tag_name:, body: 'before', created_by_user: user)
end end
let!(:first_revision) { page.current_revision } let!(:first_revision) { page.current_revision }
@@ -103,8 +138,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
expect(page.reload.version_no).to eq(1) expect(page.reload.version_no).to eq(1)
expect(description.version_no).to eq(1) expect(description.version_no).to eq(1)
expect { update_description } expect { update_description }
.to change(WikiPage, :count).by(0) .to change(WikiRevision, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -123,8 +157,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
tag_name.update_columns(tag_id: nil) tag_name.update_columns(tag_id: nil)
expect { update_description } expect { update_description }
.to change(WikiPage, :count).by(0) .to change(WikiRevision, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1)
+22 -27
ファイルの表示
@@ -1,7 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag group Wiki facade reads', type: :request do RSpec.describe 'Tag group Wiki facade reads', type: :request do
include WikiRecords
include TagGroupRecords include TagGroupRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
@@ -70,36 +69,34 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end end
end end
it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do
help = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user) tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user)
get '/wiki' get '/wiki'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include( expect(json.map { _1.fetch('title') }).to include(
'タググループ:読取り契約', '新タグ') 'タググループ:読取り契約', help.title, '新タグ')
expect(json.map { _1.fetch('title') }).not_to include(help.title)
end end
it 'excludes Help when its ID collides with a new group' do it 'keeps Help readable when its ID collides with a new group' do
help = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body', tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
created_by_user: user) created_by_user: user)
seed_group(name: '別 ID重複', id: help.id, description: 'group body') seed_group(name: '別 ID重複', id: help.id, description: 'group body')
get '/wiki' get '/wiki'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複') expect(json.map { _1.fetch('title') }).to include(help.title, 'タググループ:別 ID重複')
expect(json.map { _1.fetch('title') }).not_to include(help.title)
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }" get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq('help body')
get '/wiki/changes' get '/wiki/changes'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.map { _1.dig('wiki_page', 'title') }).to include( expect(json.map { _1.dig('wiki_page', 'title') }).to include(
'タググループ:別 ID重複') help.title, 'タググループ:別 ID重複')
expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title)
end end
it 'finds groups by a partial prefix of their displayed title' do it 'finds groups by a partial prefix of their displayed title' do
@@ -125,7 +122,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end end
it 'orders mixed resource history by timestamp rather than independent IDs' do it 'orders mixed resource history by timestamp rather than independent IDs' do
help = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help', tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
created_by_user: user) created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
@@ -139,8 +136,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) } timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
expect(timestamps).to eq(timestamps.sort.reverse) expect(timestamps).to eq(timestamps.sort.reverse)
titles = json.map { _1.dig('wiki_page', 'title') } titles = json.map { _1.dig('wiki_page', 'title') }
expect(titles).not_to include(help.title) expect(titles.index('新タグ')).to be < titles.index(help.title)
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約') expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約')
end end
it 'renders nullable group editors without raising' do it 'renders nullable group editors without raising' do
@@ -169,16 +166,16 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約')) 'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
end end
it 'includes group history but excludes all legacy-only revisions' do it 'includes Help and group history but excludes legacy-only migrated revisions' do
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user) extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
help = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user) tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
get '/wiki/changes' get '/wiki/changes'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' } rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' }
expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id]) expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id) expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
expect(json).not_to include(include( expect(json).to include(include(
'revision_id' => help.current_revision.id, 'revision_id' => help.current_revision.id,
'wiki_page' => include('title' => help.title))) 'wiki_page' => include('title' => help.title)))
end end
@@ -217,7 +214,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
context 'when Help, description and group share the same numeric ID' do context 'when Help, description and group share the same numeric ID' do
let(:colliding_help) do let(:colliding_help) do
seed_wiki_page( Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:三者ID衝突'), tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
body: 'help', created_by_user: user) body: 'help', created_by_user: user)
end end
@@ -242,12 +239,13 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
colliding_description_version colliding_description_version
end end
it 'lists both new resources sharing a numeric ID without legacy Help' do it 'lists all three resources without deduplicating their numeric ID' do
get '/wiki' get '/wiki'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('id') == colliding_help.id } rows = json.select { _1.fetch('id') == colliding_help.id }
expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly( expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly(
[colliding_help.id, colliding_help.title],
[colliding_description.id, '新タグ'], [colliding_description.id, '新タグ'],
[colliding_group_id, 'タググループ:三者ID衝突']) [colliding_group_id, 'タググループ:三者ID衝突'])
end end
@@ -261,6 +259,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')] [row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
end end
expect(histories).to contain_exactly( expect(histories).to contain_exactly(
[colliding_help.current_revision.id, colliding_help.title],
[colliding_description_version.id, '新タグ'], [colliding_description_version.id, '新タグ'],
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突']) [group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
end end
@@ -286,8 +285,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end end
end end
it 'resolves new resources by title while excluding colliding legacy Help' do it 'resolves colliding group, Help and description IDs by title' do
help = seed_wiki_page( help = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user) tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
seed_group(name: 'ID衝突', id: help.id, description: 'group') seed_group(name: 'ID衝突', id: help.id, description: 'group')
TagDescription.create!( TagDescription.create!(
@@ -295,15 +294,11 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
TagDescriptionVersion.create!( TagDescriptionVersion.create!(
tag_id: new_tag.id, language_code: 'ja', version_no: 1, tag_id: new_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'description', created_at: Time.current) event_type: 'create', body: 'description', created_at: Time.current)
{ 'タググループ:ID衝突' => 'group', '新タグ' => 'description' } { 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => 'description' }
.each do |title, body| .each do |title, body|
get "/wiki/title/#{ CGI.escapeURIComponent(title) }" get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq(body) expect(json.fetch('body').strip).to eq(body)
end end
without_group_legacy_reads do
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
end
expect(response).to have_http_status(:not_found)
end end
end end
+9 -8
ファイルの表示
@@ -33,11 +33,8 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
end end
end end
it 'creates the group and first snapshot without new Wiki records' do it 'creates the group, first snapshot and compatibility Wiki history' do
expect { create_group } create_group
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
group = group_rows('tag_groups', name: '新規').sole group = group_rows('tag_groups', name: '新規').sole
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規') expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
@@ -45,6 +42,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
version = group_version(group.fetch('id'), 1) version = group_version(group.fetch('id'), 1)
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id) expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id) expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
expect(page.body.strip).to eq('- 洗澡鹿')
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.sole.body).to eq(page.body)
# New IDs are independently allocated; no global identity assertion here.
end end
it 'keeps repeated placements but only one membership and one snapshot member' do it 'keeps repeated placements but only one membership and one snapshot member' do
@@ -291,9 +293,8 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
expect(group_state).to eq(before_state) expect(group_state).to eq(before_state)
end end
tables = TagGroupRecords::GROUP_TABLES (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] if operation == :update .each do |table|
tables.each do |table|
it "rolls back all writes after a real #{ table } write fails" do it "rolls back all writes after a real #{ table } write fails" do
expect_atomic_group_write(table) { write_group(operation, headers) } expect_atomic_group_write(table) { write_group(operation, headers) }
end end
+7 -8
ファイルの表示
@@ -1,7 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag group write integrity', type: :request do RSpec.describe 'Tag group write integrity', type: :request do
include WikiRecords
include TagGroupRecords include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers include ActiveSupport::Testing::TimeHelpers
@@ -25,7 +24,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
end end
def create_help body def create_help body
seed_wiki_page( Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user) tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
end end
@@ -137,14 +136,14 @@ RSpec.describe 'Tag group write integrity', type: :request do
id = seed_group(name: '更新', id: help.id, members:, placements:) id = seed_group(name: '更新', id: help.id, members:, placements:)
help_before = help.attributes help_before = help.attributes
expect do put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
end.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).name).to eq('改名') page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' })
expect(page.id).not_to eq(help.id)
expect(page.body).to eq('- 洗澡鹿')
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.sole.body).to eq(page.body)
expect(help.reload.attributes).to eq(help_before) expect(help.reload.attributes).to eq(help_before)
end end
+13 -23
ファイルの表示
@@ -391,18 +391,7 @@ RSpec.describe 'Tag group native API', type: :request do
end end
end end
it 'creates native state without adding compatibility Wiki records' do [:create, :update].each do |operation|
expect { native_write(:create) }
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:created)
expect_group_snapshot(
json.fetch('id'), number: 1, members: snapshot_members,
placements:, description: '全体の説明')
end
[:update].each do |operation|
it "synchronises Wiki current state and histories on native #{ operation }" do it "synchronises Wiki current state and histories on native #{ operation }" do
native_write(operation) native_write(operation)
expect(response).to have_http_status(operation == :create ? :created : :ok) expect(response).to have_http_status(operation == :create ? :created : :ok)
@@ -429,18 +418,19 @@ RSpec.describe 'Tag group native API', type: :request do
end end
end end
it 'assigns the next group ID from the existing maximum' do it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do
child_id inserts = []
expected_id = (TagGroup.maximum(:id) || 0) + 1 subscriber = lambda do |*args|
native_write(:create) sql = args.last.fetch(:sql)
expect(response).to have_http_status(:created) inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i)
expect(json.fetch('id')).to eq(expected_id)
end
TagGroupRecords::GROUP_TABLES.each do |table|
it "rolls back native creation after #{ table } writes" do
expect_atomic_group_write(table) { native_write(:create) }
end end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
native_write(:create)
end
expect(response).to have_http_status(:created)
expect(inserts.size).to eq(1)
columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') }
expect(columns).not_to include('id')
end end
it 'updates a native group through Wiki and then reads structured current data' do it 'updates a native group through Wiki and then reads structured current data' do
-91
ファイルの表示
@@ -1,91 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag resource ID allocation', type: :request do
include TagGroupRecords
include WikiRecords
let!(:user) { create_member_user! }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
def seed_description id
tag = create(:tag, primary_name: "description_id_#{ id }")
TagDescription.create!(id:, tag:, language_code: 'ja', body: 'existing', version_no: 1)
end
def create_resource resource
if resource == :description
tag = create(:tag, primary_name: 'new_description')
post '/wiki', params: { title: tag.name('ja'), body: 'new description' }, headers: headers
else
post '/tag_groups', params: {
name: 'new_group', description: 'new group', members: [], placements: [] },
headers:, as: :json
end
expect(response).to have_http_status(:created)
end
[:description, :group].each do |resource|
it "ignores 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(2)
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 } from its own maximum, preserving gaps and existing rows" 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)
expected_id = resource == :description ? 6 : 9
expect(json.fetch('id')).to eq(expected_id)
expect(TagDescription.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(descriptions)
expect(TagGroup.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(groups)
expect(TagDescription.exists?(id: 3)).to be(false)
expect(TagGroup.exists?(id: 2)).to be(false)
end
end
it 'preserves group ID and unrelated rows on update' do
id = seed_group(name: 'existing', id: 5, description: 'before')
other_id = seed_group(name: 'other', id: 8, description: 'other')
other = TagGroup.find(other_id).attributes
put "/tag_groups/#{ id }", params: {
name: 'renamed', description: 'after', members: [], placements: [] }, headers:, as: :json
expect(response).to have_http_status(:ok)
expect(json.fetch('id')).to eq(id)
expect(TagGroup.find(id).description).to eq('after')
expect(TagGroup.find(other_id).attributes).to eq(other)
expect(TagGroup.count).to eq(2)
end
it 'preserves description identity on a compatibility update' do
tag = create(:tag, primary_name: 'existing_description')
page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
id = description.id
put "/wiki/#{ id }", params: {
title: tag.name('ja'), body: 'after', base_revision_id: page.current_revision.id },
headers: headers
expect(response).to have_http_status(:ok)
expect(description.reload.id).to eq(id)
expect(description.body).to eq('after')
expect(TagDescription.count).to eq(1)
end
end
+1 -2
ファイルの表示
@@ -1,7 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag and wiki history integrity', type: :request do RSpec.describe 'Tag and wiki history integrity', type: :request do
include WikiRecords
let(:member_user) { create(:user, role: 'member') } let(:member_user) { create(:user, role: 'member') }
def stub_current_user user def stub_current_user user
@@ -14,7 +13,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'), tag_name: primary_tag_name_for(tag, 'ja'),
body:, body:,
created_by_user: user, created_by_user: user,
+1 -2
ファイルの表示
@@ -1,7 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Wiki conflict handling', type: :request do RSpec.describe 'Wiki conflict handling', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
@@ -10,7 +9,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
it 'returns 409 when base_revision_id is stale' do it 'returns 409 when base_revision_id is stale' do
page = page =
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'wiki_conflict_request'), tag_name: create(:tag_name, name: 'wiki_conflict_request'),
body: 'first', body: 'first',
created_by_user: user, created_by_user: user,
-59
ファイルの表示
@@ -1,59 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki creation freeze', type: :request do
include TagGroupRecords
include WikiRecords
let!(:user) { create_member_user! }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
it 'updates an independent group without implicitly creating a legacy page' do
id = seed_group(name: 'independent', description: 'before')
expect do
put "/tag_groups/#{ id }", params: {
name: 'independent', description: 'after', members: [], placements: [] },
headers:, as: :json
end.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).description).to eq('after')
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
+45 -17
ファイルの表示
@@ -2,7 +2,6 @@ require 'cgi'
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Wiki history integrity', type: :request do RSpec.describe 'Wiki history integrity', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
@@ -10,7 +9,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
end end
def create_wiki_page title:, body: 'body', message: 'init', user: self.user def create_wiki_page title:, body: 'body', message: 'init', user: self.user
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
@@ -18,14 +17,41 @@ RSpec.describe 'Wiki history integrity', type: :request do
end end
describe 'POST /wiki' do describe 'POST /wiki' do
it 'rejects frozen creation without partial page or history' do it 'creates wiki_page, wiki_revision, and wiki_version atomically' do
expect do expect {
post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' }, post '/wiki',
params: {
title: 'Help:wiki_history_create_atomic',
body: "a\nb\nc",
message: 'initial commit',
},
headers: auth_headers(user) headers: auth_headers(user)
end.to change(WikiPage, :count).by(0) }
.and change(WikiRevision, :count).by(0) .to change(WikiPage, :count).by(1)
.and change(WikiVersion, :count).by(0) .and change(WikiRevision, :count).by(1)
expect(response.status).to be_between(400, 499) .and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('Help:wiki_history_create_atomic')
expect(page.body).to eq("a\nb\nc")
expect(revision).to be_content
expect(revision.message).to eq('initial commit')
expect(revision.lines_count).to eq(3)
expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'Help:wiki_history_create_atomic',
body: "a\nb\nc",
reason: 'initial commit',
created_by_user_id: user.id
)
end end
it 'returns 422 and creates nothing when normalised body is blank' do it 'returns 422 and creates nothing when normalised body is blank' do
@@ -45,19 +71,21 @@ RSpec.describe 'Wiki history integrity', type: :request do
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist .where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
end end
it 'rejects frozen creation without modifying an existing page or history' do it 'returns 422 and creates no partial page when title already exists' do
create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first') create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first')
expect do expect {
post '/wiki', post '/wiki',
params: { title: 'Help:wiki_history_duplicate_title', params: {
body: 'second', message: 'duplicate' }, title: 'Help:wiki_history_duplicate_title',
body: 'second',
message: 'duplicate',
},
headers: auth_headers(user) headers: auth_headers(user)
end.to change(WikiPage, :count).by(0) }
.and change(WikiRevision, :count).by(0) .not_to change(WikiPage, :count)
.and change(WikiVersion, :count).by(0)
expect(response.status).to be_between(400, 499) expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name) expect(WikiPage.joins(:tag_name)
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1) .where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
end end
+96 -19
ファイルの表示
@@ -4,7 +4,6 @@ require 'securerandom'
RSpec.describe 'Wiki API', type: :request do RSpec.describe 'Wiki API', type: :request do
include WikiRecords
def auth_headers(user) def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code } { 'X-Transfer-Code' => user.inheritance_code }
end end
@@ -19,7 +18,7 @@ RSpec.describe 'Wiki API', type: :request do
deprecated_at: Time.zone.local(2026, 6, 1)) deprecated_at: Time.zone.local(2026, 6, 1))
end end
let!(:page) do let!(:page) do
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: tn, tag_name: tn,
body: 'init', body: 'init',
created_by_user: user, created_by_user: user,
@@ -109,14 +108,94 @@ RSpec.describe 'Wiki API', type: :request do
end end
end end
['Help:TestPage', 'ヘルプ:ホーム'].each do |title| context 'when success' do
it "rejects frozen creation of #{ title } without history writes" do it 'creates wiki_page and first content revision' do
expect do expect do
post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member) post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' },
end.to change(WikiPage, :count).by(0) headers: auth_headers(member)
.and change(WikiRevision, :count).by(0) end
.and change(WikiVersion, :count).by(0) .to change(WikiPage, :count).by(1)
expect(response.status).to be_between(400, 499) .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
page_id = json.fetch('id')
expect(json.fetch('title')).to eq('Help:TestPage')
created_page = WikiPage.find(page_id)
version = created_page.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
version_no: 1,
event_type: 'create',
title: 'Help:TestPage',
body: "a\nb\nc",
created_by_user_id: member.id
)
rev = created_page.current_revision
expect(rev).to be_present
expect(rev).to be_content
expect(rev.message).to eq('init')
expect(created_page.body).to eq("a\nb\nc")
expect(rev.lines_count).to eq(3)
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c'])
end
it 'reuses existing WikiLine rows by sha256' do
# 先に同じ行を作っておく
WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current)
post endpoint,
params: { title: 'Help:Reuse', body: "a\na" },
headers: auth_headers(member)
page = WikiPage.find(JSON.parse(response.body).fetch('id'))
rev = page.current_revision
expect(rev.lines_count).to eq(2)
# "a" の WikiLine が増殖しない(1行のはず)
expect(WikiLine.where(body: 'a').count).to eq(1)
end
it 'deduplicates duplicated new lines before upsert' do
duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
post endpoint,
params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
headers: auth_headers(member)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
rev = page.current_revision
expect(rev.lines_count).to eq(2)
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(rev.wiki_revision_lines.count).to eq(2)
expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
end
it 'normalises CRLF and strips trailing newlines' do
post endpoint,
params: { title: 'Help:NormalisedBody',
body: "a\r\nb\r\n\r\n", message: 'normalise' },
headers: auth_headers(member)
expect(response).to have_http_status(:created)
page = WikiPage.find(json.fetch('id'))
rev = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(version.body).to eq("a\nb")
expect(rev.lines_count).to eq(2)
expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
end end
end end
end end
@@ -132,7 +211,7 @@ RSpec.describe 'Wiki API', type: :request do
let!(:test_tag_name) { create(:tag_name, name: 'TestPage') } let!(:test_tag_name) { create(:tag_name, name: 'TestPage') }
let!(:page) do let!(:page) do
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: test_tag_name, tag_name: test_tag_name,
body: "a\nb", body: "a\nb",
created_by_user: member, created_by_user: member,
@@ -170,8 +249,7 @@ RSpec.describe 'Wiki API', type: :request do
expect do expect do
put "/wiki/#{page.id}", put "/wiki/#{page.id}",
params: { title: 'TestPage', body: "x\ny", message: 'edit', params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
base_revision_id: current_id },
headers: auth_headers(member) headers: auth_headers(member)
end end
.to change(WikiRevision, :count).by(1) .to change(WikiRevision, :count).by(1)
@@ -206,7 +284,7 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: member) created_by_user: member)
linked_page = linked_page =
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: linked_tag_name, tag_name: linked_tag_name,
body: 'before', body: 'before',
created_by_user: member, created_by_user: member,
@@ -280,14 +358,13 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do describe 'GET /wiki/search' do
before do before do
search_tag = create(:tag, primary_name: 'spec_wiki_title_2') Wiki::Commit.create_content!(
seed_wiki_page( tag_name: create(:tag_name, name: 'spec_wiki_title_2'),
tag_name: search_tag.tag_name('ja'),
body: 'search body 2', body: 'search body 2',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'unrelated_title'), tag_name: create(:tag_name, name: 'unrelated_title'),
body: 'unrelated body', body: 'unrelated body',
created_by_user: user, created_by_user: user,
@@ -439,7 +516,7 @@ RSpec.describe 'Wiki API', type: :request do
it 'raises because redirect revisions are deprecated' do it 'raises because redirect revisions are deprecated' do
target_tag_name = create(:tag_name, name: 'redirect_deprecated_target') target_tag_name = create(:tag_name, name: 'redirect_deprecated_target')
target = target =
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: target_tag_name, tag_name: target_tag_name,
body: 'target', body: 'target',
created_by_user: user, created_by_user: user,
@@ -462,7 +539,7 @@ RSpec.describe 'Wiki API', type: :request do
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general)
linked_page = linked_page =
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: linked_tag_name, tag_name: linked_tag_name,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+11 -10
ファイルの表示
@@ -1,7 +1,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Wiki title collision', type: :request do RSpec.describe 'Wiki title collision', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
@@ -9,7 +8,7 @@ RSpec.describe 'Wiki title collision', type: :request do
end end
def create_wiki_page title:, body: def create_wiki_page title:, body:
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
@@ -44,18 +43,20 @@ RSpec.describe 'Wiki title collision', type: :request do
expect(source.wiki_versions.count).to eq(source_version_count) expect(source.wiki_versions.count).to eq(source_version_count)
end end
it 'rejects frozen creation with an existing title without adding history' do it 'returns 422 when creating wiki with existing title' do
create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists') create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists')
expect do expect {
post '/wiki', post '/wiki',
params: { title: 'Help:wiki_collision_create', body: 'new body', params: {
message: 'duplicate create' }, title: 'Help:wiki_collision_create',
body: 'new body',
message: 'duplicate create',
},
headers: auth_headers(user) headers: auth_headers(user)
end.to change(WikiPage, :count).by(0) }
.and change(WikiRevision, :count).by(0) .not_to change(WikiPage, :count)
.and change(WikiVersion, :count).by(0)
expect(response.status).to be_between(400, 499) expect(response).to have_http_status(:unprocessable_entity)
end end
end end
+74 -37
ファイルの表示
@@ -1,11 +1,10 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Unmigrated Wiki compatibility', type: :request do RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'Help_contract') } let!(:tag_name) { create(:tag_name, name: 'Help_contract') }
let!(:page) do let!(:page) do
seed_wiki_page(tag_name:, body: 'before', created_by_user: user) Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user)
end end
let!(:first_revision) { page.current_revision } let!(:first_revision) { page.current_revision }
let!(:last_revision) do let!(:last_revision) do
@@ -34,45 +33,83 @@ RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
expect(page.current_revision).to have_attributes(message: 'legacy rename') expect(page.current_revision).to have_attributes(message: 'legacy rename')
end end
include TagGroupRecords it 'shows legacy current and historical content by ID' do
get "/wiki/#{ page.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 "does not recognise legacy #{ lookup } existence" do
path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
without_group_legacy_reads { get "#{ path }/exists" }
expect(response).to have_http_status(:not_found)
end
end
['/wiki', '/wiki/search', '/wiki/changes'].each do |path|
it "excludes unmigrated content from #{ path } without legacy SELECTs" do
without_group_legacy_reads { get path }
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end
it 'returns empty scoped history without legacy SELECTs' do
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to eq([]) expect(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 end
[false, true].each do |explicit| it 'shows legacy current and historical content by title' do
it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do get "/wiki/title/#{ tag_name.name }"
params = { from: first_revision.id } expect(response).to have_http_status(:ok)
params[:to] = last_revision.id if explicit expect(json).to include('body' => 'after', 'revision_id' => last_revision.id)
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: } get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id }
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id)
end
it 'recognises legacy IDs' do
get "/wiki/#{ page.id }/exists"
expect(response).to have_http_status(:no_content)
end
it 'recognises legacy titles' do
get "/wiki/title/#{ tag_name.name }/exists"
expect(response).to have_http_status(:no_content)
end
it 'diffs legacy revisions with explicit and current target' do
[last_revision.id, nil].each do |to|
get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: }
expect(response).to have_http_status(:ok)
expect(json).to include(
'wiki_page_id' => page.id, 'title' => tag_name.name,
'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id,
'diff' => [{ 'type' => 'removed', 'content' => 'before' },
{ 'type' => 'added', 'content' => 'after' }])
end
end
describe 'revision resource scope' do
let!(:other_page) do
Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'Help_other_contract'),
body: 'other page history', created_by_user: user)
end
let(:other_revision) { other_page.current_revision }
it 'returns 404 when show specifies another page revision' do
get "/wiki/#{ page.id }", params: { version: other_revision.id }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'returns 404 when diff from belongs to another page' do
get "/wiki/#{ page.id }/diff",
params: { from: other_revision.id, to: last_revision.id }
expect(response).to have_http_status(:not_found)
end
it 'returns 404 when diff to belongs to another page' do
get "/wiki/#{ page.id }/diff",
params: { from: first_revision.id, to: other_revision.id }
expect(response).to have_http_status(:not_found)
end
end
it 'retains legacy history metadata and ordering' do
get '/wiki/changes', params: { id: page.id }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('revision_id') }).to eq(
[last_revision.id, first_revision.id])
expect(json.first).to include(
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3),
'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil },
'user' => { 'id' => user.id, 'name' => user.name })
end end
end end
+22 -15
ファイルの表示
@@ -2,11 +2,10 @@ require 'digest'
require 'rails_helper' require 'rails_helper'
RSpec.describe Wiki::Commit do RSpec.describe Wiki::Commit do
include WikiRecords
let(:user) { create_member_user! } let(:user) { create_member_user! }
def create_page title:, body: 'initial body' def create_page title:, body: 'initial body'
seed_wiki_page( described_class.create_content!(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
@@ -14,18 +13,26 @@ RSpec.describe Wiki::Commit do
end end
describe '.create_content!' do describe '.create_content!' do
[:create_content!, :create_legacy_content!].each do |operation| it 'creates page, revision, and version with normalised body' do
it "freezes #{ operation } without adding page or history" do expect {
name = create(:tag_name, name: 'Help:frozen_service') described_class.create_content!(
counts = [WikiPage.count, WikiRevision.count, WikiVersion.count] tag_name: create(:tag_name, name: 'commit_integrity_create'),
begin body: "a\r\nb\r\n\r\n",
described_class.public_send(operation, tag_name: name, body: 'initial', created_by_user: user,
created_by_user: user) message: 'init')
rescue ActiveRecord::RecordInvalid }
# A validation rejection is allowed; persistence is not. .to change(WikiPage, :count).by(1)
end .and change(WikiRevision, :count).by(1)
expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts) .and change(WikiVersion, :count).by(1)
end
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' })
revision = page.current_revision
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(revision.lines_count).to eq(2)
expect(version.body).to eq("a\nb")
expect(version.reason).to eq('init')
end end
it 'rejects body that becomes blank after normalisation' do it 'rejects body that becomes blank after normalisation' do
@@ -73,7 +80,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = create(:tag, primary_tag_name: tag_name, category: :general)
page = page =
seed_wiki_page( described_class.create_content!(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+3 -4
ファイルの表示
@@ -1,13 +1,12 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Wiki::Commit do RSpec.describe Wiki::Commit do
include WikiRecords
let(:user) { create_member_user! } let(:user) { create_member_user! }
def create_page(title: 'commit_spec_page', body: 'initial body') def create_page(title: 'commit_spec_page', body: 'initial body')
tag_name = create(:tag_name, name: title) tag_name = create(:tag_name, name: title)
seed_wiki_page( Wiki::Commit.create_content!(
tag_name:, tag_name:,
body:, body:,
created_by_user: user, created_by_user: user,
@@ -85,7 +84,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = create(:tag, primary_tag_name: tag_name, category: :general)
page = page =
seed_wiki_page( described_class.create_content!(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
@@ -112,7 +111,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = create(:tag, primary_tag_name: tag_name, category: :general)
page = page =
seed_wiki_page( described_class.create_content!(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+1 -2
ファイルの表示
@@ -1,11 +1,10 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe WikiVersionRecorder do RSpec.describe WikiVersionRecorder do
include WikiRecords
let(:user) { create_member_user! } let(:user) { create_member_user! }
def create_page title:, body: 'body' def create_page title:, body: 'body'
seed_wiki_page( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
-20
ファイルの表示
@@ -1,20 +0,0 @@
# Historical records must remain seedable after application creation is frozen.
module WikiRecords
def seed_wiki_page(tag_name:, body:, created_by_user:, message: nil)
page = WikiPage.create!(tag_name:, body:,
created_user: created_by_user, updated_user: created_by_user)
revision = Wiki::Commit.legacy_content!(
page:, body:, created_user: created_by_user, message:)
if tag_name.tag && tag_name.language_code == 'ja'
TagDescription.create!(
id: page.id, tag: tag_name.tag, language_code: 'ja', body: page.reload.body,
version_no: 1, created_by_user_id: created_by_user.id,
updated_by_user_id: created_by_user.id)
TagDescriptionVersion.create!(
id: revision.id, tag_id: tag_name.tag_id, language_code: 'ja',
version_no: 1, event_type: 'create', body: page.body,
created_by_user_id: created_by_user.id, created_at: revision.created_at)
end
page
end
end
-2
ファイルの表示
@@ -22,5 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/help
生成ファイル
+43 -4
ファイルの表示
@@ -38,7 +38,6 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1", "unist-util-visit-parents": "^6.0.1",
"yaml": "^2.9.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
@@ -2061,6 +2060,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2075,6 +2077,9 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2089,6 +2094,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2103,6 +2111,9 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2117,6 +2128,9 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2131,6 +2145,9 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2145,6 +2162,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2159,6 +2179,9 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2173,6 +2196,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2187,6 +2213,9 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2201,6 +2230,9 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2215,6 +2247,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2229,6 +2264,9 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -9076,9 +9114,10 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.9.1", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
-1
ファイルの表示
@@ -42,7 +42,6 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1", "unist-util-visit-parents": "^6.0.1",
"yaml": "^2.9.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
+80 -52
ファイルの表示
@@ -15,6 +15,7 @@ import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { applyClientAnimationMode, import { applyClientAnimationMode,
applyClientAppearance, applyClientAppearance,
type ClientAnimationMode,
fetchUserThemeSlots, fetchUserThemeSlots,
fetchUserSettings, fetchUserSettings,
getClientThemeMode, getClientThemeMode,
@@ -24,6 +25,7 @@ import { applyClientAnimationMode,
seedClientThemeMode } from '@/lib/settings' seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
@@ -32,13 +34,12 @@ import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage' import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage' import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage' import GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx' import TOSPage from '@/pages/TOSPage.mdx'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import HelpListPage from '@/pages/help/HelpListPage'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
@@ -59,60 +60,90 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import type { Dispatch, FC, SetStateAction } from 'react' import type { Dispatch, FC, SetStateAction } from 'react'
import type { ClientAnimationMode } from '@/lib/settings'
import type { User } from '@/types' import type { User } from '@/types'
const RouteTransitionWrapper = ({ animationMode, user, setUser }: { const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode animationMode: ClientAnimationMode
user: User | null user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => { setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation () const location = useLocation ()
const routes = (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/help" element={<HelpListPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
if (animationMode === 'off') if (animationMode === 'off')
return routes {
return (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
}
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{routes} <Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
</AnimatePresence>) </AnimatePresence>)
} }
@@ -135,9 +166,9 @@ const RoutedApp: FC = () => {
() => ( () => (
animationMode === 'off' animationMode === 'off'
? { duration: 0 } ? { duration: 0 }
: (animationMode === 'reduced' : animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const } ? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }) : { duration: .2, ease: 'easeOut' as const }
), ),
[animationMode], [animationMode],
) )
@@ -254,12 +285,10 @@ const RoutedApp: FC = () => {
</DialogueProvider>) </DialogueProvider>)
} }
const router = createBrowserRouter ([{ const router = createBrowserRouter ([{
path: '*', path: '*',
element: <RoutedApp/> }]) element: <RoutedApp/> }])
const App: FC = () => ( const App: FC = () => (
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
@@ -267,5 +296,4 @@ const App: FC = () => (
<RouterProvider router={router}/> <RouterProvider router={router}/>
</>) </>)
export default App export default App
-3
ファイルの表示
@@ -82,9 +82,6 @@ export const menuOutline = (
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] }, visible: wikiPageFlg && editable }] },
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
{ name: '検索', to: '/help' },
{ name: '全体履歴', to: '/help/changes' }] },
{ name: 'おたのしみ', visible: false, subMenu: [ { name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' }, { name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] }, { name: 'グカネータ (β)', to: '/gekanator' }] },
-56
ファイルの表示
@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createHelpFixture } from '@/test/helpFixtures'
describe ('Help file catalogue', () => {
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
const { api } = createHelpFixture ()
expect (api.getHelpArticle ('guide')?.languages.ja.currentEdition).toBe ('2026-10-02')
expect (await api.getHelpSearchEntries ('ja')).toEqual ([
{ identifier: 'guide', edition: '2026-10-02',
title: 'ホーム', body: '\nタグを検索する' },
{ identifier: '設定 手順', edition: '2026-10-03',
title: '設定', body: '\nタグを設定する' }])
})
it ('orders same-day editions numerically and separates languages', () => {
const { api } = createHelpFixture ()
expect (api.getHelpEditions ('guide', 'ja')).toEqual ([
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
expect (api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
})
it ('derives each edition title from its first H1', async () => {
const { api, raw } = createHelpFixture ()
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
.toEqual ({ title: '旧ホーム', body: '\n過去限定本文' })
raw['../../help/guide/editions/ja/2026-10-02.mdx']
.mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-02'))
.toEqual ({ title: '改題', body: '\r\n本文\r\n# 別見出し' })
})
it ('returns missing resources without substituting another edition', async () => {
const { api } = createHelpFixture ()
expect (api.getHelpArticle ('absent')).toBeNull ()
expect (api.getHelpEditions ('absent', 'ja')).toEqual ([])
expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull ()
expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull ()
})
it ('rejects a missing current file instead of silently hiding an article', async () => {
const { api, raw } = createHelpFixture ()
delete raw['../../help/guide/editions/ja/2026-10-02.mdx']
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ()
})
it ('reports raw loading failures and missing leading H1', async () => {
const { api, raw } = createHelpFixture ()
const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx']
loader.mockRejectedValueOnce (new Error ('fixture failure'))
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('fixture failure')
loader.mockResolvedValueOnce ('本文\n# 遅い見出し')
await expect (api.getHelpEditionText ('guide', 'ja', '2026-10-02')).rejects.toThrow ()
})
})
-132
ファイルの表示
@@ -1,132 +0,0 @@
import { parse as parseYAML } from 'yaml'
import type { ComponentType } from 'react'
type ArticleLanguage = { currentEdition: string }
type ArticleMeta = {
identifier: string
languages: Record<string, ArticleLanguage> }
type MDXModule = { default: ComponentType }
export type HelpEditionText = {
title: string
body: string }
export type HelpSearchEntry = HelpEditionText & {
identifier: string
edition: string }
const articleFiles = import.meta.glob<string> ('../../help/*/article.yml',
{ query: '?raw',
import: 'default',
eager: true })
const editionFiles = import.meta.glob<MDXModule> ('../../help/*/editions/*/*.mdx')
const rawEditionFiles = import.meta.glob<string> ('../../help/*/editions/*/*.mdx',
{ query: '?raw',
import: 'default' })
const cameliseArticle = (
value: { identifier: string
languages: Record<string, { ['current_edition']: string }> },
): ArticleMeta => (
{ identifier: value.identifier,
languages: Object.fromEntries (
Object.entries (value.languages).map (([language, data]) => [
language, { currentEdition: data['current_edition'] }])) })
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)
}
export const getHelpArticle = (identifier: string): ArticleMeta | null => {
const path = `../../help/${ identifier }/article.yml`
const source = articleFiles[path]
if (!(source))
return null
return cameliseArticle (parseYAML (source))
}
export const getHelpEditions = (
identifier: string,
language: string,
): string[] => {
const prefix = `../../help/${ identifier }/editions/${ language }/`
return (Object.keys (editionFiles)
.filter (path => path.startsWith (prefix))
.map (path => path.slice (prefix.length, -'.mdx'.length))
.sort (compareEditions))
}
export const getHelpEdition = async (
identifier: string,
language: string,
edition: string,
): Promise<ComponentType | null> => {
const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx`
const loader = editionFiles[path]
if (!(loader))
return null
return (await loader ()).default
}
export const getHelpEditionText = async (
identifier: string,
language: string,
edition: string,
): Promise<HelpEditionText | null> => {
const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx`
const loader = rawEditionFiles[path]
if (!(loader))
return null
const mdx = (await loader ()).replace (/^\uFEFF/, '')
const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/)
if (!(heading))
throw new Error (`Help MDX の先頭に H1 がありません: ${ path }`)
return { title: heading[1].trim (),
body: mdx.slice (heading[0].length) }
}
export const getHelpSearchEntries = async (
language: string,
): Promise<HelpSearchEntry[]> => {
const entries = await Promise.all (
Object.values (articleFiles).map (async source => {
const article = cameliseArticle (parseYAML (source))
const edition = article.languages[language]?.currentEdition
if (!(edition))
return null
const content = await getHelpEditionText (article.identifier, language, edition)
if (!(content))
throw new Error (`Help edition が見つかりません: ${ article.identifier }/${ edition }`)
return { identifier: article.identifier, edition, ...content }
}))
return entries.filter ((entry): entry is HelpSearchEntry => entry != null)
}
-94
ファイルの表示
@@ -1,94 +0,0 @@
import { QueryClient } from '@tanstack/react-query'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PrefetchLink from '@/components/PrefetchLink'
import * as prefetchers from '@/lib/prefetchers'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import { 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 (() => {
settings.linkPreload = 'intent'
vi.clearAllMocks ()
})
it.each (['/help', '/help/guide', '/help/changes',
'/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 client = new QueryClient ()
const loader = helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx']
await Promise.all ([
prefetchers.prefetchForURL (client, '/help/guide'),
prefetchers.prefetchForURL (client, '/help/guide')])
expect (loader).toHaveBeenCalledTimes (1)
renderWithProviders (
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
{ route: '/help/guide' })
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.raw['../../help/guide/editions/ja/2026-10-01.mdx'])
.toHaveBeenCalled ()
})
it.each (['intent', 'click', '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'))
})
})
+17 -24
ファイルの表示
@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki'
const api = vi.hoisted (() => ({ apiGet: vi.fn () })) const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
@@ -19,37 +21,28 @@ describe ('wiki API functions', () => {
await fetchWikiPage ('1', { version: '3' }) await fetchWikiPage ('1', { version: '3' })
expect (api.apiGet).toHaveBeenNthCalledWith ( expect (api.apiGet).toHaveBeenNthCalledWith (
1, 1,
'/wiki', '/wiki',
{ params: { title: '虹' } }) { params: { title: '虹' } },
)
expect (api.apiGet).toHaveBeenNthCalledWith ( expect (api.apiGet).toHaveBeenNthCalledWith (
2, 2,
'/wiki/1', '/wiki/1',
{ params: { version: '3' } }) { params: { version: '3' } },
)
}) })
it ('encodes title path segments and returns null on misses', async () => { it ('encodes title path segments and returns null on misses', async () => {
api.apiGet.mockResolvedValueOnce ({ id: 2 }) api.apiGet.mockResolvedValueOnce ({ id: 2 })
api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } }) api.apiGet.mockRejectedValueOnce (new Error ('missing'))
await fetchWikiPageByTitle ('a/b c', { version: undefined }) await fetchWikiPageByTitle ('a/b c', { version: undefined })
await expect (fetchWikiPageByTitle ('missing', { })).resolves.toBeNull () await expect (fetchWikiPageByTitle ('missing', {})).resolves.toBeNull ()
expect (api.apiGet).toHaveBeenNthCalledWith ( expect (api.apiGet).toHaveBeenNthCalledWith (
1, 1,
'/wiki/title/a%2Fb%20c', '/wiki/title/a%2Fb%20c',
{ params: { version: undefined } }) { params: { version: undefined } },
}) )
it.each ([403, 500]) ('preserves HTTP %s errors', async status => {
const error = { isAxiosError: true, response: { status } }
api.apiGet.mockRejectedValueOnce (error)
await expect (fetchWikiPageByTitle ('ホーム', { })).rejects.toBe (error)
})
it ('preserves network failures for callers', async () => {
const error = new Error ('network failure')
api.apiGet.mockRejectedValueOnce (error)
await expect (fetchWikiPageByTitle ('ホーム', { })).rejects.toBe (error)
}) })
}) })
-77
ファイルの表示
@@ -1,77 +0,0 @@
import { fireEvent, screen, within } from '@testing-library/react'
import { afterAll, 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', () => {
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)
vi.spyOn (helpFixture.api, 'getHelpEdition').mockResolvedValue (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 ('contains wide tables in a horizontal scroll area', async () => {
const Content = await compileMdx (tableSource.replace ('通常', 'W'.repeat (200)))
vi.spyOn (helpFixture.api, 'getHelpEdition').mockResolvedValue (Content)
renderWithProviders (
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
{ route: '/help/guide' })
const table = await screen.findByRole ('table')
expect (table.closest ('.overflow-x-auto, .overflow-x-scroll')).not.toBeNull ()
})
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)
})
})
-41
ファイルの表示
@@ -1,41 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { describe, expect, it, vi } from 'vitest'
import { apiGet } from '@/lib/api'
import App from '@/App'
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', () => {
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 (apiGet).not.toHaveBeenCalled ()
expect (window.location.pathname).toBe ('/help/changes')
})
})
-108
ファイルの表示
@@ -1,108 +0,0 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import 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', () => {
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']) (
'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
})
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx']
.mockReturnValueOnce (pending)
renderDetail ()
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 ('ホーム第三版 ヘルプ'))
})
})
-126
ファイルの表示
@@ -1,126 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
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, getHelpEdition, getHelpEditionText, getHelpEditions } from '@/lib/help'
import NotFound from '@/pages/NotFound'
import type { FC, ComponentType } from 'react'
const HelpDetailPage: FC = () => {
const { identifier = '' } = useParams ()
const location = useLocation ()
// Locale 対応が入るまでは ja 固定
const language = 'ja'
const article = useMemo (
() => getHelpArticle (identifier),
[identifier])
const query = new URLSearchParams (location.search)
const requestedEdition = query.get ('edition')
const languageMeta = article?.languages[language]
const edition = requestedEdition ?? languageMeta?.currentEdition
const editions = useMemo (() => getHelpEditions (identifier, language),
[identifier, language])
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 [loaded, setLoaded] = useState<{ identifier: string
language: string
edition: string
title: string
content: ComponentType | null } | null> (null)
useEffect (() => {
if (!(edition))
return
let active = true
void (async () => {
const component = await getHelpEdition (identifier, language, edition)
const text = await getHelpEditionText (identifier, language, edition)
if (active)
{
setLoaded ({ identifier,
language,
edition,
title: text?.title ?? '',
content: component })
}
}) ()
return () => {
active = false
}
}, [identifier, language, edition])
if (!(article)
|| !(languageMeta)
|| !(edition)
|| !(editions.includes (edition)))
return <NotFound/>
const current =
(loaded?.identifier === identifier
&& loaded?.language === language
&& loaded?.edition === edition)
? loaded
: null
if (current && (!(current.content) || !(current.title)))
return <NotFound/>
const Content = current?.content
return (
<MainArea>
<Helmet>
<title>{current?.title || identifier} ヘルプ | {SITE_TITLE}</title>
</Helmet>
{requestedEdition && (
<div className="text-sm flex gap-3 items-center justify-center
border border-gray-700 rounded px-2 py-1 mb-4">
{pred
? (
<PrefetchLink
to={`/help/${ encodeURIComponent (identifier) }?edition=${ pred }`}>
&lt; 古
</PrefetchLink>)
: '(最古)'}
<span>{edition}</span>
{succ
? (
<PrefetchLink
to={`/help/${ encodeURIComponent (identifier) }?edition=${ succ }`}>
新 &gt;
</PrefetchLink>)
: '(最新)'}
</div>)}
<article className="prose dark:prose-invert mx-auto p-4">
{Content ? <Content/> : <div>Loading...</div>}
</article>
</MainArea>)
}
export default HelpDetailPage
-52
ファイルの表示
@@ -1,52 +0,0 @@
import { fireEvent, screen } from '@testing-library/react'
import { 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', () => {
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.raw['../../help/guide/editions/ja/2026-10-02.mdx']
.mockRejectedValueOnce (new Error ('fixture loading failed'))
renderWithProviders (<HelpListPage/>)
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
expect (screen.queryByRole ('link', { name: 'ホーム' })).not.toBeInTheDocument ()
})
})
-143
ファイルの表示
@@ -1,143 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { getHelpSearchEntries } from '@/lib/help'
import { dateString, inputClass } from '@/lib/utils'
import type { FormEvent, FC } from 'react'
import type { HelpSearchEntry } from '@/lib/help'
type SearchConditions = { title: string; text: string }
const HelpSearchPage: FC = () => {
const [title, setTitle] = useState ('')
const [text, setText] = useState ('')
const [conditions, setConditions] = useState<SearchConditions> ({ title: '', text: '' })
const [entries, setEntries] = useState<HelpSearchEntry[]> ([])
const [loading, setLoading] = useState (true)
const [error, setError] = useState<string | null> (null)
useEffect (() => {
let active = true
void (async () => {
try
{
if (active)
setEntries (await getHelpSearchEntries ('ja'))
}
catch (err)
{
if (active)
setError (err instanceof Error ? err.message : '取得に失敗しました.')
}
finally
{
if (active)
setLoading (false)
}
}) ()
return () => { active = false }
}, [])
const results = useMemo (() => {
const titleQuery = conditions.title.trim ().normalize ('NFKC').toLowerCase ()
const textQuery = conditions.text.trim ().normalize ('NFKC').toLowerCase ()
return (
entries.filter (entry => (
(!(titleQuery)
|| entry.title.normalize ('NFKC').toLowerCase ().includes (titleQuery))
&& (!(textQuery)
|| entry.body.normalize ('NFKC').toLowerCase ().includes (textQuery))))
.sort ((a, b) => a.title.localeCompare (b.title, 'ja')))
}, [entries, conditions])
const handleSearch = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
setConditions ({ title, text })
}
return (
<MainArea>
<Helmet>
<title>{`ヘルプ | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="max-w-xl">
<PageTitle>ヘルプ</PageTitle>
<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}</p>
: (
<div className="mt-4">
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
<table className="table-auto w-full 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>
</tr>
</thead>
<tbody>
{results.map (entry => {
const updatedAt = new Date(entry.edition.replace (/\..*$/, ''))
return (
<tr key={entry.identifier}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<PrefetchLink
to={`/help/${encodeURIComponent (entry.identifier)}`}>
{entry.title}
</PrefetchLink>
</td>
<td className="p-2">{dateString (updatedAt, 'hour')}</td>
</tr>)
})}
</tbody>
</table>
{results.length === 0 && <p className="mt-4">該当する記事がありません.</p>}
</div>))}
</MainArea>)
}
export default HelpSearchPage
-91
ファイルの表示
@@ -1,91 +0,0 @@
import { screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiGet } from '@/lib/api'
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 ()
return <output data-testid="location">{location.pathname}{location.search}</output>
}
const renderWiki = (title: string) => {
const route = `/wiki/${encodeURIComponent (title)}`
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
}
describe ('Legacy Wiki to Help compatibility', () => {
beforeEach (() => {
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 ()
})
it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム']) (
'redirects a unique current or historical Japanese title: %s', async title => {
renderWiki (title)
await waitFor (() => {
expect (screen.getByTestId ('location')).toHaveTextContent ('/help/guide')
})
expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument ()
expect (apiGet).toHaveBeenCalledWith (
`/wiki/title/${encodeURIComponent (title)}`, expect.anything ())
})
it ('keeps the original URL and displays NotFound when no title matches', async () => {
const route = renderWiki ('該当なし')
await screen.findByText ('404')
expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument ()
})
it ('does not choose between articles with the same title', async () => {
const loader = helpFixture.raw['../../help/設定 手順/editions/ja/2026-10-03.mdx']
loader.mockResolvedValueOnce ('# ホーム\n\n別の記事')
const route = renderWiki ('ホーム')
await screen.findByText ('404')
expect (screen.getByTestId ('location')).toHaveTextContent (route)
})
it.each ([403, 500]) ('does not redirect HTTP %s to Help', async status => {
vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } })
const route = renderWiki ('ホーム')
await waitFor (() => expect (apiGet).toHaveBeenCalled ())
expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
})
})
-37
ファイルの表示
@@ -1,37 +0,0 @@
import * as jsxRuntime from 'react/jsx-runtime'
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
import { useMDXComponents } from '@/mdx-components'
import viteConfig from '../../vite.config'
import type { ComponentType } from 'react'
type Transform = (source: string, id: string) => Promise<{ code: string } | null>
export const transformMdx = async (source: string, suffix = '.mdx') => {
const plugins = viteConfig.plugins?.flat () as { name: string; transform: Transform }[]
const plugin = plugins.find (entry => entry.name === '@mdx-js/rollup')
if (plugin == null)
throw new Error ('The application MDX compiler is not registered')
return plugin.transform.call (plugin, source, `/test/help-table${suffix}`)
}
export const compileMdx = async (source: string): Promise<ComponentType> => {
const result = await transformMdx (source)
if (result == null)
throw new Error ('MDX was not compiled')
const code = transpileModule (result.code, {
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
const exports = { } as { default: ComponentType }
const requireFixture = (name: string) => {
if (name === 'react/jsx-runtime')
return jsxRuntime
if (name === '@/mdx-components')
return { useMDXComponents }
throw new Error (`Unexpected MDX dependency: ${name}`)
}
new Function ('require', 'exports', code) (requireFixture, exports)
return exports.default
}
-60
ファイルの表示
@@ -1,60 +0,0 @@
import { createElement } from 'react'
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
import { vi } from 'vitest'
import { parse } from 'yaml'
import source from '@/lib/help.ts?raw'
import type { ReactElement } 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', 'en', '2026-10-02', 'Home', 'English body'],
['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const
const compiled = transpileModule (source.replaceAll ('import.meta', 'helpMeta'), {
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
// Replace Vite's file-discovery boundary, executing the actual Help module.
export const createHelpFixture = () => {
const articles: Record<string, string> = {
'../../help/guide/article.yml':
'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02\n'
+ ' en:\n current_edition: 2026-10-02\n',
'../../help/設定 手順/article.yml':
'identifier: 設定 手順\nlanguages:\n ja:\n current_edition: 2026-10-03\n' }
const raw = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
`../../help/${id}/editions/${language}/${edition}.mdx`,
vi.fn (async () => `# ${title}\n\n${body}`)]))
const modules = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
`../../help/${id}/editions/${language}/${edition}.mdx`,
vi.fn (async (): Promise<{ default: () => ReactElement }> => ({
default: () => createElement ('div', null,
createElement ('h1', null, title), createElement ('p', null, body)) }))]))
const glob = (pattern: string, options?: { query?: string }) => {
if (pattern.endsWith ('article.yml'))
return articles
if (pattern.endsWith ('*.mdx'))
return options?.query === '?raw' ? raw : modules
throw new Error (`Unexpected Help glob: ${pattern}`)
}
const api = { } as typeof Help
const requireFixture = (name: string) => {
if (name === 'yaml')
return { parse }
throw new Error (`Unexpected Help dependency: ${name}`)
}
new Function ('require', 'exports', 'helpMeta', compiled) (
requireFixture, api, { glob })
return { api, articles, raw, modules }
}
export const helpFixture = createHelpFixture ()
+2 -16
ファイルの表示
@@ -1,26 +1,12 @@
import mdx from '@mdx-js/rollup' import mdx from '@mdx-js/rollup'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
import remarkGFM from 'remark-gfm'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components',
remarkPlugins: [remarkGFM] })
const originalTransform = mdxPlugin.transform
if (typeof originalTransform === 'function')
{
mdxPlugin.transform =
function (code, id)
{
return (/[?&](raw|url)(?:&|$)/.test (id)
? null
: originalTransform.call (this, code, id))
}
}
// https://vite.dev/config/
export default defineConfig ({ export default defineConfig ({
plugins: [mdxPlugin, react ()], plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()],
resolve: { alias: { '@': path.resolve (__dirname, './src') } }, resolve: { alias: { '@': path.resolve (__dirname, './src') } },
server: { host: true, server: { host: true,
port: 5173, port: 5173,