コミットを比較
4 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 130d8b256a | |||
| eeefb09d0c | |||
| d2e69b72da | |||
| b8126eeea9 |
@@ -7,13 +7,24 @@ class GekanatorPostsController < ApplicationController
|
|||||||
.order(Arel.sql(
|
.order(Arel.sql(
|
||||||
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
|
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
|
||||||
'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
|
'posts.original_created_from, posts.created_at) DESC, posts.id DESC'))
|
||||||
|
.to_a
|
||||||
|
|
||||||
render json: { posts: posts.map { |post| post_json(post) } }
|
active_tags_by_post_id =
|
||||||
|
posts.each_with_object({ }) do |post, h|
|
||||||
|
h[post.id] = post.tags.reject(&:deprecated?)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
posts: posts.map { |post|
|
||||||
|
post_json(post,
|
||||||
|
active_tags_by_post_id:)
|
||||||
|
}
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def post_json post
|
def post_json post, active_tags_by_post_id:
|
||||||
{
|
{
|
||||||
id: post.id,
|
id: post.id,
|
||||||
url: post.url,
|
url: post.url,
|
||||||
@@ -22,16 +33,26 @@ class GekanatorPostsController < ApplicationController
|
|||||||
thumbnail_base: post.thumbnail_base,
|
thumbnail_base: post.thumbnail_base,
|
||||||
original_created_from: post.original_created_from,
|
original_created_from: post.original_created_from,
|
||||||
original_created_before: post.original_created_before,
|
original_created_before: post.original_created_before,
|
||||||
post_similarity_edges: post.post_similarities.map { |similarity|
|
post_similarity_edges: post_similarity_edges_json(
|
||||||
{
|
post,
|
||||||
target_post_id: similarity.target_post_id,
|
active_tags_by_post_id:),
|
||||||
cos: similarity.cos.to_f
|
tags: active_tags_by_post_id.fetch(post.id, []).map { |tag| tag_json(tag) }
|
||||||
}
|
|
||||||
},
|
|
||||||
tags: post.tags.map { |tag| tag_json(tag) }
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def post_similarity_edges_json post, active_tags_by_post_id:
|
||||||
|
post
|
||||||
|
.post_similarities
|
||||||
|
.filter_map do |similarity|
|
||||||
|
next unless active_tags_by_post_id.key?(similarity.target_post_id)
|
||||||
|
|
||||||
|
{
|
||||||
|
target_post_id: similarity.target_post_id,
|
||||||
|
cos: similarity.cos.to_f
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def tag_json tag
|
def tag_json tag
|
||||||
{
|
{
|
||||||
id: tag.id,
|
id: tag.id,
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
.accepted
|
.accepted
|
||||||
.includes(:gekanator_question_examples)
|
.includes(:gekanator_question_examples)
|
||||||
.order(priority_weight: :desc, id: :asc)
|
.order(priority_weight: :desc, id: :asc)
|
||||||
|
.to_a
|
||||||
|
deprecated_tag_keys = deprecated_tag_keys_for(questions)
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
questions: questions.map { |question| question_json(question) }
|
questions: questions.filter_map { |question|
|
||||||
|
json = question_json(question)
|
||||||
|
next if hidden_question?(json[:condition], deprecated_tag_keys)
|
||||||
|
|
||||||
|
json
|
||||||
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -100,4 +107,41 @@ class GekanatorQuestionsController < ApplicationController
|
|||||||
.first
|
.first
|
||||||
&.first
|
&.first
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def deprecated_tag_keys_for questions
|
||||||
|
tag_keys = questions.filter_map { |question|
|
||||||
|
condition = condition_json(question.condition)
|
||||||
|
next unless condition['type'] == 'tag'
|
||||||
|
|
||||||
|
condition['key'].to_s.presence
|
||||||
|
}.uniq
|
||||||
|
return {} if tag_keys.empty?
|
||||||
|
|
||||||
|
categories = []
|
||||||
|
names = []
|
||||||
|
tag_keys.each do |key|
|
||||||
|
category, name = parse_tag_key(key)
|
||||||
|
categories << category
|
||||||
|
names << name
|
||||||
|
end
|
||||||
|
|
||||||
|
Tag
|
||||||
|
.joins(:tag_name)
|
||||||
|
.where(category: categories.uniq)
|
||||||
|
.where(tag_names: { name: names.uniq })
|
||||||
|
.where.not(deprecated_at: nil)
|
||||||
|
.pluck('tags.category', 'tag_names.name')
|
||||||
|
.each_with_object({ }) do |(category, name), h|
|
||||||
|
h["#{ category }:#{ name }"] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def hidden_question? condition, deprecated_tag_keys
|
||||||
|
condition[:type] == 'tag' && deprecated_tag_keys[condition[:key].to_s]
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_tag_key key
|
||||||
|
parts = key.to_s.split(':')
|
||||||
|
[parts.first.to_s, parts.drop(1).join(':')]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,17 +4,18 @@ class WikiPagesController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
if title.blank?
|
if title.blank?
|
||||||
return render json: WikiPageRepr.base(WikiPage.joins(:tag_name).includes(:tag_name))
|
return render json: WikiPageRepr.base(
|
||||||
|
WikiPage.joins(:tag_name).includes(tag_name: :tag))
|
||||||
end
|
end
|
||||||
|
|
||||||
q = WikiPage.joins(:tag_name).includes(:tag_name)
|
q = WikiPage.joins(:tag_name).includes(tag_name: :tag)
|
||||||
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
|
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
|
||||||
render json: WikiPageRepr.base(q.limit(20))
|
render json: WikiPageRepr.base(q.limit(20))
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
page = WikiPage.joins(:tag_name)
|
page = WikiPage.joins(:tag_name)
|
||||||
.includes(:tag_name)
|
.includes(tag_name: :tag)
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
render_wiki_page_or_404 page
|
render_wiki_page_or_404 page
|
||||||
end
|
end
|
||||||
@@ -22,7 +23,7 @@ class WikiPagesController < ApplicationController
|
|||||||
def show_by_title
|
def show_by_title
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
page = WikiPage.joins(:tag_name)
|
page = WikiPage.joins(:tag_name)
|
||||||
.includes(:tag_name)
|
.includes(tag_name: :tag)
|
||||||
.find_by(tag_name: { name: title })
|
.find_by(tag_name: { name: title })
|
||||||
render_wiki_page_or_404 page
|
render_wiki_page_or_404 page
|
||||||
end
|
end
|
||||||
@@ -51,7 +52,7 @@ class WikiPagesController < ApplicationController
|
|||||||
from = params[:from].presence
|
from = params[:from].presence
|
||||||
to = params[:to].presence
|
to = params[:to].presence
|
||||||
|
|
||||||
page = WikiPage.joins(:tag_name).includes(:tag_name).find(id)
|
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
||||||
|
|
||||||
from_rev = from && page.wiki_revisions.find(from)
|
from_rev = from && page.wiki_revisions.find(from)
|
||||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||||
@@ -76,6 +77,7 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
render json: { wiki_page_id: page.id,
|
render json: { wiki_page_id: page.id,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
|
deprecated_at: page.deprecated_at,
|
||||||
older_revision_id: from_rev&.id,
|
older_revision_id: from_rev&.id,
|
||||||
newer_revision_id: to_rev.id,
|
newer_revision_id: to_rev.id,
|
||||||
diff: diff_json }
|
diff: diff_json }
|
||||||
@@ -157,7 +159,7 @@ class WikiPagesController < ApplicationController
|
|||||||
def changes
|
def changes
|
||||||
id = params[:id].presence
|
id = params[:id].presence
|
||||||
q = WikiRevision.joins(wiki_page: :tag_name)
|
q = WikiRevision.joins(wiki_page: :tag_name)
|
||||||
.includes(:created_user, wiki_page: :tag_name)
|
.includes(:created_user, wiki_page: { tag_name: :tag })
|
||||||
.order(id: :desc)
|
.order(id: :desc)
|
||||||
q = q.where(wiki_page_id: id) if id
|
q = q.where(wiki_page_id: id) if id
|
||||||
|
|
||||||
@@ -165,7 +167,9 @@ class WikiPagesController < ApplicationController
|
|||||||
{ revision_id: rev.id,
|
{ revision_id: rev.id,
|
||||||
pred: rev.base_revision_id,
|
pred: rev.base_revision_id,
|
||||||
succ: nil,
|
succ: nil,
|
||||||
wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title },
|
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 },
|
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
||||||
kind: rev.kind,
|
kind: rev.kind,
|
||||||
message: rev.message,
|
message: rev.message,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class Post < ApplicationRecord
|
|||||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
|
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
|
||||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||||
has_many :tags, through: :active_post_tags
|
has_many :tags, through: :active_post_tags
|
||||||
|
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
|
||||||
|
through: :active_post_tags, source: :tag
|
||||||
|
|
||||||
has_many :user_post_views, dependent: :delete_all
|
has_many :user_post_views, dependent: :delete_all
|
||||||
has_many :post_similarities, dependent: :delete_all
|
has_many :post_similarities, dependent: :delete_all
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class WikiPage < ApplicationRecord
|
|||||||
validates :body, presence: true
|
validates :body, presence: true
|
||||||
|
|
||||||
def title = tag_name.name
|
def title = tag_name.name
|
||||||
|
def deprecated_at = tag_name.tag&.deprecated_at
|
||||||
|
|
||||||
def title= val
|
def title= val
|
||||||
(self.tag_name ||= build_tag_name).name = val
|
(self.tag_name ||= build_tag_name).name = val
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
module WikiPageRepr
|
module WikiPageRepr
|
||||||
BASE = { methods: [:title] }.freeze
|
BASE = { methods: [:title, :deprecated_at] }.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module Similarity
|
module Similarity
|
||||||
class Calc
|
class Calc
|
||||||
def self.call model, tgt
|
def self.call model, tgt, scope: nil
|
||||||
similarity_model = "#{ model.name }Similarity".constantize
|
similarity_model = "#{ model.name }Similarity".constantize
|
||||||
|
|
||||||
# 最大保存件数
|
# 最大保存件数
|
||||||
@@ -8,7 +8,8 @@ module Similarity
|
|||||||
|
|
||||||
similarity_model.delete_all
|
similarity_model.delete_all
|
||||||
|
|
||||||
posts = model.includes(tgt).select(:id).to_a
|
scope ||= model.all
|
||||||
|
posts = scope.includes(tgt).select(:id).to_a
|
||||||
|
|
||||||
tag_ids = { }
|
tag_ids = { }
|
||||||
tag_cnts = { }
|
tag_cnts = { }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace :post_similarity do
|
namespace :post_similarity do
|
||||||
desc '関聯投稿テーブル作成'
|
desc '関聯投稿テーブル作成'
|
||||||
task calc: :environment do
|
task calc: :environment do
|
||||||
Similarity::Calc.call(Post, :tags)
|
Similarity::Calc.call(Post, :active_tags)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace :tag_similarity do
|
namespace :tag_similarity do
|
||||||
desc '関聯タグ・テーブル作成'
|
desc '関聯タグ・テーブル作成'
|
||||||
task calc: :environment do
|
task calc: :environment do
|
||||||
Similarity::Calc.call(Tag, :posts)
|
Similarity::Calc.call(Tag, :posts, scope: Tag.where(deprecated_at: nil))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,79 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Tag, type: :model do
|
RSpec.describe Tag, type: :model do
|
||||||
|
describe '.normalise_tags!' do
|
||||||
|
it 'rejects deprecated tags when deny_deprecated is enabled' do
|
||||||
|
tag_name = TagName.create!(name: 'normalise deprecated tag')
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
tag_name:,
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: 1.day.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.normalise_tags!(
|
||||||
|
[deprecated_tag.name],
|
||||||
|
deny_deprecated: true
|
||||||
|
)
|
||||||
|
}.to raise_error(Tag::DeprecatedTagNormalisationError) { |error|
|
||||||
|
expect(error.tag_names).to eq([deprecated_tag.name])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.expand_parent_tags' do
|
||||||
|
it 'expands through multiple deprecated parents to an active ancestor' do
|
||||||
|
child = create(:tag, name: 'expand_child')
|
||||||
|
deprecated_parent = create(
|
||||||
|
:tag,
|
||||||
|
name: 'expand_deprecated_parent',
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
deprecated_grandparent = create(
|
||||||
|
:tag,
|
||||||
|
name: 'expand_deprecated_grandparent',
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
active_ancestor = create(:tag, name: 'expand_active_ancestor')
|
||||||
|
TagImplication.create!(tag: child, parent_tag: deprecated_parent)
|
||||||
|
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
|
||||||
|
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
|
||||||
|
|
||||||
|
expanded = described_class.expand_parent_tags([child])
|
||||||
|
|
||||||
|
expect(expanded).to include(
|
||||||
|
child,
|
||||||
|
deprecated_parent,
|
||||||
|
deprecated_grandparent,
|
||||||
|
active_ancestor
|
||||||
|
)
|
||||||
|
expect(expanded.reject(&:deprecated?)).to contain_exactly(child, active_ancestor)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'terminates when implications contain a cycle' do
|
||||||
|
first = create(:tag, name: 'expand_cycle_first')
|
||||||
|
second = create(:tag, name: 'expand_cycle_second')
|
||||||
|
TagImplication.create!(tag: first, parent_tag: second)
|
||||||
|
TagImplication.create!(tag: second, parent_tag: first)
|
||||||
|
|
||||||
|
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'deprecated validation' do
|
||||||
|
it 'rejects deprecated nico tags' do
|
||||||
|
tag = build(
|
||||||
|
:tag,
|
||||||
|
name: 'nico:deprecated_validation',
|
||||||
|
category: :nico,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(tag).not_to be_valid
|
||||||
|
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '.merge_tags!' do
|
describe '.merge_tags!' do
|
||||||
let!(:target_tag) { create(:tag, category: :general) }
|
let!(:target_tag) { create(:tag, category: :general) }
|
||||||
let!(:source_tag) { create(:tag, category: :general) }
|
let!(:source_tag) { create(:tag, category: :general) }
|
||||||
|
|||||||
@@ -897,6 +897,37 @@ RSpec.describe 'Gekanator learning API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /gekanator/questions' do
|
describe 'GET /gekanator/questions' do
|
||||||
|
it 'omits questions for deprecated tags' do
|
||||||
|
active_tag = Tag.create!(name: 'active_question_tag', category: :general)
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
name: 'deprecated_question_tag',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
[active_tag, deprecated_tag].each do |question_tag|
|
||||||
|
GekanatorQuestion.create!(
|
||||||
|
text: "#{ question_tag.name }?",
|
||||||
|
kind: 'tag',
|
||||||
|
source: 'admin_curated',
|
||||||
|
status: 'accepted',
|
||||||
|
priority_weight: 1.0,
|
||||||
|
condition: {
|
||||||
|
type: 'tag',
|
||||||
|
key: "#{ question_tag.category }:#{ question_tag.name }"
|
||||||
|
},
|
||||||
|
created_by: admin
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
get '/gekanator/questions'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
question_ids = json.fetch('questions').map { |question| question.fetch('id') }
|
||||||
|
expect(question_ids).to include('tag:general:active_question_tag')
|
||||||
|
expect(question_ids).not_to include('tag:general:deprecated_question_tag')
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns accepted questions only and includes example_answers for post_similarity questions' do
|
it 'returns accepted questions only and includes example_answers for post_similarity questions' do
|
||||||
sign_in_as admin
|
sign_in_as admin
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
|
||||||
|
RSpec.describe 'Gekanator posts API', type: :request do
|
||||||
|
describe 'GET /gekanator/posts' do
|
||||||
|
it 'omits deprecated tags and returns the stored similarity cosine' do
|
||||||
|
active_tag = Tag.create!(name: 'active tag', category: :general)
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
name: 'deprecated tag',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
post_record = Post.create!(title: 'source', url: 'https://example.com/source')
|
||||||
|
target_post = Post.create!(title: 'target', url: 'https://example.com/target')
|
||||||
|
|
||||||
|
PostTag.create!(post: post_record, tag: active_tag)
|
||||||
|
PostTag.create!(post: post_record, tag: deprecated_tag)
|
||||||
|
PostTag.create!(post: target_post, tag: deprecated_tag)
|
||||||
|
PostSimilarity.create!(post: post_record, target_post:, cos: 0.375)
|
||||||
|
|
||||||
|
get '/gekanator/posts'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
post_json = json.fetch('posts').find { |post| post.fetch('id') == post_record.id }
|
||||||
|
expect(post_json.fetch('tags').map { |tag| tag.fetch('name') }).to eq(['active tag'])
|
||||||
|
expect(post_json.fetch('post_similarity_edges')).to contain_exactly(
|
||||||
|
'target_post_id' => target_post.id,
|
||||||
|
'cos' => 0.375
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -517,6 +517,24 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect([true, false]).to include(json['viewed'])
|
expect([true, false]).to include(json['viewed'])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'omits deprecated tags' do
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
name: 'deprecated_post_tag',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
PostTag.create!(post: post_record, tag: deprecated_tag)
|
||||||
|
|
||||||
|
request
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
tag_names = json.fetch('tags').flat_map { |node|
|
||||||
|
[node.fetch('name')] + node.fetch('children').map { |child| child.fetch('name') }
|
||||||
|
}
|
||||||
|
expect(tag_names).to include('spec_tag')
|
||||||
|
expect(tag_names).not_to include('deprecated_post_tag')
|
||||||
|
end
|
||||||
|
|
||||||
context 'when post has parent, child, and sibling posts' do
|
context 'when post has parent, child, and sibling posts' do
|
||||||
let!(:parent_post) do
|
let!(:parent_post) do
|
||||||
create_parent_post!(
|
create_parent_post!(
|
||||||
@@ -697,6 +715,58 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(names).not_to include('manko')
|
expect(names).not_to include('manko')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects a deprecated tag specified directly' do
|
||||||
|
Tag.create!(
|
||||||
|
name: 'deprecated_direct_tag',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'new post',
|
||||||
|
url: 'https://example.com/deprecated-direct-tag',
|
||||||
|
tags: 'deprecated_direct_tag',
|
||||||
|
thumbnail: dummy_upload
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'tags' => ['廃止済みタグは付与できません.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'expands through multiple deprecated parent tags and saves active ancestors' do
|
||||||
|
child = Tag.create!(name: 'active_child', category: :general)
|
||||||
|
deprecated_parent = Tag.create!(
|
||||||
|
name: 'deprecated_parent',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
deprecated_grandparent = Tag.create!(
|
||||||
|
name: 'deprecated_grandparent',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
active_grandparent = Tag.create!(name: 'active_grandparent', category: :general)
|
||||||
|
TagImplication.create!(tag: child, parent_tag: deprecated_parent)
|
||||||
|
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
|
||||||
|
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent)
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'expanded post',
|
||||||
|
url: 'https://example.com/expanded-deprecated-parent',
|
||||||
|
tags: 'active_child',
|
||||||
|
thumbnail: dummy_upload
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
saved_names = Post.find(json.fetch('id')).tags.map(&:name)
|
||||||
|
expect(saved_names).to include('active_child', 'active_grandparent')
|
||||||
|
expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
|
||||||
|
end
|
||||||
|
|
||||||
context "when nico tag already exists in tags" do
|
context "when nico tag already exists in tags" do
|
||||||
before do
|
before do
|
||||||
Tag.find_undiscard_or_create_by!(
|
Tag.find_undiscard_or_create_by!(
|
||||||
@@ -930,6 +1000,26 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(names).to include('spec_tag_2')
|
expect(names).to include('spec_tag_2')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects a deprecated tag specified directly' do
|
||||||
|
Tag.create!(
|
||||||
|
name: 'deprecated_update_tag',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
put "/posts/#{ post_record.id }", params: post_update_params(
|
||||||
|
post_record,
|
||||||
|
title: 'updated title',
|
||||||
|
tags: 'deprecated_update_tag'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'tags' => ['廃止済みタグは付与できません.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
context "when nico tag already exists in tags" do
|
context "when nico tag already exists in tags" do
|
||||||
before do
|
before do
|
||||||
Tag.find_undiscard_or_create_by!(
|
Tag.find_undiscard_or_create_by!(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
event_type:,
|
event_type:,
|
||||||
name:,
|
name:,
|
||||||
category:,
|
category:,
|
||||||
|
deprecated_at: nil,
|
||||||
aliases: [],
|
aliases: [],
|
||||||
parent_tags: [],
|
parent_tags: [],
|
||||||
created_by_user:,
|
created_by_user:,
|
||||||
@@ -33,6 +34,7 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
event_type: event_type,
|
event_type: event_type,
|
||||||
name: name,
|
name: name,
|
||||||
category: category,
|
category: category,
|
||||||
|
deprecated_at: deprecated_at,
|
||||||
aliases: Array(aliases).join(' '),
|
aliases: Array(aliases).join(' '),
|
||||||
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
|
||||||
created_by_user: created_by_user,
|
created_by_user: created_by_user,
|
||||||
@@ -65,6 +67,7 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
event_type: 'update',
|
event_type: 'update',
|
||||||
name: 'new_tag_name',
|
name: 'new_tag_name',
|
||||||
category: 'meme',
|
category: 'meme',
|
||||||
|
deprecated_at: t_v2,
|
||||||
aliases: ['alias_shared', 'alias_new'],
|
aliases: ['alias_shared', 'alias_new'],
|
||||||
parent_tags: [parent_shared, parent_new],
|
parent_tags: [parent_shared, parent_new],
|
||||||
created_by_user: member,
|
created_by_user: member,
|
||||||
@@ -133,6 +136,10 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
'current' => 'meme',
|
'current' => 'meme',
|
||||||
'prev' => 'general'
|
'prev' => 'general'
|
||||||
)
|
)
|
||||||
|
expect(latest.fetch('deprecated_at')).to eq(
|
||||||
|
'current' => t_v2.iso8601,
|
||||||
|
'prev' => nil
|
||||||
|
)
|
||||||
expect(latest.fetch('aliases')).to include(
|
expect(latest.fetch('aliases')).to include(
|
||||||
{ 'name' => 'alias_shared', 'type' => 'context' },
|
{ 'name' => 'alias_shared', 'type' => 'context' },
|
||||||
{ 'name' => 'alias_new', 'type' => 'added' },
|
{ 'name' => 'alias_new', 'type' => 'added' },
|
||||||
@@ -178,6 +185,10 @@ RSpec.describe 'TagVersions API', type: :request do
|
|||||||
'current' => 'general',
|
'current' => 'general',
|
||||||
'prev' => nil
|
'prev' => nil
|
||||||
)
|
)
|
||||||
|
expect(first.fetch('deprecated_at')).to eq(
|
||||||
|
'current' => nil,
|
||||||
|
'prev' => nil
|
||||||
|
)
|
||||||
expect(first.fetch('aliases')).to include(
|
expect(first.fetch('aliases')).to include(
|
||||||
{ 'name' => 'alias_shared', 'type' => 'added' },
|
{ 'name' => 'alias_shared', 'type' => 'added' },
|
||||||
{ 'name' => 'alias_old', 'type' => 'added' }
|
{ 'name' => 'alias_old', 'type' => 'added' }
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
@@ -123,6 +124,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
category: 'meme',
|
category: 'meme',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
@@ -149,6 +151,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'put_tag_alias_only_alias',
|
aliases: 'put_tag_alias_only_alias',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|||||||
@@ -76,6 +76,27 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(response_tags.first['id']).to eq(meme.id)
|
expect(response_tags.first['id']).to eq(meme.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'filters tags by deprecated state' do
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
name: 'deprecated_filter',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: 1.day.from_now
|
||||||
|
)
|
||||||
|
active_tag = Tag.create!(name: 'active_filter', category: :general)
|
||||||
|
|
||||||
|
get '/tags', params: { name: '_filter', deprecated: '1' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response_names).to include(deprecated_tag.name)
|
||||||
|
expect(response_names).not_to include(active_tag.name)
|
||||||
|
|
||||||
|
get '/tags', params: { name: '_filter', deprecated: '0' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response_names).to include(active_tag.name)
|
||||||
|
expect(response_names).not_to include(deprecated_tag.name)
|
||||||
|
end
|
||||||
|
|
||||||
it 'filters tags by post_count range' do
|
it 'filters tags by post_count range' do
|
||||||
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
|
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
|
||||||
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general)
|
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general)
|
||||||
@@ -301,6 +322,21 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(t['matched_alias']).to eq('unko')
|
expect(t['matched_alias']).to eq('unko')
|
||||||
expect(json.map { |x| x['name'] }).not_to include('unknown')
|
expect(json.map { |x| x['name'] }).not_to include('unknown')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'omits deprecated tags' do
|
||||||
|
deprecated_tag = Tag.create!(
|
||||||
|
name: 'spec_deprecated',
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
deprecated_tag.update_columns(post_count: 1)
|
||||||
|
|
||||||
|
get '/tags/autocomplete', params: { q: 'spec_', present: '0' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { |item| item.fetch('name') }).to include('spec_tag')
|
||||||
|
expect(json.map { |item| item.fetch('name') }).not_to include('spec_deprecated')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /tags/name/:name' do
|
describe 'GET /tags/name/:name' do
|
||||||
@@ -437,6 +473,32 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'updates deprecated state and records it in tag versions' do
|
||||||
|
expect {
|
||||||
|
patch "/tags/#{ tag.id }", params: { deprecated: '1' }
|
||||||
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(tag.reload.deprecated_at).to be_present
|
||||||
|
|
||||||
|
versions = tag.tag_versions.order(:version_no)
|
||||||
|
expect(versions.first.deprecated_at).to be_nil
|
||||||
|
expect(versions.second.deprecated_at).to eq(tag.deprecated_at)
|
||||||
|
expect(json.fetch('deprecated_at')).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects deprecating a nico tag' do
|
||||||
|
nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico)
|
||||||
|
|
||||||
|
patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(nico_tag.reload.deprecated_at).to be_nil
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'deprecated' => ['ニコタグは廃止できません.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns 422 when changing normal tag category to nico' do
|
it 'returns 422 when changing normal tag category to nico' do
|
||||||
expect {
|
expect {
|
||||||
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
||||||
@@ -585,6 +647,111 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(row['has_children']).to eq(true)
|
expect(row['has_children']).to eq(true)
|
||||||
expect(row['children']).to eq([])
|
expect(row['children']).to eq([])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'passes through deprecated tags when finding children' do
|
||||||
|
deprecated_middle = Tag.create!(
|
||||||
|
name: 'depth_deprecated_middle',
|
||||||
|
category: :character,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
visible_descendant = Tag.create!(
|
||||||
|
name: 'depth_visible_descendant',
|
||||||
|
category: :material
|
||||||
|
)
|
||||||
|
TagImplication.create!(parent_tag: root_material, tag: deprecated_middle)
|
||||||
|
TagImplication.create!(parent_tag: deprecated_middle, tag: visible_descendant)
|
||||||
|
|
||||||
|
get '/tags/with-depth', params: { parent: root_material.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { |item| item.fetch('name') }).to eq(['depth_visible_descendant'])
|
||||||
|
expect(json.map { |item| item.fetch('name') }).not_to include('depth_deprecated_middle')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'passes through multiple deprecated tags for roots and has_children' do
|
||||||
|
active_child = Tag.create!(
|
||||||
|
name: 'depth_active_child_below_deprecated',
|
||||||
|
category: :character
|
||||||
|
)
|
||||||
|
deprecated_parent = Tag.create!(
|
||||||
|
name: 'depth_deprecated_parent',
|
||||||
|
category: :character,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
deprecated_grandparent = Tag.create!(
|
||||||
|
name: 'depth_deprecated_grandparent',
|
||||||
|
category: :material,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
active_ancestor = Tag.create!(
|
||||||
|
name: 'depth_active_ancestor',
|
||||||
|
category: :meme
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
|
||||||
|
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
|
||||||
|
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
|
||||||
|
|
||||||
|
get '/tags/with-depth'
|
||||||
|
|
||||||
|
root_names = json.map { |item| item.fetch('name') }
|
||||||
|
expect(root_names).to include('depth_active_ancestor')
|
||||||
|
expect(root_names).not_to include('depth_active_child_below_deprecated')
|
||||||
|
ancestor_json = json.find { |item| item.fetch('id') == active_ancestor.id }
|
||||||
|
expect(ancestor_json.fetch('has_children')).to eq(true)
|
||||||
|
|
||||||
|
get '/tags/with-depth', params: { parent: active_ancestor.id }
|
||||||
|
|
||||||
|
expect(json.map { |item| item.fetch('name') }).to include(
|
||||||
|
'depth_active_child_below_deprecated'
|
||||||
|
)
|
||||||
|
expect(json.map { |item| item.fetch('name') }).not_to include(
|
||||||
|
'depth_deprecated_parent',
|
||||||
|
'depth_deprecated_grandparent'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'treats an active tag with only deprecated ancestors as a root' do
|
||||||
|
active_child = Tag.create!(
|
||||||
|
name: 'depth_root_below_deprecated',
|
||||||
|
category: :character
|
||||||
|
)
|
||||||
|
deprecated_parent = Tag.create!(
|
||||||
|
name: 'depth_root_deprecated_parent',
|
||||||
|
category: :material,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag: active_child, parent_tag: deprecated_parent)
|
||||||
|
|
||||||
|
get '/tags/with-depth'
|
||||||
|
|
||||||
|
expect(json.map { |item| item.fetch('name') }).to include(
|
||||||
|
'depth_root_below_deprecated'
|
||||||
|
)
|
||||||
|
expect(json.map { |item| item.fetch('name') }).not_to include(
|
||||||
|
'depth_root_deprecated_parent'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'terminates when deprecated implications contain a cycle' do
|
||||||
|
first = Tag.create!(
|
||||||
|
name: 'depth_cycle_first',
|
||||||
|
category: :character,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
second = Tag.create!(
|
||||||
|
name: 'depth_cycle_second',
|
||||||
|
category: :material,
|
||||||
|
deprecated_at: Time.current
|
||||||
|
)
|
||||||
|
TagImplication.create!(tag: first, parent_tag: root_material)
|
||||||
|
TagImplication.create!(tag: second, parent_tag: first)
|
||||||
|
TagImplication.create!(tag: first, parent_tag: second)
|
||||||
|
|
||||||
|
get '/tags/with-depth', params: { parent: root_material.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to eq([])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /tags/name/:name/materials' do
|
describe 'GET /tags/name/:name/materials' do
|
||||||
@@ -732,6 +899,20 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(tag.category).to eq('general')
|
expect(tag.category).to eq('general')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'deprecated がなければ 422 を返す' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'new',
|
||||||
|
category: 'general',
|
||||||
|
aliases: '',
|
||||||
|
parent_tags: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'deprecated' => ['廃止状態は必須です.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
it 'name, category, aliases, parent tags をまとめて更新できる' do
|
it 'name, category, aliases, parent tags をまとめて更新できる' do
|
||||||
old_parent = Tag.create!(
|
old_parent = Tag.create!(
|
||||||
tag_name: TagName.create!(name: 'put_old_parent'),
|
tag_name: TagName.create!(name: 'put_old_parent'),
|
||||||
@@ -749,6 +930,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'meme',
|
category: 'meme',
|
||||||
aliases: 'put_alias_a put_alias_b put_alias_a',
|
aliases: 'put_alias_a put_alias_b put_alias_a',
|
||||||
parent_tags: 'put_kept_parent put_new_parent',
|
parent_tags: 'put_kept_parent put_new_parent',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -793,6 +975,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'spec_tag put_alias_self_test',
|
aliases: 'spec_tag put_alias_self_test',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -810,6 +993,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: 'spec_tag',
|
parent_tags: 'spec_tag',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -825,6 +1009,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'meta',
|
category: 'meta',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}.to change(TagVersion, :count).by(2)
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
@@ -860,6 +1045,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: new_parent.name,
|
parent_tags: new_parent.name,
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -875,6 +1061,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'nico',
|
category: 'nico',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}.not_to change(TagVersion, :count)
|
}.not_to change(TagVersion, :count)
|
||||||
|
|
||||||
@@ -896,6 +1083,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'nico',
|
category: 'nico',
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}.not_to change(NicoTagVersion, :count)
|
}.not_to change(NicoTagVersion, :count)
|
||||||
|
|
||||||
@@ -916,6 +1104,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: old_category,
|
category: old_category,
|
||||||
aliases: '',
|
aliases: '',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}.not_to change(TagVersion, :count)
|
}.not_to change(TagVersion, :count)
|
||||||
|
|
||||||
@@ -946,6 +1135,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'meme',
|
category: 'meme',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
@@ -981,6 +1171,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko put_stolen_alias',
|
aliases: 'unko put_stolen_alias',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change { tag.reload.tag_versions.count }.by(2)
|
.to change { tag.reload.tag_versions.count }.by(2)
|
||||||
@@ -1015,6 +1206,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: child.name,
|
parent_tags: child.name,
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
@@ -1036,6 +1228,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
category: 'general',
|
category: 'general',
|
||||||
aliases: 'unko',
|
aliases: 'unko',
|
||||||
parent_tags: '',
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
end
|
end
|
||||||
|
let!(:tag) do
|
||||||
|
Tag.create!(
|
||||||
|
tag_name: tn,
|
||||||
|
category: :general,
|
||||||
|
deprecated_at: Time.zone.local(2026, 6, 1)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
describe 'GET /wiki' do
|
describe 'GET /wiki' do
|
||||||
it 'returns wiki pages with title' do
|
it 'returns wiki pages with title' do
|
||||||
@@ -30,6 +37,8 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
expect(json[0]).to have_key('title')
|
expect(json[0]).to have_key('title')
|
||||||
expect(json.map { |p| p['title'] }).to include('spec_wiki_title')
|
expect(json.map { |p| p['title'] }).to include('spec_wiki_title')
|
||||||
|
wiki_json = json.find { |item| item.fetch('id') == page.id }
|
||||||
|
expect(wiki_json.fetch('deprecated_at')).to eq(tag.deprecated_at.iso8601(3))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -48,7 +57,8 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
expect(json).to include(
|
expect(json).to include(
|
||||||
'id' => page.id,
|
'id' => page.id,
|
||||||
'title' => 'spec_wiki_title')
|
'title' => 'spec_wiki_title',
|
||||||
|
'deprecated_at' => tag.deprecated_at.iso8601(3))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -409,7 +419,11 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
'kind' => 'content',
|
'kind' => 'content',
|
||||||
'message' => 'r2'
|
'message' => 'r2'
|
||||||
)
|
)
|
||||||
expect(top['wiki_page']).to include('id' => page.id, 'title' => 'spec_wiki_title')
|
expect(top['wiki_page']).to include(
|
||||||
|
'id' => page.id,
|
||||||
|
'title' => 'spec_wiki_title',
|
||||||
|
'deprecated_at' => tag.deprecated_at.iso8601(3)
|
||||||
|
)
|
||||||
expect(top['user']).to include('id' => user.id, 'name' => user.name)
|
expect(top['user']).to include('id' => user.id, 'name' => user.name)
|
||||||
expect(top).to have_key('timestamp')
|
expect(top).to have_key('timestamp')
|
||||||
|
|
||||||
@@ -479,6 +493,7 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
expect(json).to include(
|
expect(json).to include(
|
||||||
'wiki_page_id' => page.id,
|
'wiki_page_id' => page.id,
|
||||||
'title' => 'spec_wiki_title',
|
'title' => 'spec_wiki_title',
|
||||||
|
'deprecated_at' => tag.deprecated_at.iso8601(3),
|
||||||
'older_revision_id' => rev_a.id,
|
'older_revision_id' => rev_a.id,
|
||||||
'newer_revision_id' => rev_b.id
|
'newer_revision_id' => rev_b.id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ require 'rails_helper'
|
|||||||
RSpec.describe 'post_similarity:calc' do
|
RSpec.describe 'post_similarity:calc' do
|
||||||
include RakeTaskHelper
|
include RakeTaskHelper
|
||||||
|
|
||||||
it 'calls Similarity::Calc with Post and :tags' do
|
it 'calculates similarities from active tags only' do
|
||||||
# 必要最低限のデータ
|
# 必要最低限のデータ
|
||||||
t1 = Tag.create!(name: "t1")
|
t1 = Tag.create!(name: "t1")
|
||||||
t2 = Tag.create!(name: "t2")
|
t2 = Tag.create!(name: "t2")
|
||||||
t3 = Tag.create!(name: "t3")
|
t3 = Tag.create!(name: "t3")
|
||||||
|
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
|
||||||
|
|
||||||
p1 = Post.create!(url: "https://example.com/1")
|
p1 = Post.create!(url: "https://example.com/1")
|
||||||
p2 = Post.create!(url: "https://example.com/2")
|
p2 = Post.create!(url: "https://example.com/2")
|
||||||
@@ -22,6 +23,8 @@ RSpec.describe 'post_similarity:calc' do
|
|||||||
PostTag.create!(post: p2, tag: t3)
|
PostTag.create!(post: p2, tag: t3)
|
||||||
|
|
||||||
PostTag.create!(post: p3, tag: t3)
|
PostTag.create!(post: p3, tag: t3)
|
||||||
|
PostTag.create!(post: p1, tag: deprecated_tag)
|
||||||
|
PostTag.create!(post: p2, tag: deprecated_tag)
|
||||||
|
|
||||||
expect { run_rake_task("post_similarity:calc") }
|
expect { run_rake_task("post_similarity:calc") }
|
||||||
.to change { PostSimilarity.count }.from(0)
|
.to change { PostSimilarity.count }.from(0)
|
||||||
@@ -29,6 +32,6 @@ RSpec.describe 'post_similarity:calc' do
|
|||||||
ps = PostSimilarity.find_by!(post_id: p1.id, target_post_id: p2.id)
|
ps = PostSimilarity.find_by!(post_id: p1.id, target_post_id: p2.id)
|
||||||
ps_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id)
|
ps_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id)
|
||||||
expect(ps_rev.cos).to eq(ps.cos)
|
expect(ps_rev.cos).to eq(ps.cos)
|
||||||
|
expect(ps.cos).to be_within(0.0001).of(0.5)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ require 'rails_helper'
|
|||||||
RSpec.describe 'tag_similarity:calc' do
|
RSpec.describe 'tag_similarity:calc' do
|
||||||
include RakeTaskHelper
|
include RakeTaskHelper
|
||||||
|
|
||||||
it 'calls Similarity::Calc with Tag and :posts' do
|
it 'calculates similarities for active tags only' do
|
||||||
# 必要最低限のデータ
|
# 必要最低限のデータ
|
||||||
t1 = Tag.create!(name: "t1")
|
t1 = Tag.create!(name: "t1")
|
||||||
t2 = Tag.create!(name: "t2")
|
t2 = Tag.create!(name: "t2")
|
||||||
t3 = Tag.create!(name: "t3")
|
t3 = Tag.create!(name: "t3")
|
||||||
|
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
|
||||||
|
|
||||||
p1 = Post.create!(url: "https://example.com/1")
|
p1 = Post.create!(url: "https://example.com/1")
|
||||||
p2 = Post.create!(url: "https://example.com/2")
|
p2 = Post.create!(url: "https://example.com/2")
|
||||||
@@ -22,6 +23,7 @@ RSpec.describe 'tag_similarity:calc' do
|
|||||||
PostTag.create!(post: p2, tag: t3)
|
PostTag.create!(post: p2, tag: t3)
|
||||||
|
|
||||||
PostTag.create!(post: p3, tag: t3)
|
PostTag.create!(post: p3, tag: t3)
|
||||||
|
PostTag.create!(post: p1, tag: deprecated_tag)
|
||||||
|
|
||||||
expect { run_rake_task("tag_similarity:calc") }
|
expect { run_rake_task("tag_similarity:calc") }
|
||||||
.to change { TagSimilarity.count }.from(0)
|
.to change { TagSimilarity.count }.from(0)
|
||||||
@@ -29,6 +31,7 @@ RSpec.describe 'tag_similarity:calc' do
|
|||||||
ps = TagSimilarity.find_by!(tag_id: t1.id, target_tag_id: t2.id)
|
ps = TagSimilarity.find_by!(tag_id: t1.id, target_tag_id: t2.id)
|
||||||
ps_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id)
|
ps_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id)
|
||||||
expect(ps_rev.cos).to eq(ps.cos)
|
expect(ps_rev.cos).to eq(ps.cos)
|
||||||
|
expect(TagSimilarity.where(tag_id: deprecated_tag.id)).to be_empty
|
||||||
|
expect(TagSimilarity.where(target_tag_id: deprecated_tag.id)).to be_empty
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ describe ('TagLink', () => {
|
|||||||
expect (screen.getByText ('4')).toBeInTheDocument ()
|
expect (screen.getByText ('4')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('does not append deprecated state to the rendered tag name', () => {
|
||||||
|
renderWithProviders (
|
||||||
|
<TagLink
|
||||||
|
tag={buildTag ({
|
||||||
|
name: '旧タグ',
|
||||||
|
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
})}
|
||||||
|
withWiki={false}
|
||||||
|
withCount={false}/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect (screen.getByRole ('link', { name: '旧タグ' })).toBeInTheDocument ()
|
||||||
|
expect (screen.queryByText ('(廃止)')).not.toBeInTheDocument ()
|
||||||
|
})
|
||||||
|
|
||||||
it ('links wiki markers to the correct detail route', () => {
|
it ('links wiki markers to the correct detail route', () => {
|
||||||
renderWithProviders (
|
renderWithProviders (
|
||||||
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
|
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
|
||||||
|
|||||||
@@ -128,4 +128,4 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagLink
|
export default TagLink
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiGet, apiPost } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
buildGekanatorQuestions,
|
buildGekanatorQuestions,
|
||||||
expectedAnswerForQuestion,
|
expectedAnswerForQuestion,
|
||||||
|
fetchGekanatorPosts,
|
||||||
|
fetchGekanatorQuestions,
|
||||||
learnedSemanticSideForPost,
|
learnedSemanticSideForPost,
|
||||||
questionIdForCondition,
|
questionIdForCondition,
|
||||||
restoreGekanatorQuestion,
|
restoreGekanatorQuestion,
|
||||||
@@ -24,6 +26,7 @@ vi.mock('@/lib/api', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const mockedApiPost = vi.mocked(apiPost)
|
const mockedApiPost = vi.mocked(apiPost)
|
||||||
|
const mockedApiGet = vi.mocked(apiGet)
|
||||||
|
|
||||||
const post = (overrides: Partial<Post> = {}): Post => ({
|
const post = (overrides: Partial<Post> = {}): Post => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -43,6 +46,24 @@ const post = (overrides: Partial<Post> = {}): Post => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Gekanator API functions', () => {
|
||||||
|
it('returns posts from the Gekanator posts endpoint', async () => {
|
||||||
|
const posts = [post()]
|
||||||
|
mockedApiGet.mockResolvedValueOnce({ posts })
|
||||||
|
|
||||||
|
await expect(fetchGekanatorPosts()).resolves.toEqual(posts)
|
||||||
|
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns questions from the Gekanator questions endpoint', async () => {
|
||||||
|
const questions: StoredGekanatorQuestion[] = []
|
||||||
|
mockedApiGet.mockResolvedValueOnce({ questions })
|
||||||
|
|
||||||
|
await expect(fetchGekanatorQuestions()).resolves.toEqual(questions)
|
||||||
|
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('expectedAnswerForQuestion', () => {
|
describe('expectedAnswerForQuestion', () => {
|
||||||
it('returns a direct example answer when present', () => {
|
it('returns a direct example answer when present', () => {
|
||||||
const question: StoredGekanatorQuestion = {
|
const question: StoredGekanatorQuestion = {
|
||||||
@@ -126,6 +147,7 @@ describe('expectedAnswerForQuestion', () => {
|
|||||||
postCount: 1,
|
postCount: 1,
|
||||||
createdAt: '2026-06-10T00:00:00.000Z',
|
createdAt: '2026-06-10T00:00:00.000Z',
|
||||||
updatedAt: '2026-06-10T00:00:00.000Z',
|
updatedAt: '2026-06-10T00:00:00.000Z',
|
||||||
|
deprecatedAt: null,
|
||||||
hasWiki: false,
|
hasWiki: false,
|
||||||
hasDeerjikists: false,
|
hasDeerjikists: false,
|
||||||
materialId: null,
|
materialId: null,
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ describe ('tags API functions', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each ([
|
||||||
|
[true, '1'],
|
||||||
|
[false, '0'],
|
||||||
|
] as const) ('maps deprecated=%s to %s', async (deprecated, expected) => {
|
||||||
|
api.apiGet.mockResolvedValueOnce ({ tags: [], count: 0 })
|
||||||
|
|
||||||
|
await fetchTags ({ ...baseParams, deprecated })
|
||||||
|
|
||||||
|
expect (api.apiGet).toHaveBeenCalledWith (
|
||||||
|
'/tags',
|
||||||
|
{ params: expect.objectContaining ({ deprecated: expected }) },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it ('returns null when tag fetches fail', async () => {
|
it ('returns null when tag fetches fail', async () => {
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchTagChanges = async (
|
export const fetchTagChanges = async (
|
||||||
{ id, page, limit }: {
|
{ id, page, limit }: {
|
||||||
id?: string
|
id?: string
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ vi.mock ('@/lib/tags', () => tagsApi)
|
|||||||
describe ('TagListPage', () => {
|
describe ('TagListPage', () => {
|
||||||
it ('loads tags from URL filters and renders the results table', async () => {
|
it ('loads tags from URL filters and renders the results table', async () => {
|
||||||
tagsApi.fetchTags.mockResolvedValueOnce ({
|
tagsApi.fetchTags.mockResolvedValueOnce ({
|
||||||
tags: [buildTag ({ id: 7, name: '虹夏', category: 'character', postCount: 99 })],
|
tags: [buildTag ({
|
||||||
|
id: 7,
|
||||||
|
name: '虹夏',
|
||||||
|
category: 'character',
|
||||||
|
postCount: 99,
|
||||||
|
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
})],
|
||||||
count: 1,
|
count: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
renderWithProviders (
|
renderWithProviders (
|
||||||
<TagListPage/>,
|
<TagListPage/>,
|
||||||
{ route: '/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5' },
|
{
|
||||||
|
route:
|
||||||
|
'/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5&deprecated=1',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
@@ -30,6 +39,7 @@ describe ('TagListPage', () => {
|
|||||||
category: 'character',
|
category: 'character',
|
||||||
page: 3,
|
page: 3,
|
||||||
postCountGTE: 5,
|
postCountGTE: 5,
|
||||||
|
deprecated: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -38,6 +48,8 @@ describe ('TagListPage', () => {
|
|||||||
'/tags/7',
|
'/tags/7',
|
||||||
)
|
)
|
||||||
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
|
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
|
||||||
|
expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1')
|
||||||
|
expect (screen.getAllByText ('廃止')).toHaveLength (2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('navigates to a normalized search URL on submit', async () => {
|
it ('navigates to a normalized search URL on submit', async () => {
|
||||||
@@ -46,7 +58,9 @@ describe ('TagListPage', () => {
|
|||||||
renderWithProviders (<TagListPage/>, { route: '/tags' })
|
renderWithProviders (<TagListPage/>, { route: '/tags' })
|
||||||
|
|
||||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
|
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
|
||||||
fireEvent.change (screen.getByRole ('combobox'), { target: { value: 'character' } })
|
fireEvent.change (screen.getAllByRole ('combobox')[0], {
|
||||||
|
target: { value: 'character' },
|
||||||
|
})
|
||||||
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
|
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
|
||||||
|
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import { Route, Routes } from 'react-router-dom'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||||
|
import { buildTag, buildWikiPage } from '@/test/factories'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
const wikiApi = vi.hoisted (() => ({
|
||||||
|
fetchWikiPage: vi.fn (),
|
||||||
|
fetchWikiPageByTitle: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const tagsApi = vi.hoisted (() => ({
|
||||||
|
fetchTagByName: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const postsApi = vi.hoisted (() => ({
|
||||||
|
fetchPosts: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock ('@/lib/wiki', () => wikiApi)
|
||||||
|
vi.mock ('@/lib/tags', () => tagsApi)
|
||||||
|
vi.mock ('@/lib/posts', () => postsApi)
|
||||||
|
|
||||||
|
describe ('WikiDetailPage', () => {
|
||||||
|
it ('renders deprecated state outside the wiki title link', async () => {
|
||||||
|
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
|
||||||
|
title: '旧タグ',
|
||||||
|
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
}))
|
||||||
|
tagsApi.fetchTagByName.mockResolvedValueOnce (buildTag ({
|
||||||
|
name: '旧タグ',
|
||||||
|
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
}))
|
||||||
|
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
|
||||||
|
|
||||||
|
renderWithProviders (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
|
</Routes>,
|
||||||
|
{ route: '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const marker = await screen.findByText ('(廃止)')
|
||||||
|
const heading = marker.closest ('h1')
|
||||||
|
const link = screen.getByRole ('link', { name: '旧タグ' })
|
||||||
|
|
||||||
|
expect (heading).not.toBeNull ()
|
||||||
|
expect (heading!).toHaveTextContent ('旧タグ(廃止)')
|
||||||
|
expect (link).toBeInTheDocument ()
|
||||||
|
expect (marker.closest ('a')).toBeNull ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,6 +39,7 @@ const WikiDetailPage: FC = () => {
|
|||||||
queryFn: () => fetchWikiPageByTitle (title, { version }) })
|
queryFn: () => fetchWikiPageByTitle (title, { version }) })
|
||||||
|
|
||||||
const effectiveTitle = wikiPage?.title ?? title
|
const effectiveTitle = wikiPage?.title ?? title
|
||||||
|
const deprecated = wikiPage?.deprecatedAt != null
|
||||||
|
|
||||||
const { data: tag } = useQuery ({
|
const { data: tag } = useQuery ({
|
||||||
enabled: Boolean (effectiveTitle),
|
enabled: Boolean (effectiveTitle),
|
||||||
@@ -88,7 +89,7 @@ const WikiDetailPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`${ title } Wiki | ${ SITE_TITLE }`}</title>
|
<title>{`${ effectiveTitle }${ deprecated ? '(廃止)' : '' } Wiki | ${ SITE_TITLE }`}</title>
|
||||||
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
|
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
@@ -110,10 +111,13 @@ const WikiDetailPage: FC = () => {
|
|||||||
|
|
||||||
<article className="prose dark:prose-invert mx-auto p-4">
|
<article className="prose dark:prose-invert mx-auto p-4">
|
||||||
<h1 className="prose-a:no-underline">
|
<h1 className="prose-a:no-underline">
|
||||||
<TagLink tag={tag ?? defaultTag}
|
<TagLink tag={tag ?? { ...defaultTag,
|
||||||
|
name: effectiveTitle,
|
||||||
|
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
|
||||||
withWiki={false}
|
withWiki={false}
|
||||||
withCount={false}
|
withCount={false}
|
||||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
||||||
|
{deprecated && <span>(廃止)</span>}
|
||||||
</h1>
|
</h1>
|
||||||
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe ('WikiDiffPage', () => {
|
|||||||
api.apiGet.mockResolvedValueOnce ({
|
api.apiGet.mockResolvedValueOnce ({
|
||||||
wikiPageId: 3,
|
wikiPageId: 3,
|
||||||
title: '差分対象',
|
title: '差分対象',
|
||||||
|
deprecatedAt: null,
|
||||||
olderRevisionId: 1,
|
olderRevisionId: 1,
|
||||||
newerRevisionId: 2,
|
newerRevisionId: 2,
|
||||||
diff: [
|
diff: [
|
||||||
@@ -43,4 +44,26 @@ describe ('WikiDiffPage', () => {
|
|||||||
expect (screen.getByText ('added line')).toBeInTheDocument ()
|
expect (screen.getByText ('added line')).toBeInTheDocument ()
|
||||||
expect (screen.getByText ('removed line')).toBeInTheDocument ()
|
expect (screen.getByText ('removed line')).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('appends deprecated state to the wiki title', async () => {
|
||||||
|
api.apiGet.mockResolvedValueOnce ({
|
||||||
|
wikiPageId: 3,
|
||||||
|
title: '廃止 Wiki',
|
||||||
|
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
olderRevisionId: 1,
|
||||||
|
newerRevisionId: 2,
|
||||||
|
diff: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||||
|
</Routes>,
|
||||||
|
{ route: '/wiki/3/diff?from=1&to=2' },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect (await screen.findByRole ('heading', {
|
||||||
|
name: '廃止 Wiki(廃止)',
|
||||||
|
})).toBeInTheDocument ()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const WikiDiffPage: FC = () => {
|
|||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
const from = query.get ('from')
|
const from = query.get ('from')
|
||||||
const to = query.get ('to')
|
const to = query.get ('to')
|
||||||
|
const displayTitle = diff
|
||||||
|
? `${ diff.title }${ diff.deprecatedAt != null ? '(廃止)' : '' }`
|
||||||
|
: ''
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -33,9 +36,9 @@ const WikiDiffPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`Wiki 差分: ${ diff?.title } | ${ SITE_TITLE }`}</title>
|
<title>{`Wiki 差分: ${ displayTitle } | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<PageTitle>{diff?.title}</PageTitle>
|
<PageTitle>{displayTitle}</PageTitle>
|
||||||
<div className="prose mx-auto p-4">
|
<div className="prose mx-auto p-4">
|
||||||
{diff
|
{diff
|
||||||
? (
|
? (
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
const api = vi.hoisted (() => ({
|
||||||
|
apiGet: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock ('@/lib/api', () => api)
|
||||||
|
|
||||||
|
describe ('WikiHistoryPage', () => {
|
||||||
|
it ('renders deprecated state outside the wiki title link', async () => {
|
||||||
|
api.apiGet.mockResolvedValueOnce ([{
|
||||||
|
revisionId: 2,
|
||||||
|
pred: 1,
|
||||||
|
succ: null,
|
||||||
|
wikiPage: {
|
||||||
|
id: 3,
|
||||||
|
title: '旧タグ',
|
||||||
|
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
user: { id: 4, name: 'tester' },
|
||||||
|
kind: 'content',
|
||||||
|
message: 'updated',
|
||||||
|
timestamp: '2026-06-02T00:00:00.000Z',
|
||||||
|
}])
|
||||||
|
|
||||||
|
renderWithProviders (<WikiHistoryPage/>)
|
||||||
|
|
||||||
|
const link = await screen.findByRole ('link', { name: '旧タグ' })
|
||||||
|
const marker = screen.getByText ('(廃止)')
|
||||||
|
|
||||||
|
expect (link).toHaveAttribute ('href', '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0?version=2')
|
||||||
|
expect (marker.closest ('a')).toBeNull ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -59,6 +59,7 @@ const WikiHistoryPage: FC = () => {
|
|||||||
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
|
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
|
||||||
{change.wikiPage.title}
|
{change.wikiPage.title}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
|
{change.wikiPage.deprecatedAt != null && <span>(廃止)</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{change.pred == null ? '新規' : '更新'}
|
{change.pred == null ? '新規' : '更新'}
|
||||||
|
|||||||
@@ -42,4 +42,21 @@ describe ('WikiSearchPage', () => {
|
|||||||
})
|
})
|
||||||
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
|
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('marks deprecated wiki tags in the result title', async () => {
|
||||||
|
api.apiGet.mockResolvedValueOnce ([
|
||||||
|
buildWikiPage ({
|
||||||
|
title: '旧タグ',
|
||||||
|
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
renderWithProviders (<WikiSearchPage/>)
|
||||||
|
|
||||||
|
const link = await screen.findByRole ('link', { name: '旧タグ' })
|
||||||
|
const marker = screen.getByText ('(廃止)')
|
||||||
|
|
||||||
|
expect (link).toBeInTheDocument ()
|
||||||
|
expect (marker.closest ('a')).toBeNull ()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const WikiSearchPage: FC = () => {
|
|||||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
|
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
|
||||||
{page.title}
|
{page.title}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
|
{page.deprecatedAt != null && <span>(廃止)</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{dateString (page.updatedAt)}
|
{dateString (page.updatedAt)}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const buildUser = (overrides: Partial<User> = {}): User => ({
|
|||||||
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
|
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'テストWiki',
|
title: 'テストWiki',
|
||||||
|
deprecatedAt: null,
|
||||||
createdUserId: 1,
|
createdUserId: 1,
|
||||||
updatedUserId: 1,
|
updatedUserId: 1,
|
||||||
createdAt: '2026-01-02T03:04:05.000Z',
|
createdAt: '2026-01-02T03:04:05.000Z',
|
||||||
|
|||||||
+3
-1
@@ -299,6 +299,7 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha
|
|||||||
export type WikiPage = {
|
export type WikiPage = {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
|
deprecatedAt: string | null
|
||||||
createdUserId: number
|
createdUserId: number
|
||||||
updatedUserId: number
|
updatedUserId: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -312,7 +313,7 @@ export type WikiPageChange = {
|
|||||||
revisionId: number
|
revisionId: number
|
||||||
pred: number | null
|
pred: number | null
|
||||||
succ: null
|
succ: null
|
||||||
wikiPage: Pick<WikiPage, 'id' | 'title'>
|
wikiPage: Pick<WikiPage, 'id' | 'title' | 'deprecatedAt'>
|
||||||
user: Pick<User, 'id' | 'name'>
|
user: Pick<User, 'id' | 'name'>
|
||||||
kind: 'content' | 'redirect'
|
kind: 'content' | 'redirect'
|
||||||
message: string | null
|
message: string | null
|
||||||
@@ -321,6 +322,7 @@ export type WikiPageChange = {
|
|||||||
export type WikiPageDiff = {
|
export type WikiPageDiff = {
|
||||||
wikiPageId: number
|
wikiPageId: number
|
||||||
title: string
|
title: string
|
||||||
|
deprecatedAt: string | null
|
||||||
olderRevisionId: number | null
|
olderRevisionId: number | null
|
||||||
newerRevisionId: number
|
newerRevisionId: number
|
||||||
diff: WikiPageDiffDiff[] }
|
diff: WikiPageDiffDiff[] }
|
||||||
|
|||||||
@@ -27,5 +27,6 @@
|
|||||||
"@/*": ["*"]
|
"@/*": ["*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||||
}
|
}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする