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:
peptiderepo
2026-04-13 21:30:11 +07:00
parent 0fa2707163
commit ed65b6ab90
28 changed files with 3380 additions and 783 deletions

View File

@@ -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';

View File

@@ -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
View 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
View 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 );
}
}