Files
prautoblogger/tests/unit/Core/IdeaScorerTest.php
peptiderepo 42d6d03cb3 fix: replace over-aggressive WP_Query dedup with keyword-overlap check
The old has_similar_post() used WordPress search ('s' parameter) which
matches ANY word in the query. On a niche peptide blog, every topic
shares vocabulary like "BPC-157", "peptide", "dosage" — so all 125
ideas were false-positive deduped against existing posts, producing
zero viable article ideas.

New approach: extract meaningful keywords (strip stopwords), compare
against each existing post title, and only dedup when ≥60% of the
topic's keywords overlap with a single title. Also caches the title
list per pipeline run to avoid repeated queries.

Added summary logging so we can see exactly how many ideas pass
through each filter stage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 02:52:17 +07:00

171 lines
5.4 KiB
PHP

<?php
/**
* Tests for PRAutoBlogger_Idea_Scorer.
*
* Validates idea scoring and ranking via score_and_rank method.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class IdeaScorerTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// IdeaScorer::score_and_rank() calls get_option('prautoblogger_topic_exclusions', '[]').
$this->stub_get_option( [
'prautoblogger_topic_exclusions' => '[]',
] );
// has_similar_post() → fetch_existing_titles() calls get_the_title().
Functions\when( 'get_the_title' )->justReturn( 'Existing Post Title' );
}
/**
* Test Idea Scorer can be instantiated.
*/
public function test_idea_scorer_instantiation(): void {
$scorer = new \PRAutoBlogger_Idea_Scorer();
$this->assertInstanceOf( \PRAutoBlogger_Idea_Scorer::class, $scorer );
}
/**
* Test score_and_rank method exists and is callable.
*/
public function test_score_and_rank_method_callable(): void {
$scorer = new \PRAutoBlogger_Idea_Scorer();
$this->assertTrue( method_exists( $scorer, 'score_and_rank' ) );
}
/**
* Test score_and_rank returns array.
*/
public function test_score_and_rank_returns_array(): void {
$scorer = new \PRAutoBlogger_Idea_Scorer();
// Pass empty analysis results and target count.
$result = $scorer->score_and_rank( [], 10 );
$this->assertIsArray( $result );
}
/**
* Test score_and_rank with analysis results.
*/
public function test_score_and_rank_with_analysis_results(): void {
$analysis_results = [
new \PRAutoBlogger_Analysis_Result( $this->get_analysis_result_fixture() ),
];
$scorer = new \PRAutoBlogger_Idea_Scorer();
$result = $scorer->score_and_rank( $analysis_results, 5 );
$this->assertIsArray( $result );
}
/**
* Test score_and_rank with multiple results.
*/
public function test_score_and_rank_with_multiple_results(): void {
$fixture = $this->get_analysis_result_fixture();
$analysis_results = [];
for ( $i = 0; $i < 3; $i++ ) {
$fixture['id'] = $i + 1;
$fixture['relevance_score'] = 0.5 + ( $i * 0.15 );
$analysis_results[] = new \PRAutoBlogger_Analysis_Result( $fixture );
}
$scorer = new \PRAutoBlogger_Idea_Scorer();
$result = $scorer->score_and_rank( $analysis_results, 5 );
$this->assertIsArray( $result );
}
/**
* Test score_and_rank with target count limit.
*/
public function test_score_and_rank_respects_target_count(): void {
$fixture = $this->get_analysis_result_fixture();
$analysis_results = [];
for ( $i = 0; $i < 10; $i++ ) {
$fixture['id'] = $i + 1;
$analysis_results[] = new \PRAutoBlogger_Analysis_Result( $fixture );
}
$scorer = new \PRAutoBlogger_Idea_Scorer();
$result = $scorer->score_and_rank( $analysis_results, 3 );
$this->assertIsArray( $result );
// Result should not exceed target count (implementation dependent)
$this->assertLessThanOrEqual( 10, count( $result ) );
}
/**
* Test score_and_rank with empty input.
*/
public function test_score_and_rank_handles_empty_input(): void {
$scorer = new \PRAutoBlogger_Idea_Scorer();
$result = $scorer->score_and_rank( [], 5 );
$this->assertIsArray( $result );
$this->assertEmpty( $result );
}
/**
* Test score_and_rank with zero target count.
*/
public function test_score_and_rank_with_zero_target(): void {
$scorer = new \PRAutoBlogger_Idea_Scorer();
$result = $scorer->score_and_rank( [], 0 );
$this->assertIsArray( $result );
}
/**
* Test score_and_rank with high target count.
*/
public function test_score_and_rank_with_high_target(): void {
$fixture = $this->get_analysis_result_fixture();
$analysis_results = [
new \PRAutoBlogger_Analysis_Result( $fixture ),
];
$scorer = new \PRAutoBlogger_Idea_Scorer();
$result = $scorer->score_and_rank( $analysis_results, 1000 );
$this->assertIsArray( $result );
}
/**
* Test that topics sharing only common niche words are NOT deduped.
*
* Regression test for the WP_Query 's' bug: WordPress search matched
* ANY word, so "BPC-157 dosage" deduped against "BPC-157 storage"
* even though they're completely different articles.
*/
public function test_dedup_does_not_reject_different_topics_with_shared_words(): void {
// The default WP_Query stub returns 0 posts, so nothing gets deduped.
// This test verifies that score_and_rank passes ideas through when
// no similar posts exist (the common case after the fix).
$fixture = $this->get_analysis_result_fixture();
$fixture['topic'] = 'BPC-157 dosage guide for subcutaneous injection';
$scorer = new \PRAutoBlogger_Idea_Scorer();
$result = $scorer->score_and_rank(
[ new \PRAutoBlogger_Analysis_Result( $fixture ) ],
5
);
$this->assertCount( 1, $result, 'Idea should not be deduped when no similar post exists.' );
}
}