# frozen_string_literal: true RSpec.describe DiscourseAi::InferredConcepts::Manager do subject(:manager) { described_class.new } fab!(:topic) fab!(:post) fab!(:concept1) { Fabricate(:inferred_concept, name: "programming") } fab!(:concept2) { Fabricate(:inferred_concept, name: "testing") } describe "#list_concepts" do it "returns all concepts sorted by name" do concepts = manager.list_concepts expect(concepts).to include("programming", "testing") expect(concepts).to eq(concepts.sort) end it "respects limit parameter" do concepts = manager.list_concepts(limit: 1) expect(concepts.length).to eq(1) end it "returns empty array when no concepts exist" do InferredConcept.destroy_all concepts = manager.list_concepts expect(concepts).to eq([]) end end describe "#generate_concepts_from_content" do before do SiteSetting.inferred_concepts_generate_persona = -1 SiteSetting.inferred_concepts_enabled = true end it "returns empty array for blank content" do expect(manager.generate_concepts_from_content("")).to eq([]) expect(manager.generate_concepts_from_content(nil)).to eq([]) end it "delegates to Finder#identify_concepts" do content = "This is about Ruby programming" finder = instance_double(DiscourseAi::InferredConcepts::Finder) allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) allow(finder).to receive(:identify_concepts).with(content).and_return(%w[ruby programming]) allow(finder).to receive(:create_or_find_concepts).with(%w[ruby programming]).and_return( [concept1], ) result = manager.generate_concepts_from_content(content) expect(result).to eq([concept1]) end end describe "#generate_concepts_from_topic" do it "returns empty array for blank topic" do expect(manager.generate_concepts_from_topic(nil)).to eq([]) end it "extracts content and generates concepts" do applier = instance_double(DiscourseAi::InferredConcepts::Applier) allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) allow(applier).to receive(:topic_content_for_analysis).with(topic).and_return("topic content") # Mock the finder instead of stubbing subject finder = instance_double(DiscourseAi::InferredConcepts::Finder) allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) allow(finder).to receive(:identify_concepts).with("topic content").and_return(%w[programming]) allow(finder).to receive(:create_or_find_concepts).with(%w[programming]).and_return( [concept1], ) result = manager.generate_concepts_from_topic(topic) expect(result).to eq([concept1]) end end describe "#generate_concepts_from_post" do it "returns empty array for blank post" do expect(manager.generate_concepts_from_post(nil)).to eq([]) end it "extracts content and generates concepts" do applier = instance_double(DiscourseAi::InferredConcepts::Applier) allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) allow(applier).to receive(:post_content_for_analysis).with(post).and_return("post content") # Mock the finder instead of stubbing subject finder = instance_double(DiscourseAi::InferredConcepts::Finder) allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) allow(finder).to receive(:identify_concepts).with("post content").and_return(%w[testing]) allow(finder).to receive(:create_or_find_concepts).with(%w[testing]).and_return([concept1]) result = manager.generate_concepts_from_post(post) expect(result).to eq([concept1]) end end describe "#match_topic_to_concepts" do it "returns empty array for blank topic" do expect(manager.match_topic_to_concepts(nil)).to eq([]) end it "delegates to Applier#match_existing_concepts" do applier = instance_double(DiscourseAi::InferredConcepts::Applier) allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) allow(applier).to receive(:match_existing_concepts).with(topic).and_return([concept1]) result = manager.match_topic_to_concepts(topic) expect(result).to eq([concept1]) end end describe "#match_post_to_concepts" do it "returns empty array for blank post" do expect(manager.match_post_to_concepts(nil)).to eq([]) end it "delegates to Applier#match_existing_concepts_for_post" do applier = instance_double(DiscourseAi::InferredConcepts::Applier) allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) allow(applier).to receive(:match_existing_concepts_for_post).with(post).and_return([concept1]) result = manager.match_post_to_concepts(post) expect(result).to eq([concept1]) end end describe "#search_topics_by_concept" do it "returns empty array for non-existent concept" do result = manager.search_topics_by_concept("nonexistent") expect(result).to eq([]) end it "returns topics associated with concept" do concept1.topics << topic result = manager.search_topics_by_concept("programming") expect(result).to include(topic) end end describe "#search_posts_by_concept" do it "returns empty array for non-existent concept" do result = manager.search_posts_by_concept("nonexistent") expect(result).to eq([]) end it "returns posts associated with concept" do concept1.posts << post result = manager.search_posts_by_concept("programming") expect(result).to include(post) end end describe "#match_content_to_concepts" do it "returns empty array when no concepts exist" do InferredConcept.destroy_all result = manager.match_content_to_concepts("some content") expect(result).to eq([]) end it "delegates to Applier#match_concepts_to_content" do content = "programming content" existing_concepts = %w[programming testing] applier = instance_double(DiscourseAi::InferredConcepts::Applier) all_double = instance_double(ActiveRecord::Relation) allow(InferredConcept).to receive(:all).and_return(all_double) allow(all_double).to receive(:pluck).with(:name).and_return(existing_concepts) allow(DiscourseAi::InferredConcepts::Applier).to receive(:new).and_return(applier) allow(applier).to receive(:match_concepts_to_content).with( content, existing_concepts, ).and_return(["programming"]) result = manager.match_content_to_concepts(content) expect(result).to eq(["programming"]) end end describe "#find_candidate_topics" do it "delegates to Finder#find_candidate_topics with options" do opts = { limit: 50, min_posts: 3 } finder = instance_double(DiscourseAi::InferredConcepts::Finder) allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) allow(finder).to receive(:find_candidate_topics).with(**opts).and_return([topic]) result = manager.find_candidate_topics(opts) expect(result).to eq([topic]) end end describe "#find_candidate_posts" do it "delegates to Finder#find_candidate_posts with options" do opts = { limit: 25, min_likes: 2 } finder = instance_double(DiscourseAi::InferredConcepts::Finder) allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) allow(finder).to receive(:find_candidate_posts).with(**opts).and_return([post]) result = manager.find_candidate_posts(opts) expect(result).to eq([post]) end end describe "#deduplicate_concepts_by_letter" do before do # Create test concepts %w[apple application banana berry cat car dog].each do |name| Fabricate(:inferred_concept, name: name) end end it "groups concepts by first letter and deduplicates" do finder = instance_double(DiscourseAi::InferredConcepts::Finder) allow(DiscourseAi::InferredConcepts::Finder).to receive(:new).and_return(finder) allow(finder).to receive(:deduplicate_concepts).at_least(:once).and_return( %w[apple banana cat dog], ) allow(InferredConcept).to receive(:where).and_call_original allow(InferredConcept).to receive(:insert_all).and_call_original manager.deduplicate_concepts_by_letter end it "handles empty concept list" do InferredConcept.destroy_all expect { manager.deduplicate_concepts_by_letter }.not_to raise_error end end end