From f6de272f55a223feb4ec9c9705eeb55236dc9541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Thu, 22 Jan 2026 23:30:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=9E=E4=BC=BC=E5=BA=A6=E7=AE=97?= =?UTF-8?q?=E5=87=BA=E3=83=90=E3=83=83=E3=83=81=E4=BF=AE=E6=AD=A3=EF=BC=8C?= =?UTF-8?q?=E3=81=BB=E3=81=8B=EF=BC=88#228=EF=BC=89=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #228 #228 #228 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/232 --- backend/app/controllers/tags_controller.rb | 14 +- backend/app/models/post.rb | 23 +-- backend/app/models/post_similarity.rb | 6 +- backend/app/models/tag_similarity.rb | 6 +- backend/app/services/similarity/calc.rb | 106 ++++++++++++++ ...hange_post_similarities_to_composite_pk.rb | 43 ++++++ ...change_tag_similarities_to_composite_pk.rb | 43 ++++++ ...21213900_add_index_to_post_similarities.rb | 9 ++ ...121225600_add_index_to_tag_similarities.rb | 9 ++ backend/db/schema.rb | 10 +- backend/lib/tasks/calc_post_similarities.rake | 24 +--- backend/lib/tasks/calc_tag_similarities.rake | 24 +--- backend/spec/factories/users.rb | 4 + backend/spec/requests/tag_children_spec.rb | 134 ++++++++++++++++++ backend/spec/requests/tags_spec.rb | 99 +++++++++++++ backend/spec/support/test_records.rb | 7 + .../spec/tasks/post_similarity_calc_spec.rb | 34 +++++ .../spec/tasks/tag_similarity_calc_spec.rb | 34 +++++ 18 files changed, 553 insertions(+), 76 deletions(-) create mode 100644 backend/app/services/similarity/calc.rb create mode 100644 backend/db/migrate/20260121024000_change_post_similarities_to_composite_pk.rb create mode 100644 backend/db/migrate/20260121024800_change_tag_similarities_to_composite_pk.rb create mode 100644 backend/db/migrate/20260121213900_add_index_to_post_similarities.rb create mode 100644 backend/db/migrate/20260121225600_add_index_to_tag_similarities.rb create mode 100644 backend/spec/requests/tag_children_spec.rb create mode 100644 backend/spec/tasks/post_similarity_calc_spec.rb create mode 100644 backend/spec/tasks/tag_similarity_calc_spec.rb diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index f80848d..475f84e 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -49,13 +49,19 @@ class TagsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.member? + name = params[:name].presence + category = params[:category].presence + tag = Tag.find(params[:id]) - attrs = { name: params[:name].presence, - category: params[:category].presence }.compact + if name.present? + tag.tag_name.update!(name:) + end - tag.update!(attrs) if attrs.present? + if category.present? + tag.update!(category:) + end - render json: tag + render json: tag.as_json(methods: [:name]) end end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 24af551..1bd0723 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -9,12 +9,7 @@ class Post < ApplicationRecord has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' has_many :tags, through: :active_post_tags has_many :user_post_views, dependent: :destroy - has_many :post_similarities_as_post, - class_name: 'PostSimilarity', - foreign_key: :post_id - has_many :post_similarities_as_target_post, - class_name: 'PostSimilarity', - foreign_key: :target_post_id + has_many :post_similarities has_one_attached :thumbnail before_validation :normalise_url @@ -34,18 +29,12 @@ class Post < ApplicationRecord end def related(limit: nil) - ids_with_cos = - post_similarities_as_post.select(:target_post_id, :cos) - .map { |ps| [ps.target_post_id, ps.cos] } + - post_similarities_as_target_post.select(:post_id, :cos) - .map { |ps| [ps.post_id, ps.cos] } + ids = post_similarities.select(:target_post_id).order(cos: :desc) + ids = ids.limit(limit) if limit + ids = ids.pluck(:target_post_id) + return [] if ids.empty? - sorted = ids_with_cos.sort_by { |_, cos| -cos } - - ids = sorted.map(&:first) - ids = ids.first(limit) if limit - - Post.where(id: ids).index_by(&:id).values_at(*ids) + Post.where(id: ids).order(Arel.sql("FIELD(id, #{ ids.join(',') })")) end def resized_thumbnail! diff --git a/backend/app/models/post_similarity.rb b/backend/app/models/post_similarity.rb index a753518..c549b5e 100644 --- a/backend/app/models/post_similarity.rb +++ b/backend/app/models/post_similarity.rb @@ -1,4 +1,6 @@ class PostSimilarity < ApplicationRecord - belongs_to :post, class_name: 'Post', foreign_key: 'post_id' - belongs_to :target_post, class_name: 'Post', foreign_key: 'target_post_id' + self.primary_key = :post_id, :target_post_id + + belongs_to :post + belongs_to :target_post, class_name: 'Post' end diff --git a/backend/app/models/tag_similarity.rb b/backend/app/models/tag_similarity.rb index d983409..dd66c1e 100644 --- a/backend/app/models/tag_similarity.rb +++ b/backend/app/models/tag_similarity.rb @@ -1,4 +1,6 @@ class TagSimilarity < ApplicationRecord - belongs_to :tag, class_name: 'Tag', foreign_key: 'tag_id' - belongs_to :target_tag, class_name: 'Tag', foreign_key: 'target_tag_id' + self.primary_key = :tag_id, :target_tag_id + + belongs_to :tag + belongs_to :target_tag, class_name: 'Tag' end diff --git a/backend/app/services/similarity/calc.rb b/backend/app/services/similarity/calc.rb new file mode 100644 index 0000000..40d717d --- /dev/null +++ b/backend/app/services/similarity/calc.rb @@ -0,0 +1,106 @@ +module Similarity + class Calc + def self.call model, tgt + similarity_model = "#{ model.name }Similarity".constantize + + # 最大保存件数 + n = 20 + + similarity_model.delete_all + + posts = model.includes(tgt).select(:id).to_a + + tag_ids = { } + tag_cnts = { } + + posts.each do |p| + arr = p.public_send(tgt).map(&:id).sort + tag_ids[p.id] = arr + tag_cnts[p.id] = arr.size + end + + intersection_size = -> a, b do + i = 0 + j = 0 + cnt = 0 + while i < a.size && j < b.size + a_i = a[i] + b_j = b[j] + if a_i == b_j + cnt += 1 + i += 1 + j += 1 + elsif a_i < b_j + i += 1 + else + j += 1 + end + end + cnt + end + + push_topk = -> list, cos, target_id do + return if list.size >= n && cos <= list[-1][0] + + idx = nil + list.each_with_index do |(c, tid), i| + if tid == target_id + idx = i + break + end + end + if idx + return if cos <= list[idx][0] + list.delete_at(idx) + end + + insert_at = list.size + list.each_with_index do |(c, _), i| + if cos > c + insert_at = i + break + end + end + list.insert(insert_at, [cos, target_id]) + list.pop if list.size > n + end + + top = Hash.new { |h, key| h[key] = [] } + + ids = posts.map(&:id) + ids.each_with_index do |post_id, i| + a = tag_ids[post_id] + a_cnt = tag_cnts[post_id] + + ((i + 1)...ids.size).each do |j| + target_id = ids[j] + b = tag_ids[target_id] + b_cnt = tag_cnts[target_id] + + norm = Math.sqrt(a_cnt * b_cnt) + cos = norm.zero? ? 0.0 : intersection_size.(a, b).fdiv(norm) + + push_topk.(top[post_id], cos, target_id) + push_topk.(top[target_id], cos, post_id) + end + end + + buf = [] + flush = -> do + return if buf.empty? + similarity_model.insert_all!(buf) + buf.clear + end + + top.each do |post_id, list| + list.each do |cos, target_post_id| + buf << { "#{ model.name.underscore }_id".to_sym => post_id, + "target_#{ model.name.underscore }_id".to_sym => target_post_id, + cos: } + flush.call if buf.size >= 1_000 + end + end + flush.call + end + end +end diff --git a/backend/db/migrate/20260121024000_change_post_similarities_to_composite_pk.rb b/backend/db/migrate/20260121024000_change_post_similarities_to_composite_pk.rb new file mode 100644 index 0000000..1b86e30 --- /dev/null +++ b/backend/db/migrate/20260121024000_change_post_similarities_to_composite_pk.rb @@ -0,0 +1,43 @@ +class ChangePostSimilaritiesToCompositePk < ActiveRecord::Migration[8.0] + def up + execute <<~SQL + ALTER TABLE + post_similarities + MODIFY COLUMN id BIGINT NOT NULL + ; + SQL + + execute <<~SQL + ALTER TABLE + post_similarities + DROP PRIMARY KEY + ; + SQL + + remove_column :post_similarities, :id + + execute <<~SQL + ALTER TABLE + post_similarities + ADD PRIMARY KEY (post_id, target_post_id) + ; + SQL + end + + def down + execute <<~SQL + ALTER TABLE + post_similarities + DROP PRIMARY KEY + ; + SQL + + execute <<~SQL + ALTER TABLE + post_similarities + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT FIRST + , ADD PRIMARY KEY (id) + ; + SQL + end +end diff --git a/backend/db/migrate/20260121024800_change_tag_similarities_to_composite_pk.rb b/backend/db/migrate/20260121024800_change_tag_similarities_to_composite_pk.rb new file mode 100644 index 0000000..7a8bfc0 --- /dev/null +++ b/backend/db/migrate/20260121024800_change_tag_similarities_to_composite_pk.rb @@ -0,0 +1,43 @@ +class ChangeTagSimilaritiesToCompositePk < ActiveRecord::Migration[8.0] + def up + execute <<~SQL + ALTER TABLE + tag_similarities + MODIFY COLUMN id BIGINT NOT NULL + ; + SQL + + execute <<~SQL + ALTER TABLE + tag_similarities + DROP PRIMARY KEY + ; + SQL + + remove_column :tag_similarities, :id + + execute <<~SQL + ALTER TABLE + tag_similarities + ADD PRIMARY KEY (tag_id, target_tag_id) + ; + SQL + end + + def down + execute <<~SQL + ALTER TABLE + tag_similarities + DROP PRIMARY KEY + ; + SQL + + execute <<~SQL + ALTER TABLE + tag_similarities + ADD COLUMN id BIGINT NOT NULL AUTO_INCREMENT FIRST + , ADD PRIMARY KEY (id) + ; + SQL + end +end diff --git a/backend/db/migrate/20260121213900_add_index_to_post_similarities.rb b/backend/db/migrate/20260121213900_add_index_to_post_similarities.rb new file mode 100644 index 0000000..52a6ee1 --- /dev/null +++ b/backend/db/migrate/20260121213900_add_index_to_post_similarities.rb @@ -0,0 +1,9 @@ +class AddIndexToPostSimilarities < ActiveRecord::Migration[8.0] + def change + remove_index :post_similarities, name: 'index_post_similarities_on_post_id' + + add_index :post_similarities, [:post_id, :cos], + order: { cos: :desc }, + name: 'index_post_similarities_on_post_id_and_cos' + end +end diff --git a/backend/db/migrate/20260121225600_add_index_to_tag_similarities.rb b/backend/db/migrate/20260121225600_add_index_to_tag_similarities.rb new file mode 100644 index 0000000..c820f15 --- /dev/null +++ b/backend/db/migrate/20260121225600_add_index_to_tag_similarities.rb @@ -0,0 +1,9 @@ +class AddIndexToTagSimilarities < ActiveRecord::Migration[8.0] + def change + remove_index :tag_similarities, name: 'index_tag_similarities_on_tag_id' + + add_index :tag_similarities, [:tag_id, :cos], + order: { cos: :desc }, + name: 'index_tag_similarities_on_tag_id_and_cos' + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index a67d0dd..c6fc0ed 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_01_18_144400) do +ActiveRecord::Schema[8.0].define(version: 2026_01_21_225600) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -55,11 +55,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_18_144400) do t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id" end - create_table "post_similarities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "target_post_id", null: false t.float "cos", null: false - t.index ["post_id"], name: "index_post_similarities_on_post_id" + t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc } t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end @@ -126,11 +126,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_18_144400) do t.index ["name"], name: "index_tag_names_on_name", unique: true end - create_table "tag_similarities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_id", null: false t.bigint "target_tag_id", null: false t.float "cos", null: false - t.index ["tag_id"], name: "index_tag_similarities_on_tag_id" + t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc } t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" end diff --git a/backend/lib/tasks/calc_post_similarities.rake b/backend/lib/tasks/calc_post_similarities.rake index 97e4fd2..925770f 100644 --- a/backend/lib/tasks/calc_post_similarities.rake +++ b/backend/lib/tasks/calc_post_similarities.rake @@ -1,28 +1,6 @@ namespace :post_similarity do desc '関聯投稿テーブル作成' task calc: :environment do - dot = -> a, b { (a.keys & b.keys).sum { |k| a[k] * b[k] } } - norm = -> v { Math.sqrt(v.values.sum { |e| e * e }) } - cos = -> a, b do - na = norm.(a) - nb = norm.(b) - if na.zero? || nb.zero? - 0.0 - else - dot.(a, b) / na / nb - end - end - - posts = Post.includes(:tags).to_a - posts.each_with_index do |post, i| - existence_of_tags = post.tags.index_with(1) - ((i + 1)...posts.size).each do |j| - target_post = posts[j] - existence_of_target_tags = target_post.tags.index_with(1) - PostSimilarity.find_or_initialize_by(post:, target_post:).tap { |ps| - ps.cos = cos.(existence_of_tags, existence_of_target_tags) - }.save! - end - end + Similarity::Calc.call(Post, :tags) end end diff --git a/backend/lib/tasks/calc_tag_similarities.rake b/backend/lib/tasks/calc_tag_similarities.rake index 9e16977..0fc718a 100644 --- a/backend/lib/tasks/calc_tag_similarities.rake +++ b/backend/lib/tasks/calc_tag_similarities.rake @@ -1,28 +1,6 @@ namespace :tag_similarity do desc '関聯タグ・テーブル作成' task calc: :environment do - dot = -> a, b { (a.keys & b.keys).sum { |k| a[k] * b[k] } } - norm = -> v { Math.sqrt(v.values.sum { |e| e * e }) } - cos = -> a, b do - na = norm.(a) - nb = norm.(b) - if na.zero? || nb.zero? - 0.0 - else - dot.(a, b) / na / nb - end - end - - tags = Tag.includes(:posts).to_a - tags.each_with_index do |tag, i| - existence_of_posts = tag.posts.index_with(1) - ((i + 1)...tags.size).each do |j| - target_tag = tags[j] - existence_of_target_posts = target_tag.posts.index_with(1) - TagSimilarity.find_or_initialize_by(tag:, target_tag:).tap { |ts| - ts.cos = cos.(existence_of_posts, existence_of_target_posts) - }.save! - end - end + Similarity::Calc.call(Tag, :posts) end end diff --git a/backend/spec/factories/users.rb b/backend/spec/factories/users.rb index 18548b6..f7db70a 100644 --- a/backend/spec/factories/users.rb +++ b/backend/spec/factories/users.rb @@ -7,5 +7,9 @@ FactoryBot.define do trait :member do role { "member" } end + + trait :admin do + role { 'admin' } + end end end diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb new file mode 100644 index 0000000..9db9beb --- /dev/null +++ b/backend/spec/requests/tag_children_spec.rb @@ -0,0 +1,134 @@ +# spec/requests/tag_children_spec.rb +require "rails_helper" + +RSpec.describe "TagChildren", type: :request do + let!(:parent) { create(:tag) } + let!(:child) { create(:tag) } + + # ここは君のUser factoryに合わせて調整 + let(:user) { create_member_user! } + let(:admin) { create_admin_user! } + + # current_user を ApplicationController でスタブ + def stub_current_user(user_or_nil) + allow_any_instance_of(ApplicationController) + .to receive(:current_user) + .and_return(user_or_nil) + end + + describe "POST /tag_children" do + subject(:do_request) do + post "/tags/#{ parent_id }/children/#{ child_id }" + end + + context "when not logged in" do + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 401" do + stub_current_user(nil) + do_request + expect(response).to have_http_status(:unauthorized) + end + end + + context "when logged in but not admin" do + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 403" do + stub_current_user(user) + do_request + expect(response).to have_http_status(:forbidden) + end + end + + context "when admin and params are present" do + before { stub_current_user(admin) } + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 204 and adds child to parent.children" do + expect(parent.children).not_to include(child) + + expect { do_request } + .to change { parent.reload.children.ids.include?(child.id) } + .from(false).to(true) + + expect(response).to have_http_status(:no_content) + end + end + + context "when Tag.find raises (invalid ids) it still returns 204" do + before { stub_current_user(admin) } + + let(:parent_id) { -1 } + let(:child_id) { -1 } + + it "returns 204 (rescue nil)" do + do_request + expect(response).to have_http_status(:no_content) + end + end + end + + describe "DELETE /tag_children" do + subject(:do_request) do + delete "/tags/#{ parent_id }/children/#{ child_id }" + end + + context "when not logged in" do + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 401" do + stub_current_user(nil) + do_request + expect(response).to have_http_status(:unauthorized) + end + end + + context "when logged in but not admin" do + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 403" do + stub_current_user(user) + do_request + expect(response).to have_http_status(:forbidden) + end + end + + context "when admin and params are present" do + before do + stub_current_user(admin) + parent.children << child + end + + let(:parent_id) { parent.id } + let(:child_id) { child.id } + + it "returns 204 and removes child from parent.children" do + expect(parent.reload.children).to include(child) + + expect { do_request } + .to change { parent.reload.children.ids.include?(child.id) } + .from(true).to(false) + + expect(response).to have_http_status(:no_content) + end + end + + context "when Tag.find raises (invalid ids) it still returns 204" do + before { stub_current_user(admin) } + + let(:parent_id) { -1 } + let(:child_id) { -1 } + + it "returns 204 (rescue nil)" do + do_request + expect(response).to have_http_status(:no_content) + end + end + end +end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 7e26538..11cce46 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -76,4 +76,103 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:not_found) end end + + # member? を持つ user を想定(Factory 側で trait 作ってもOK) + let(:member_user) { create(:user) } + let(:non_member_user) { create(:user) } + + def stub_current_user(user) + allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user) + end + + before do + allow(member_user).to receive(:member?).and_return(true) + allow(non_member_user).to receive(:member?).and_return(false) + end + + describe "PATCH /tags/:id" do + context "未ログイン" do + before { stub_current_user(nil) } + + it "401 を返す" do + patch "/tags/#{tag.id}", params: { name: "new" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "ログインしてゐるが member でない" do + before { stub_current_user(non_member_user) } + + it "403 を返す" do + patch "/tags/#{tag.id}", params: { name: "new" } + expect(response).to have_http_status(:forbidden) + end + end + + context "member" do + before { stub_current_user(member_user) } + + it "name だけ更新できる" do + patch "/tags/#{tag.id}", params: { name: "new" } + + expect(response).to have_http_status(:ok) + + tag.reload + expect(tag.name).to eq("new") + expect(tag.category).to eq("general") + + json = JSON.parse(response.body) + expect(json["id"]).to eq(tag.id) + expect(json["name"]).to eq("new") + expect(json["category"]).to eq("general") + end + + it "category だけ更新できる" do + patch "/tags/#{tag.id}", params: { category: "meme" } + + expect(response).to have_http_status(:ok) + + tag.reload + expect(tag.name).to eq("spec_tag") + expect(tag.category).to eq("meme") + end + + it "空文字は presence により無視され、更新は走らない(値が変わらない)" do + patch "/tags/#{tag.id}", params: { name: "", category: " " } + + expect(response).to have_http_status(:ok) + + tag.reload + expect(tag.name).to eq("spec_tag") + expect(tag.category).to eq("general") + end + + it "両方更新できる" do + patch "/tags/#{tag.id}", params: { name: "n", category: "meta" } + + expect(response).to have_http_status(:ok) + + tag.reload + expect(tag.name).to eq("n") + expect(tag.category).to eq("meta") + end + + it "存在しない id だと RecordNotFound になる(通常は 404)" do + # Rails 設定次第で例外がそのまま上がる/404になる + # APIなら rescue_from で 404 にしてることが多いので、その場合は 404 を期待。 + patch "/tags/999999999", params: { name: "x" } + + expect(response.status).to be_in([404, 500]) + end + + it "バリデーションで update! が失敗したら(通常は 422 か 500)" do + patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' } + + # rescue_from の実装次第で変はる: + # - RecordInvalid を 422 にしてるなら 422 + # - 未処理なら 500 + expect(response.status).to be_in([422, 500]) + end + end + end end diff --git a/backend/spec/support/test_records.rb b/backend/spec/support/test_records.rb index e85b65b..350b8da 100644 --- a/backend/spec/support/test_records.rb +++ b/backend/spec/support/test_records.rb @@ -5,4 +5,11 @@ module TestRecords role: 'member', banned: false) end + + def create_admin_user! + User.create!(name: 'spec admin', + inheritance_code: SecureRandom.hex(16), + role: 'admin', + banned: false) + end end diff --git a/backend/spec/tasks/post_similarity_calc_spec.rb b/backend/spec/tasks/post_similarity_calc_spec.rb new file mode 100644 index 0000000..41de663 --- /dev/null +++ b/backend/spec/tasks/post_similarity_calc_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + + +RSpec.describe 'post_similarity:calc' do + include RakeTaskHelper + + it 'calls Similarity::Calc with Post and :tags' do + # 必要最低限のデータ + t1 = Tag.create!(name: "t1") + t2 = Tag.create!(name: "t2") + t3 = Tag.create!(name: "t3") + + p1 = Post.create!(url: "https://example.com/1") + p2 = Post.create!(url: "https://example.com/2") + p3 = Post.create!(url: "https://example.com/3") + + # kept スコープが絡むなら、PostTag がデフォで kept になる前提 + PostTag.create!(post: p1, tag: t1) + PostTag.create!(post: p1, tag: t2) + + PostTag.create!(post: p2, tag: t1) + PostTag.create!(post: p2, tag: t3) + + PostTag.create!(post: p3, tag: t3) + + expect { run_rake_task("post_similarity:calc") } + .to change { PostSimilarity.count }.from(0) + + 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) + end +end + diff --git a/backend/spec/tasks/tag_similarity_calc_spec.rb b/backend/spec/tasks/tag_similarity_calc_spec.rb new file mode 100644 index 0000000..8022231 --- /dev/null +++ b/backend/spec/tasks/tag_similarity_calc_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + + +RSpec.describe 'tag_similarity:calc' do + include RakeTaskHelper + + it 'calls Similarity::Calc with Tag and :posts' do + # 必要最低限のデータ + t1 = Tag.create!(name: "t1") + t2 = Tag.create!(name: "t2") + t3 = Tag.create!(name: "t3") + + p1 = Post.create!(url: "https://example.com/1") + p2 = Post.create!(url: "https://example.com/2") + p3 = Post.create!(url: "https://example.com/3") + + # kept スコープが絡むなら、PostTag がデフォで kept になる前提 + PostTag.create!(post: p1, tag: t1) + PostTag.create!(post: p1, tag: t2) + + PostTag.create!(post: p2, tag: t1) + PostTag.create!(post: p2, tag: t3) + + PostTag.create!(post: p3, tag: t3) + + expect { run_rake_task("tag_similarity:calc") } + .to change { TagSimilarity.count }.from(0) + + 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) + end +end +