diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 8fdbf9a..d354314 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -7,7 +7,8 @@ class Post < ApplicationRecord 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 :tags, through: :active_post_tags - has_many :active_tags, -> { where(deprecated_at: nil) }, through: :active_post_tags, source: :tag + 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 :post_similarities, dependent: :delete_all diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 93928d3..26f271b 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -897,6 +897,37 @@ RSpec.describe 'Gekanator learning API', type: :request do end 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 sign_in_as admin diff --git a/backend/spec/requests/gekanator_posts_spec.rb b/backend/spec/requests/gekanator_posts_spec.rb new file mode 100644 index 0000000..b21e414 --- /dev/null +++ b/backend/spec/requests/gekanator_posts_spec.rb @@ -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 diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 2914220..2e6e432 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -517,6 +517,24 @@ RSpec.describe 'Posts API', type: :request do expect([true, false]).to include(json['viewed']) 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 let!(:parent_post) do create_parent_post!( @@ -697,6 +715,52 @@ RSpec.describe 'Posts API', type: :request do expect(names).not_to include('manko') 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 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 + ) + active_grandparent = Tag.create!(name: 'active_grandparent', category: :general) + TagImplication.create!(tag: child, parent_tag: deprecated_parent) + TagImplication.create!(tag: deprecated_parent, 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') + end + context "when nico tag already exists in tags" do before do Tag.find_undiscard_or_create_by!( @@ -930,6 +994,26 @@ RSpec.describe 'Posts API', type: :request do expect(names).to include('spec_tag_2') 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 before do Tag.find_undiscard_or_create_by!( diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb index 909ebe3..4914cf4 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -89,6 +89,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do category: 'general', aliases: '', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) @@ -123,6 +124,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do category: 'meme', aliases: '', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) @@ -149,6 +151,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do category: 'general', aliases: 'put_tag_alias_only_alias', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index a688598..26fe9c3 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -76,6 +76,27 @@ RSpec.describe 'Tags API', type: :request do expect(response_tags.first['id']).to eq(meme.id) end + it 'filters tags by deprecated state' do + deprecated_tag = Tag.create!( + name: 'deprecated_filter', + category: :general, + deprecated_at: Time.current + ) + 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 low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), 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(json.map { |x| x['name'] }).not_to include('unknown') 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 describe 'GET /tags/name/:name' do @@ -585,6 +621,26 @@ RSpec.describe 'Tags API', type: :request do expect(row['has_children']).to eq(true) expect(row['children']).to eq([]) 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 end describe 'GET /tags/name/:name/materials' do @@ -732,6 +788,20 @@ RSpec.describe 'Tags API', type: :request do expect(tag.category).to eq('general') 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 old_parent = Tag.create!( tag_name: TagName.create!(name: 'put_old_parent'), @@ -749,6 +819,7 @@ RSpec.describe 'Tags API', type: :request do category: 'meme', aliases: 'put_alias_a put_alias_b put_alias_a', parent_tags: 'put_kept_parent put_new_parent', + deprecated: '0', } expect(response).to have_http_status(:ok) @@ -793,6 +864,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'spec_tag put_alias_self_test', parent_tags: '', + deprecated: '0', } expect(response).to have_http_status(:ok) @@ -810,6 +882,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko', parent_tags: 'spec_tag', + deprecated: '0', } expect(response).to have_http_status(:ok) @@ -825,6 +898,7 @@ RSpec.describe 'Tags API', type: :request do category: 'meta', aliases: '', parent_tags: '', + deprecated: '0', } }.to change(TagVersion, :count).by(2) @@ -860,6 +934,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko', parent_tags: new_parent.name, + deprecated: '0', } expect(response).to have_http_status(:ok) @@ -875,6 +950,7 @@ RSpec.describe 'Tags API', type: :request do category: 'nico', aliases: '', parent_tags: '', + deprecated: '0', } }.not_to change(TagVersion, :count) @@ -896,6 +972,7 @@ RSpec.describe 'Tags API', type: :request do category: 'nico', aliases: '', parent_tags: '', + deprecated: '0', } }.not_to change(NicoTagVersion, :count) @@ -916,6 +993,7 @@ RSpec.describe 'Tags API', type: :request do category: old_category, aliases: '', parent_tags: '', + deprecated: '0', } }.not_to change(TagVersion, :count) @@ -946,6 +1024,7 @@ RSpec.describe 'Tags API', type: :request do category: 'meme', aliases: 'unko', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) @@ -981,6 +1060,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko put_stolen_alias', parent_tags: '', + deprecated: '0', } } .to change { tag.reload.tag_versions.count }.by(2) @@ -1015,6 +1095,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko', parent_tags: child.name, + deprecated: '0', } expect(response).to have_http_status(:unprocessable_entity) @@ -1036,6 +1117,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) diff --git a/backend/spec/tasks/post_similarity_calc_spec.rb b/backend/spec/tasks/post_similarity_calc_spec.rb index 41de663..20dd575 100644 --- a/backend/spec/tasks/post_similarity_calc_spec.rb +++ b/backend/spec/tasks/post_similarity_calc_spec.rb @@ -4,11 +4,12 @@ require 'rails_helper' RSpec.describe 'post_similarity:calc' do include RakeTaskHelper - it 'calls Similarity::Calc with Post and :tags' do + it 'calculates similarities from active tags only' do # 必要最低限のデータ t1 = Tag.create!(name: "t1") t2 = Tag.create!(name: "t2") t3 = Tag.create!(name: "t3") + deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current) p1 = Post.create!(url: "https://example.com/1") 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: p3, tag: t3) + PostTag.create!(post: p1, tag: deprecated_tag) + PostTag.create!(post: p2, tag: deprecated_tag) expect { run_rake_task("post_similarity:calc") } .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_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id) expect(ps_rev.cos).to eq(ps.cos) + expect(ps.cos).to be_within(0.0001).of(0.5) end end - diff --git a/backend/spec/tasks/tag_similarity_calc_spec.rb b/backend/spec/tasks/tag_similarity_calc_spec.rb index 8022231..3063010 100644 --- a/backend/spec/tasks/tag_similarity_calc_spec.rb +++ b/backend/spec/tasks/tag_similarity_calc_spec.rb @@ -4,11 +4,12 @@ require 'rails_helper' RSpec.describe 'tag_similarity:calc' do include RakeTaskHelper - it 'calls Similarity::Calc with Tag and :posts' do + it 'calculates similarities for active tags only' do # 必要最低限のデータ t1 = Tag.create!(name: "t1") t2 = Tag.create!(name: "t2") t3 = Tag.create!(name: "t3") + deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current) p1 = Post.create!(url: "https://example.com/1") 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: p3, tag: t3) + PostTag.create!(post: p1, tag: deprecated_tag) expect { run_rake_task("tag_similarity:calc") } .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_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id) 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 - diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts index dffdbd4..bf8947f 100644 --- a/frontend/src/lib/gekanator.test.ts +++ b/frontend/src/lib/gekanator.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { apiPost } from '@/lib/api' +import { apiGet, apiPost } from '@/lib/api' import { buildGekanatorQuestions, expectedAnswerForQuestion, + fetchGekanatorPosts, + fetchGekanatorQuestions, learnedSemanticSideForPost, questionIdForCondition, restoreGekanatorQuestion, @@ -24,6 +26,7 @@ vi.mock('@/lib/api', () => ({ })) const mockedApiPost = vi.mocked(apiPost) +const mockedApiGet = vi.mocked(apiGet) const post = (overrides: Partial = {}): Post => ({ id: 1, @@ -43,6 +46,24 @@ const post = (overrides: Partial = {}): Post => ({ ...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', () => { it('returns a direct example answer when present', () => { const question: StoredGekanatorQuestion = { @@ -126,6 +147,7 @@ describe('expectedAnswerForQuestion', () => { postCount: 1, createdAt: '2026-06-10T00:00:00.000Z', updatedAt: '2026-06-10T00:00:00.000Z', + deprecatedAt: null, hasWiki: false, hasDeerjikists: false, materialId: null, diff --git a/frontend/src/lib/tags.test.ts b/frontend/src/lib/tags.test.ts index 4f65360..7075997 100644 --- a/frontend/src/lib/tags.test.ts +++ b/frontend/src/lib/tags.test.ts @@ -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 () => { api.apiGet.mockRejectedValueOnce (new Error ('missing')) api.apiGet.mockRejectedValueOnce (new Error ('missing')) diff --git a/frontend/src/pages/tags/TagListPage.test.tsx b/frontend/src/pages/tags/TagListPage.test.tsx index e4eb159..f13f3eb 100644 --- a/frontend/src/pages/tags/TagListPage.test.tsx +++ b/frontend/src/pages/tags/TagListPage.test.tsx @@ -14,13 +14,22 @@ vi.mock ('@/lib/tags', () => tagsApi) describe ('TagListPage', () => { it ('loads tags from URL filters and renders the results table', async () => { 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, }) renderWithProviders ( , - { 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 (() => { @@ -30,6 +39,7 @@ describe ('TagListPage', () => { category: 'character', page: 3, postCountGTE: 5, + deprecated: true, }), ) }) @@ -38,6 +48,8 @@ describe ('TagListPage', () => { '/tags/7', ) 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 () => { @@ -46,7 +58,9 @@ describe ('TagListPage', () => { renderWithProviders (, { route: '/tags' }) 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')!) await waitFor (() => { diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 17e49c8..0cb3927 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -27,5 +27,6 @@ "@/*": ["*"] } }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"] }