feat: add browsable peptide directory with category filtering, REST API, and extended meta fields (v4.3.0)
Adds [peptide_directory] shortcode with card grid, category taxonomy (8 terms), search, pagination, detail modal, and /v1/compounds REST endpoint. Includes 9 new meta fields (half-life, stability, solubility, etc.), admin data tools for migration/re-enrichment, integration hooks for cross-plugin extensibility, dark mode support, and full uninstall cleanup. Refactors AI generator into PSA_OpenRouter + PSA_KB_Builder classes. Adds PHPUnit tests for PostType and Directory classes. Fixes QA findings: dead JS selector, renderError loading state, numeric meta validation, prepared SQL in uninstall. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,21 @@ if ( ! function_exists( 'wp_kses_post' ) ) {
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'wp_parse_args' ) ) {
|
||||
function wp_parse_args( $args, $defaults = array() ) {
|
||||
if ( is_array( $args ) ) {
|
||||
return array_merge( $defaults, $args );
|
||||
}
|
||||
return $defaults;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'esc_html' ) ) {
|
||||
function esc_html( $text ) {
|
||||
return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
|
||||
}
|
||||
}
|
||||
|
||||
// Define WP_Error class if it doesn't exist.
|
||||
if ( ! class_exists( 'WP_Error' ) ) {
|
||||
class WP_Error {
|
||||
@@ -111,4 +126,7 @@ if ( ! class_exists( 'WP_Error' ) ) {
|
||||
|
||||
// Require the class files under test.
|
||||
require_once ABSPATH . 'includes/class-psa-config.php';
|
||||
require_once ABSPATH . 'includes/class-psa-cost-tracker.php';
|
||||
require_once ABSPATH . 'includes/class-psa-openrouter.php';
|
||||
require_once ABSPATH . 'includes/class-psa-kb-builder.php';
|
||||
require_once ABSPATH . 'includes/class-psa-ai-generator.php';
|
||||
|
||||
@@ -97,16 +97,13 @@ class AIGeneratorTest extends TestCase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parse_ai_response with valid JSON string.
|
||||
* Test parse_response with valid JSON string.
|
||||
* Note: parse_ai_response was extracted to PSA_OpenRouter::parse_response() in v4.2.0.
|
||||
*/
|
||||
public function test_parse_ai_response_valid_json() {
|
||||
public function test_parse_response_valid_json() {
|
||||
$response_text = '{"is_valid": true, "canonical_name": "BPC-157", "reason": "A known peptide"}';
|
||||
|
||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, $response_text );
|
||||
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||
|
||||
$this->assertIsArray( $result );
|
||||
$this->assertArrayHasKey( 'is_valid', $result );
|
||||
@@ -115,20 +112,16 @@ class AIGeneratorTest extends TestCase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parse_ai_response with JSON wrapped in markdown fences.
|
||||
* Test parse_response with JSON wrapped in markdown fences.
|
||||
*/
|
||||
public function test_parse_ai_response_with_markdown_fences() {
|
||||
public function test_parse_response_with_markdown_fences() {
|
||||
$response_text = <<<JSON
|
||||
\`\`\`json
|
||||
{"is_valid": true, "canonical_name": "Semaglutide", "reason": "GLP-1 agonist"}
|
||||
\`\`\`
|
||||
JSON;
|
||||
|
||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, $response_text );
|
||||
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||
|
||||
$this->assertIsArray( $result );
|
||||
$this->assertTrue( $result['is_valid'] );
|
||||
@@ -136,9 +129,9 @@ JSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parse_ai_response with <think> tags (DeepSeek model).
|
||||
* Test parse_response with <think> tags (DeepSeek model).
|
||||
*/
|
||||
public function test_parse_ai_response_with_think_tags() {
|
||||
public function test_parse_response_with_think_tags() {
|
||||
$response_text = <<<JSON
|
||||
<think>
|
||||
This is a known peptide from various sources.
|
||||
@@ -146,11 +139,7 @@ This is a known peptide from various sources.
|
||||
{"is_valid": true, "canonical_name": "LL-37", "reason": "Antimicrobial peptide"}
|
||||
JSON;
|
||||
|
||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, $response_text );
|
||||
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||
|
||||
$this->assertIsArray( $result );
|
||||
$this->assertTrue( $result['is_valid'] );
|
||||
@@ -158,32 +147,24 @@ JSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parse_ai_response with invalid JSON returns WP_Error.
|
||||
* Test parse_response with invalid JSON returns WP_Error.
|
||||
*/
|
||||
public function test_parse_ai_response_invalid_json() {
|
||||
public function test_parse_response_invalid_json() {
|
||||
$response_text = '{not valid json}';
|
||||
|
||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, $response_text );
|
||||
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||
|
||||
$this->assertTrue( is_wp_error( $result ) );
|
||||
$this->assertEquals( 'parse_error', $result->get_error_code() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test parse_ai_response with empty string returns WP_Error.
|
||||
* Test parse_response with empty string returns WP_Error.
|
||||
*/
|
||||
public function test_parse_ai_response_empty_string() {
|
||||
public function test_parse_response_empty_string() {
|
||||
$response_text = '';
|
||||
|
||||
$reflection = new ReflectionClass( 'PSA_AI_Generator' );
|
||||
$method = $reflection->getMethod( 'parse_ai_response' );
|
||||
$method->setAccessible( true );
|
||||
|
||||
$result = $method->invoke( null, $response_text );
|
||||
$result = PSA_OpenRouter::parse_response( $response_text );
|
||||
|
||||
$this->assertTrue( is_wp_error( $result ) );
|
||||
$this->assertEquals( 'parse_error', $result->get_error_code() );
|
||||
|
||||
136
tests/unit/DirectoryTest.php
Executable file
136
tests/unit/DirectoryTest.php
Executable file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PSA_Directory class.
|
||||
*
|
||||
* Covers: constants, format_compound() output structure.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
declare( strict_types=1 );
|
||||
|
||||
// Stub WordPress functions needed by PSA_Directory.
|
||||
if ( ! function_exists( 'add_shortcode' ) ) {
|
||||
function add_shortcode() {}
|
||||
}
|
||||
if ( ! function_exists( 'add_action' ) ) {
|
||||
function add_action() {}
|
||||
}
|
||||
if ( ! function_exists( 'register_rest_route' ) ) {
|
||||
function register_rest_route() {}
|
||||
}
|
||||
if ( ! function_exists( 'get_permalink' ) ) {
|
||||
function get_permalink( $id ) {
|
||||
return 'https://example.com/peptides/test-peptide/';
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'wp_get_object_terms' ) ) {
|
||||
function wp_get_object_terms( $id, $taxonomy, $args = array() ) {
|
||||
return array();
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'get_post_meta' ) ) {
|
||||
function get_post_meta( $post_id, $key = '', $single = false ) {
|
||||
$meta = array(
|
||||
'psa_half_life' => '4-6 hours',
|
||||
'psa_stability' => '28 days at 2-8°C',
|
||||
'psa_source' => 'ai-generated',
|
||||
'psa_sequence' => 'Gly-Glu-Pro',
|
||||
'psa_molecular_weight' => '1419.53 Da',
|
||||
'psa_molecular_formula' => 'C62H98N16O22',
|
||||
'psa_aliases' => 'BPC-157, Pentadecapeptide',
|
||||
'psa_mechanism' => 'Test mechanism',
|
||||
'psa_research_apps' => 'Test research',
|
||||
'psa_safety_profile' => 'Test safety',
|
||||
'psa_dosage_info' => 'Test dosage',
|
||||
'psa_references' => 'Test refs',
|
||||
'psa_pubchem_cid' => '12345',
|
||||
'psa_solubility' => 'Bacteriostatic Water',
|
||||
'psa_vial_size_mg' => '5',
|
||||
'psa_storage_lyophilized' => '-20°C',
|
||||
'psa_storage_reconstituted' => '2-8°C',
|
||||
'psa_typical_dose_mcg' => '200-300 mcg',
|
||||
'psa_cycle_parameters' => '4-6 weeks',
|
||||
'psa_amino_acid_count' => '15',
|
||||
);
|
||||
return $meta[ $key ] ?? '';
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'wp_trim_words' ) ) {
|
||||
function wp_trim_words( $text, $num_words = 55 ) {
|
||||
$words = explode( ' ', $text );
|
||||
return implode( ' ', array_slice( $words, 0, $num_words ) );
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( '__' ) ) {
|
||||
function __( $text, $domain = 'default' ) {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
require_once dirname( __DIR__ ) . '/bootstrap.php';
|
||||
require_once ABSPATH . 'includes/class-psa-post-type.php';
|
||||
require_once ABSPATH . 'includes/class-psa-directory.php';
|
||||
|
||||
class DirectoryTest extends \PHPUnit\Framework\TestCase {
|
||||
|
||||
/**
|
||||
* Verify directory pagination constants.
|
||||
*/
|
||||
public function test_per_page_constants(): void {
|
||||
$this->assertSame( 12, PSA_Directory::PER_PAGE_DEFAULT );
|
||||
$this->assertSame( 100, PSA_Directory::PER_PAGE_MAX );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify format_compound() returns expected keys for full field set.
|
||||
*/
|
||||
public function test_format_compound_full_fields(): void {
|
||||
$mock_post = new \stdClass();
|
||||
$mock_post->ID = 42;
|
||||
$mock_post->post_title = 'BPC-157';
|
||||
$mock_post->post_name = 'bpc-157';
|
||||
$mock_post->post_content = 'A synthetic peptide for research purposes.';
|
||||
|
||||
$result = PSA_Directory::format_compound( $mock_post, 'full' );
|
||||
|
||||
// Check required top-level keys.
|
||||
$required_keys = array(
|
||||
'id', 'name', 'slug', 'url', 'categories', 'half_life', 'stability',
|
||||
'source', 'extras', 'excerpt', 'description', 'sequence',
|
||||
'molecular_weight', 'molecular_formula', 'aliases', 'mechanism',
|
||||
'pubchem_cid', 'solubility', 'vial_size_mg', 'storage_lyophilized',
|
||||
'storage_reconstituted', 'typical_dose_mcg', 'cycle_parameters',
|
||||
'amino_acid_count',
|
||||
);
|
||||
|
||||
foreach ( $required_keys as $key ) {
|
||||
$this->assertArrayHasKey( $key, $result, "Missing key in full compound: {$key}" );
|
||||
}
|
||||
|
||||
$this->assertSame( 42, $result['id'] );
|
||||
$this->assertSame( 'BPC-157', $result['name'] );
|
||||
$this->assertSame( 'bpc-157', $result['slug'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify format_compound() with 'basic' fields returns lightweight data.
|
||||
*/
|
||||
public function test_format_compound_basic_fields(): void {
|
||||
$mock_post = new \stdClass();
|
||||
$mock_post->ID = 42;
|
||||
$mock_post->post_title = 'BPC-157';
|
||||
$mock_post->post_name = 'bpc-157';
|
||||
$mock_post->post_content = 'A synthetic peptide.';
|
||||
|
||||
$result = PSA_Directory::format_compound( $mock_post, 'basic' );
|
||||
|
||||
// Basic should NOT include detail fields.
|
||||
$this->assertArrayHasKey( 'id', $result );
|
||||
$this->assertArrayHasKey( 'name', $result );
|
||||
$this->assertArrayHasKey( 'vial_size_mg', $result );
|
||||
$this->assertArrayNotHasKey( 'sequence', $result );
|
||||
$this->assertArrayNotHasKey( 'mechanism', $result );
|
||||
$this->assertArrayNotHasKey( 'description', $result );
|
||||
}
|
||||
}
|
||||
144
tests/unit/PostTypeTest.php
Executable file
144
tests/unit/PostTypeTest.php
Executable file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PSA_Post_Type class.
|
||||
*
|
||||
* Covers: taxonomy constants, default categories list, meta field constants,
|
||||
* extended meta fields, and get_all_meta_keys() helper.
|
||||
*
|
||||
* @package PeptideSearchAI
|
||||
*/
|
||||
|
||||
declare( strict_types=1 );
|
||||
|
||||
// Stub WordPress functions needed by PSA_Post_Type but not by the test bootstrap.
|
||||
if ( ! function_exists( 'post_type_exists' ) ) {
|
||||
function post_type_exists( $post_type ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'register_post_type' ) ) {
|
||||
function register_post_type( $post_type, $args = array() ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'taxonomy_exists' ) ) {
|
||||
function taxonomy_exists( $taxonomy ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'register_taxonomy' ) ) {
|
||||
function register_taxonomy( $taxonomy, $object_type, $args = array() ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( '__' ) ) {
|
||||
function __( $text, $domain = 'default' ) {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
if ( ! function_exists( 'add_action' ) ) {
|
||||
function add_action() {}
|
||||
}
|
||||
if ( ! function_exists( 'add_filter' ) ) {
|
||||
function add_filter() {}
|
||||
}
|
||||
|
||||
require_once dirname( __DIR__ ) . '/bootstrap.php';
|
||||
require_once ABSPATH . 'includes/class-psa-post-type.php';
|
||||
|
||||
class PostTypeTest extends \PHPUnit\Framework\TestCase {
|
||||
|
||||
/**
|
||||
* Verify default categories constant contains exactly 8 entries.
|
||||
*/
|
||||
public function test_default_categories_count(): void {
|
||||
$this->assertCount( 8, PSA_Post_Type::DEFAULT_CATEGORIES );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify required category slugs exist in the constant.
|
||||
*/
|
||||
public function test_default_categories_slugs(): void {
|
||||
$required = array(
|
||||
'tissue-repair',
|
||||
'lipid-metabolism',
|
||||
'aging-research',
|
||||
'dermatological',
|
||||
'metabolic',
|
||||
'growth-hormone',
|
||||
'immunology',
|
||||
'endocrine',
|
||||
);
|
||||
|
||||
foreach ( $required as $slug ) {
|
||||
$this->assertArrayHasKey( $slug, PSA_Post_Type::DEFAULT_CATEGORIES, "Missing category slug: {$slug}" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify core META_FIELDS constant has expected keys.
|
||||
*/
|
||||
public function test_core_meta_fields_keys(): void {
|
||||
$expected = array(
|
||||
'psa_sequence',
|
||||
'psa_molecular_weight',
|
||||
'psa_molecular_formula',
|
||||
'psa_aliases',
|
||||
'psa_mechanism',
|
||||
'psa_research_apps',
|
||||
'psa_safety_profile',
|
||||
'psa_dosage_info',
|
||||
'psa_references',
|
||||
'psa_source',
|
||||
'psa_pubchem_cid',
|
||||
);
|
||||
|
||||
foreach ( $expected as $key ) {
|
||||
$this->assertArrayHasKey( $key, PSA_Post_Type::META_FIELDS, "Missing core meta field: {$key}" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify extended meta fields contain the 9 new v4.3.0 fields.
|
||||
*/
|
||||
public function test_extended_meta_fields_count(): void {
|
||||
$this->assertCount( 9, PSA_Post_Type::EXTENDED_META_FIELDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify specific extended field keys.
|
||||
*/
|
||||
public function test_extended_meta_field_keys(): void {
|
||||
$expected = array(
|
||||
'psa_half_life',
|
||||
'psa_stability',
|
||||
'psa_solubility',
|
||||
'psa_vial_size_mg',
|
||||
'psa_storage_lyophilized',
|
||||
'psa_storage_reconstituted',
|
||||
'psa_typical_dose_mcg',
|
||||
'psa_cycle_parameters',
|
||||
'psa_amino_acid_count',
|
||||
);
|
||||
|
||||
foreach ( $expected as $key ) {
|
||||
$this->assertArrayHasKey( $key, PSA_Post_Type::EXTENDED_META_FIELDS, "Missing extended meta field: {$key}" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify get_all_meta_keys() returns combined core + extended keys.
|
||||
*/
|
||||
public function test_get_all_meta_keys_merges_both(): void {
|
||||
$all_keys = PSA_Post_Type::get_all_meta_keys();
|
||||
|
||||
// Should include both core and extended.
|
||||
$this->assertContains( 'psa_sequence', $all_keys );
|
||||
$this->assertContains( 'psa_half_life', $all_keys );
|
||||
$this->assertContains( 'psa_amino_acid_count', $all_keys );
|
||||
|
||||
// Total should be core + extended.
|
||||
$expected_count = count( PSA_Post_Type::META_FIELDS ) + count( PSA_Post_Type::EXTENDED_META_FIELDS );
|
||||
$this->assertCount( $expected_count, $all_keys );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user