diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8482dae..db61496 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,71 +1,27 @@
+##
+# Thin CI caller — peptide-reconstitution-calculator
+#
+# Delegates all lint/test/file-size jobs to the estate reusable workflow.
+# Per estate standard: workflow_call is required so deploy.yml can gate on it.
+# See: peptiderepo/peptide-e2e/.github/workflows/ci.yml@main for job definitions.
+##
name: CI
on:
+ pull_request: {}
push:
- branches: [ main ]
- pull_request:
- branches: [ main ]
- workflow_call:
+ branches: [main]
+ workflow_call: {}
+# Required: calling workflow must declare contents: write so the reusable
+# phpcs job can commit phpcbf auto-fixes back to the PR branch.
permissions:
- contents: read
+ contents: write
jobs:
- # ── PHP Syntax Check ─────────────────────────────────────────────
- lint-php:
- name: PHP Lint (${{ matrix.php }})
- runs-on: ubuntu-latest
- strategy:
- matrix:
- php: [ '8.1', '8.2', '8.3' ]
- steps:
- - uses: actions/checkout@v4
-
- - name: Set up PHP ${{ matrix.php }}
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php }}
-
- - name: Syntax check all PHP files
- run: find . -name "*.php" -not -path "./vendor/*" | xargs -n 1 php -l
-
- # ── WordPress Coding Standards (PHP CodeSniffer) ──────────────────
- lint-phpcs:
- name: WordPress Coding Standards
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Set up PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: '8.1'
-
- - name: Install Composer dependencies
- run: composer install --no-interaction --prefer-dist --no-progress
-
- - name: Run PHPCS
- run: composer run lint
- continue-on-error: true
-
- # ── 300-line file check ──────────────────────────────────────────
- file-size:
- name: 300-line file check
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Check no PHP file exceeds 300 lines
- run: |
- violations=0
- while IFS= read -r file; do
- lines=$(wc -l < "$file")
- if [ "$lines" -gt 300 ]; then
- echo "VIOLATION: $file ($lines lines)"
- violations=$((violations + 1))
- fi
- done < <(find . -name "*.php" -not -path "./vendor/*" -not -path "./tests/*")
- if [ "$violations" -gt 0 ]; then
- echo "$violations file(s) exceed 300 lines"
- exit 1
- fi
- echo "All PHP files are 300 lines or fewer."
+ ci:
+ uses: peptiderepo/peptide-e2e/.github/workflows/ci.yml@main
+ with:
+ tests: stubs
+ has_js: true
+ secrets: inherit
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e485a4..27221cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,32 @@
All notable changes to Peptide Reconstitution Calculator are documented here.
Format: [Semantic Versioning](https://semver.org/).
+## [1.2.0] — 2026-06-16
+
+### Added
+- PHPUnit test suite (WP stubs, `tests: stubs`) — 40 tests across four classes:
+ - `MathTest`: 25 tests covering all PRC_Math methods — concentration mg/mL,
+ mcg/unit, injection volume, syringe units, doses per vial, and the full
+ `calculate()` orchestrator with named peptide scenarios (BPC-157, TB-500,
+ Semaglutide, MK-677, Ipamorelin) plus edge cases (zero water, zero dose,
+ negative inputs, floor rounding, rounding precision).
+ - `DefaultPresetsTest`: 11 tests — collection count, all 8 expected slugs present,
+ required keys on every preset (via dataProvider), valid dose units, min < max,
+ source='default', and spot-checks on BPC-157 / Semaglutide / MK-677.
+ - `RestControllerTest`: 7 tests — calculate() 200 response + correct values for
+ mcg and mg dose units; get_presets() returns 200; get_preset() 200 for known
+ slug and WP_Error for unknown slug.
+ - `PresetProviderTest`: 4 tests — fallback to defaults without PR Core, all
+ presets have slug, cache invalidation, idempotent invalidation.
+- `phpunit.xml.dist` + `tests/bootstrap.php` (WP function/class stubs).
+- `composer test` script (`vendor/bin/phpunit --configuration phpunit.xml.dist`).
+- `phpunit/phpunit ^9.6` and `yoast/phpunit-polyfills ^2.0` in `require-dev`.
+
+### Changed
+- `ci.yml` replaced with thin caller delegating to estate reusable workflow
+ (`peptide-e2e/.github/workflows/ci.yml@main`; `tests: stubs`, `has_js: true`,
+ `permissions: contents: write`, `workflow_call` so `deploy.yml` gate works).
+
## [1.1.0] — 2026-04-26
### Changed
diff --git a/composer.json b/composer.json
index 392da5e..f3f908a 100644
--- a/composer.json
+++ b/composer.json
@@ -7,10 +7,12 @@
"php": ">=8.1"
},
"require-dev": {
+ "phpunit/phpunit": "^9.6",
"squizlabs/php_codesniffer": "^3.13",
"wp-coding-standards/wpcs": "^3.0",
"phpcsstandards/phpcsutils": "^1.0",
- "dealerdirect/phpcodesniffer-composer-installer": "^1.0"
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "yoast/phpunit-polyfills": "^2.0"
},
"config": {
"allow-plugins": {
@@ -18,6 +20,7 @@
}
},
"scripts": {
+ "test": "vendor/bin/phpunit --configuration phpunit.xml.dist",
"lint": "phpcs",
"lint:fix": "phpcbf"
}
diff --git a/peptide-reconstitution-calculator.php b/peptide-reconstitution-calculator.php
index ed78c08..e657bd7 100644
--- a/peptide-reconstitution-calculator.php
+++ b/peptide-reconstitution-calculator.php
@@ -3,7 +3,7 @@
* Plugin Name: Peptide Reconstitution Calculator
* Plugin URI: https://peptiderepo.com/tools/reconstitution-calculator
* Description: Interactive reconstitution calculator with peptide-specific presets from Peptide Repo Core. Computes concentration, injection volume, syringe units, and doses per vial.
- * Version: 1.0.0
+ * Version: 1.2.0
* Author: peptiderepo
* Author URI: https://peptiderepo.com
* License: GPL-2.0-or-later
@@ -25,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
/* ── Constants ────────────────────────────────────────────────────────── */
-define( 'PRC_VERSION', '1.0.0' );
+define( 'PRC_VERSION', '1.2.0' );
define( 'PRC_PLUGIN_FILE', __FILE__ );
define( 'PRC_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PRC_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100755
index 0000000..6cf6d35
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,20 @@
+
+
+
+
+ ./tests/unit
+
+
+
+
+
+ ./includes
+
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100755
index 0000000..b808a00
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,331 @@
+ $default ) {
+ $out[ $name ] = array_key_exists( $name, $atts ) ? $atts[ $name ] : $default;
+ }
+ return $out;
+ }
+}
+if ( ! function_exists( 'register_rest_route' ) ) {
+ function register_rest_route( $namespace, $route, $args = array(), $override = false ) {
+ return true;
+ }
+}
+if ( ! function_exists( 'rest_url' ) ) {
+ function rest_url( $path = '' ) {
+ return 'https://example.com/wp-json/' . ltrim( $path, '/' );
+ }
+}
+if ( ! function_exists( 'esc_url_raw' ) ) {
+ function esc_url_raw( $url, $protocols = null ) {
+ return filter_var( $url, FILTER_SANITIZE_URL ) ?: '';
+ }
+}
+if ( ! function_exists( 'esc_attr' ) ) {
+ function esc_attr( $text ) {
+ return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
+ }
+}
+if ( ! function_exists( 'esc_html' ) ) {
+ function esc_html( $text ) {
+ return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
+ }
+}
+if ( ! function_exists( 'esc_html__' ) ) {
+ function esc_html__( $text, $domain = 'default' ) {
+ return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
+ }
+}
+if ( ! function_exists( 'esc_html_e' ) ) {
+ function esc_html_e( $text, $domain = 'default' ) {
+ echo htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
+ }
+}
+if ( ! function_exists( '__' ) ) {
+ function __( $text, $domain = 'default' ) {
+ return $text;
+ }
+}
+if ( ! function_exists( 'is_admin' ) ) {
+ function is_admin() {
+ global $_test_is_admin;
+ return ! empty( $_test_is_admin );
+ }
+}
+if ( ! function_exists( 'wp_enqueue_style' ) ) {
+ function wp_enqueue_style( $handle, $src = '', $deps = array(), $ver = false, $media = 'all' ) {
+ return true;
+ }
+}
+if ( ! function_exists( 'wp_enqueue_script' ) ) {
+ function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) {
+ return true;
+ }
+}
+if ( ! function_exists( 'wp_localize_script' ) ) {
+ function wp_localize_script( $handle, $object_name, $l10n ) {
+ return true;
+ }
+}
+if ( ! function_exists( 'get_transient' ) ) {
+ function get_transient( $transient ) {
+ global $_test_transients;
+ return isset( $_test_transients[ $transient ] ) ? $_test_transients[ $transient ] : false;
+ }
+}
+if ( ! function_exists( 'set_transient' ) ) {
+ function set_transient( $transient, $value, $expiration = 0 ) {
+ global $_test_transients;
+ $_test_transients[ $transient ] = $value;
+ return true;
+ }
+}
+if ( ! function_exists( 'delete_transient' ) ) {
+ function delete_transient( $transient ) {
+ global $_test_transients;
+ unset( $_test_transients[ $transient ] );
+ return true;
+ }
+}
+if ( ! function_exists( 'register_activation_hook' ) ) {
+ function register_activation_hook( $file, $callback ) {
+ return true;
+ }
+}
+if ( ! function_exists( 'register_deactivation_hook' ) ) {
+ function register_deactivation_hook( $file, $callback ) {
+ return true;
+ }
+}
+if ( ! function_exists( 'delete_option' ) ) {
+ function delete_option( $option ) {
+ global $_test_options;
+ unset( $_test_options[ $option ] );
+ return true;
+ }
+}
+if ( ! function_exists( 'wp_cache_flush' ) ) {
+ function wp_cache_flush() {
+ return true;
+ }
+}
+if ( ! function_exists( 'get_option' ) ) {
+ function get_option( $option, $default = false ) {
+ global $_test_options;
+ return isset( $_test_options[ $option ] ) ? $_test_options[ $option ] : $default;
+ }
+}
+if ( ! function_exists( 'do_action' ) ) {
+ function do_action( $tag, ...$args ) {
+ return null;
+ }
+}
+
+// WP_REST_Request stub used by REST controller tests.
+if ( ! class_exists( 'WP_REST_Request' ) ) {
+ class WP_REST_Request {
+ /** @var array */
+ private array $params = array();
+ private string $method;
+
+ public function __construct( string $method = 'GET', string $route = '' ) {
+ $this->method = $method;
+ }
+
+ /**
+ * @param string $key
+ * @param mixed $value
+ */
+ public function set_param( string $key, $value ): void {
+ $this->params[ $key ] = $value;
+ }
+
+ /**
+ * @param string $key
+ * @return mixed
+ */
+ public function get_param( string $key ) {
+ return $this->params[ $key ] ?? null;
+ }
+
+ /** @return array */
+ public function get_params(): array {
+ return $this->params;
+ }
+ }
+}
+
+// WP_REST_Response stub.
+if ( ! class_exists( 'WP_REST_Response' ) ) {
+ class WP_REST_Response {
+ /** @var mixed */
+ public $data;
+ public int $status;
+
+ /**
+ * @param mixed $data
+ */
+ public function __construct( $data = null, int $status = 200 ) {
+ $this->data = $data;
+ $this->status = $status;
+ }
+
+ /** @return mixed */
+ public function get_data() {
+ return $this->data;
+ }
+
+ public function get_status(): int {
+ return $this->status;
+ }
+ }
+}
+
+// WP_Error stub.
+if ( ! class_exists( 'WP_Error' ) ) {
+ class WP_Error {
+ /** @var array */
+ public array $errors = array();
+ /** @var array */
+ public array $error_data = array();
+
+ /**
+ * @param mixed $data
+ */
+ public function __construct( string $code = '', string $message = '', $data = '' ) {
+ if ( $code ) {
+ $this->errors[ $code ][] = $message;
+ if ( $data ) {
+ $this->error_data[ $code ] = $data;
+ }
+ }
+ }
+
+ public function get_error_code(): string {
+ $codes = array_keys( $this->errors );
+ return $codes ? $codes[0] : '';
+ }
+
+ public function get_error_message( string $code = '' ): string {
+ if ( ! $code ) {
+ $code = $this->get_error_code();
+ }
+ return isset( $this->errors[ $code ] ) ? $this->errors[ $code ][0] : '';
+ }
+ }
+}
+
+if ( ! function_exists( 'is_wp_error' ) ) {
+ function is_wp_error( $thing ): bool {
+ return ( $thing instanceof WP_Error );
+ }
+}
+
+// ── Load plugin classes for testing ──────────────────────────────────
+
+// The autoloader resolves PRC_ classes from includes/ — register it now.
+require_once PRC_PLUGIN_DIR . 'includes/class-prc-autoloader.php';
+PRC_Autoloader::register();
+
+// Load pure classes explicitly (no WP hooks fire at require time).
+require_once PRC_PLUGIN_DIR . 'includes/class-prc-math.php';
+require_once PRC_PLUGIN_DIR . 'includes/class-prc-default-presets.php';
+require_once PRC_PLUGIN_DIR . 'includes/class-prc-calculator.php';
+require_once PRC_PLUGIN_DIR . 'includes/class-prc-preset-provider.php';
+require_once PRC_PLUGIN_DIR . 'includes/api/class-prc-rest-controller.php';
+require_once PRC_PLUGIN_DIR . 'includes/frontend/class-prc-shortcode.php';
diff --git a/tests/unit/DefaultPresetsTest.php b/tests/unit/DefaultPresetsTest.php
new file mode 100755
index 0000000..3a23d67
--- /dev/null
+++ b/tests/unit/DefaultPresetsTest.php
@@ -0,0 +1,265 @@
+> */
+ private array $presets;
+
+ protected function setUp(): void {
+ $this->presets = PRC_Default_Presets::get_all();
+ }
+
+ // ── Collection shape ──────────────────────────────────────────────
+
+ /**
+ * get_all() returns an array with at least one entry.
+ */
+ public function test_get_all_returns_array(): void {
+ $this->assertIsArray( $this->presets );
+ $this->assertGreaterThanOrEqual( 1, count( $this->presets ) );
+ }
+
+ /**
+ * Exactly 8 built-in presets are defined.
+ */
+ public function test_get_all_contains_eight_presets(): void {
+ $this->assertCount( 8, $this->presets );
+ }
+
+ /**
+ * The 8 expected slugs are all present.
+ */
+ public function test_expected_slugs_present(): void {
+ $slugs = array_column( $this->presets, 'slug' );
+
+ $expected = [
+ 'bpc-157',
+ 'tb-500',
+ 'semaglutide',
+ 'cjc-1295-dac',
+ 'ipamorelin',
+ 'pt-141',
+ 'mk-677',
+ 'ghrp-6',
+ ];
+
+ foreach ( $expected as $slug ) {
+ $this->assertContains( $slug, $slugs, "Missing expected slug: $slug" );
+ }
+ }
+
+ // ── Per-preset required fields ────────────────────────────────────
+
+ /**
+ * Every preset has all required keys.
+ *
+ * @dataProvider preset_provider
+ * @param array $preset
+ */
+ public function test_preset_has_required_keys( array $preset ): void {
+ $required = [
+ 'slug',
+ 'name',
+ 'vial_sizes_mg',
+ 'default_vial_mg',
+ 'recommended_water_ml',
+ 'dose_range_min',
+ 'dose_range_max',
+ 'dose_unit',
+ 'typical_frequency',
+ 'evidence_strength',
+ 'source',
+ ];
+
+ foreach ( $required as $key ) {
+ $this->assertArrayHasKey( $key, $preset, "Missing key '$key' in preset '{$preset['slug']}'" );
+ }
+ }
+
+ /**
+ * Every preset's slug is a non-empty string.
+ *
+ * @dataProvider preset_provider
+ * @param array $preset
+ */
+ public function test_preset_slug_is_non_empty_string( array $preset ): void {
+ $this->assertIsString( $preset['slug'] );
+ $this->assertNotEmpty( $preset['slug'] );
+ }
+
+ /**
+ * Every preset's name is a non-empty string.
+ *
+ * @dataProvider preset_provider
+ * @param array $preset
+ */
+ public function test_preset_name_is_non_empty_string( array $preset ): void {
+ $this->assertIsString( $preset['name'] );
+ $this->assertNotEmpty( $preset['name'] );
+ }
+
+ /**
+ * vial_sizes_mg is a non-empty array of positive floats.
+ *
+ * @dataProvider preset_provider
+ * @param array $preset
+ */
+ public function test_preset_vial_sizes_are_positive( array $preset ): void {
+ $this->assertIsArray( $preset['vial_sizes_mg'] );
+ $this->assertNotEmpty( $preset['vial_sizes_mg'] );
+
+ foreach ( $preset['vial_sizes_mg'] as $size ) {
+ $this->assertIsNumeric( $size );
+ $this->assertGreaterThan( 0, $size, "Non-positive vial size in preset '{$preset['slug']}'" );
+ }
+ }
+
+ /**
+ * default_vial_mg is one of the values in vial_sizes_mg.
+ *
+ * @dataProvider preset_provider
+ * @param array $preset
+ */
+ public function test_preset_default_vial_is_in_sizes( array $preset ): void {
+ $this->assertContains(
+ $preset['default_vial_mg'],
+ $preset['vial_sizes_mg'],
+ "default_vial_mg not in vial_sizes_mg for preset '{$preset['slug']}'"
+ );
+ }
+
+ /**
+ * recommended_water_ml is positive.
+ *
+ * @dataProvider preset_provider
+ * @param array $preset
+ */
+ public function test_preset_recommended_water_is_positive( array $preset ): void {
+ $this->assertGreaterThan( 0, $preset['recommended_water_ml'] );
+ }
+
+ /**
+ * dose_unit is either 'mcg' or 'mg'.
+ *
+ * @dataProvider preset_provider
+ * @param array $preset
+ */
+ public function test_preset_dose_unit_is_valid( array $preset ): void {
+ $this->assertContains( $preset['dose_unit'], [ 'mcg', 'mg' ], "Invalid dose_unit in preset '{$preset['slug']}'" );
+ }
+
+ /**
+ * dose_range_min < dose_range_max when both are non-null.
+ *
+ * @dataProvider preset_provider
+ * @param array $preset
+ */
+ public function test_preset_dose_range_min_less_than_max( array $preset ): void {
+ if ( null === $preset['dose_range_min'] || null === $preset['dose_range_max'] ) {
+ $this->addToAssertionCount( 1 ); // Null ranges are acceptable.
+ return;
+ }
+
+ $this->assertLessThan(
+ $preset['dose_range_max'],
+ $preset['dose_range_min'],
+ "dose_range_min >= dose_range_max in preset '{$preset['slug']}'"
+ );
+ }
+
+ /**
+ * source field is 'default' for every built-in preset.
+ *
+ * @dataProvider preset_provider
+ * @param array $preset
+ */
+ public function test_preset_source_is_default( array $preset ): void {
+ $this->assertSame( 'default', $preset['source'] );
+ }
+
+ // ── Spot-check specific known values ──────────────────────────────
+
+ /**
+ * BPC-157: 5 mg default vial, 2 mL water, 500 mcg max dose.
+ */
+ public function test_bpc157_specific_values(): void {
+ $preset = $this->find_preset( 'bpc-157' );
+
+ $this->assertNotNull( $preset );
+ $this->assertSame( 5.0, (float) $preset['default_vial_mg'] );
+ $this->assertSame( 2.0, (float) $preset['recommended_water_ml'] );
+ $this->assertSame( 500.0, (float) $preset['dose_range_max'] );
+ $this->assertSame( 'mcg', $preset['dose_unit'] );
+ }
+
+ /**
+ * Semaglutide: dose_unit is mg (not mcg), max dose 2.4 mg.
+ */
+ public function test_semaglutide_mg_dose_unit(): void {
+ $preset = $this->find_preset( 'semaglutide' );
+
+ $this->assertNotNull( $preset );
+ $this->assertSame( 'mg', $preset['dose_unit'] );
+ $this->assertEqualsWithDelta( 2.4, (float) $preset['dose_range_max'], 0.01 );
+ }
+
+ /**
+ * MK-677: 30 mg default vial, 3 mL water (large-vial path in recommend_water_ml).
+ */
+ public function test_mk677_large_vial_water_recommendation(): void {
+ $preset = $this->find_preset( 'mk-677' );
+
+ $this->assertNotNull( $preset );
+ $this->assertSame( 30.0, (float) $preset['default_vial_mg'] );
+ $this->assertSame( 3.0, (float) $preset['recommended_water_ml'] );
+ }
+
+ // ── DataProvider ──────────────────────────────────────────────────
+
+ /**
+ * Provides each preset individually.
+ *
+ * @return array}>
+ */
+ public function preset_provider(): array {
+ $presets = PRC_Default_Presets::get_all();
+ $cases = [];
+ foreach ( $presets as $preset ) {
+ $cases[ $preset['slug'] ] = [ $preset ];
+ }
+ return $cases;
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────
+
+ /**
+ * Find a preset by slug.
+ *
+ * @param string $slug
+ * @return array|null
+ */
+ private function find_preset( string $slug ): ?array {
+ foreach ( $this->presets as $preset ) {
+ if ( $preset['slug'] === $slug ) {
+ return $preset;
+ }
+ }
+ return null;
+ }
+}
diff --git a/tests/unit/MathTest.php b/tests/unit/MathTest.php
new file mode 100755
index 0000000..89361e8
--- /dev/null
+++ b/tests/unit/MathTest.php
@@ -0,0 +1,444 @@
+assertEqualsWithDelta( 2.5, $result, 0.0001 );
+ }
+
+ /**
+ * 10 mg peptide in 2 mL water → 5.0 mg/mL.
+ */
+ public function test_concentration_mg_per_ml_high_dose(): void {
+ $result = PRC_Math::concentration_mg_per_ml( 10.0, 2.0 );
+ $this->assertEqualsWithDelta( 5.0, $result, 0.0001 );
+ }
+
+ /**
+ * 2 mg peptide in 1 mL water → 2.0 mg/mL.
+ */
+ public function test_concentration_mg_per_ml_small_vial(): void {
+ $result = PRC_Math::concentration_mg_per_ml( 2.0, 1.0 );
+ $this->assertEqualsWithDelta( 2.0, $result, 0.0001 );
+ }
+
+ /**
+ * 30 mg peptide in 3 mL water → 10.0 mg/mL.
+ */
+ public function test_concentration_mg_per_ml_large_vial(): void {
+ $result = PRC_Math::concentration_mg_per_ml( 30.0, 3.0 );
+ $this->assertEqualsWithDelta( 10.0, $result, 0.0001 );
+ }
+
+ /**
+ * Zero water volume → returns 0 (division guard).
+ */
+ public function test_concentration_mg_per_ml_zero_water_returns_zero(): void {
+ $result = PRC_Math::concentration_mg_per_ml( 5.0, 0.0 );
+ $this->assertSame( 0.0, $result );
+ }
+
+ /**
+ * Negative water volume is treated as zero (guard condition uses <=).
+ */
+ public function test_concentration_mg_per_ml_negative_water_returns_zero(): void {
+ $result = PRC_Math::concentration_mg_per_ml( 5.0, -1.0 );
+ $this->assertSame( 0.0, $result );
+ }
+
+ /**
+ * Zero vial mg with valid water → 0 mg/mL.
+ */
+ public function test_concentration_mg_per_ml_zero_peptide(): void {
+ $result = PRC_Math::concentration_mg_per_ml( 0.0, 2.0 );
+ $this->assertSame( 0.0, $result );
+ }
+
+ // ── concentration_mcg_per_unit ────────────────────────────────────
+
+ /**
+ * 5 mg / 2 mL: (5 * 1000) / (2 * 100) = 5000 / 200 = 25.0 mcg/unit.
+ */
+ public function test_concentration_mcg_per_unit_standard(): void {
+ $result = PRC_Math::concentration_mcg_per_unit( 5.0, 2.0 );
+ $this->assertEqualsWithDelta( 25.0, $result, 0.0001 );
+ }
+
+ /**
+ * 10 mg / 2 mL: (10 * 1000) / (2 * 100) = 10000 / 200 = 50.0 mcg/unit.
+ */
+ public function test_concentration_mcg_per_unit_10mg_2ml(): void {
+ $result = PRC_Math::concentration_mcg_per_unit( 10.0, 2.0 );
+ $this->assertEqualsWithDelta( 50.0, $result, 0.0001 );
+ }
+
+ /**
+ * 2 mg / 1 mL: (2 * 1000) / (1 * 100) = 2000 / 100 = 20.0 mcg/unit.
+ */
+ public function test_concentration_mcg_per_unit_2mg_1ml(): void {
+ $result = PRC_Math::concentration_mcg_per_unit( 2.0, 1.0 );
+ $this->assertEqualsWithDelta( 20.0, $result, 0.0001 );
+ }
+
+ /**
+ * Zero water → returns 0 (guard).
+ */
+ public function test_concentration_mcg_per_unit_zero_water_returns_zero(): void {
+ $result = PRC_Math::concentration_mcg_per_unit( 5.0, 0.0 );
+ $this->assertSame( 0.0, $result );
+ }
+
+ // ── injection_volume_ml ───────────────────────────────────────────
+
+ /**
+ * 250 mcg dose at 2.5 mg/mL: dose_mg = 0.25 → 0.25 / 2.5 = 0.1 mL.
+ */
+ public function test_injection_volume_ml_standard(): void {
+ $result = PRC_Math::injection_volume_ml( 0.25, 2.5 );
+ $this->assertEqualsWithDelta( 0.1, $result, 0.0001 );
+ }
+
+ /**
+ * 500 mcg dose at 2.5 mg/mL: 0.5 / 2.5 = 0.2 mL.
+ */
+ public function test_injection_volume_ml_higher_dose(): void {
+ $result = PRC_Math::injection_volume_ml( 0.5, 2.5 );
+ $this->assertEqualsWithDelta( 0.2, $result, 0.0001 );
+ }
+
+ /**
+ * 1 mg dose at 5 mg/mL: 1.0 / 5.0 = 0.2 mL.
+ */
+ public function test_injection_volume_ml_mg_dose(): void {
+ $result = PRC_Math::injection_volume_ml( 1.0, 5.0 );
+ $this->assertEqualsWithDelta( 0.2, $result, 0.0001 );
+ }
+
+ /**
+ * Zero concentration → returns 0 (division guard).
+ */
+ public function test_injection_volume_ml_zero_concentration_returns_zero(): void {
+ $result = PRC_Math::injection_volume_ml( 0.25, 0.0 );
+ $this->assertSame( 0.0, $result );
+ }
+
+ /**
+ * Negative concentration → returns 0 (guard condition uses <=).
+ */
+ public function test_injection_volume_ml_negative_concentration_returns_zero(): void {
+ $result = PRC_Math::injection_volume_ml( 0.25, -1.0 );
+ $this->assertSame( 0.0, $result );
+ }
+
+ // ── syringe_units ─────────────────────────────────────────────────
+
+ /**
+ * 0.1 mL × 100 = 10 units.
+ */
+ public function test_syringe_units_0_1_ml(): void {
+ $result = PRC_Math::syringe_units( 0.1 );
+ $this->assertEqualsWithDelta( 10.0, $result, 0.0001 );
+ }
+
+ /**
+ * 0.2 mL × 100 = 20 units.
+ */
+ public function test_syringe_units_0_2_ml(): void {
+ $result = PRC_Math::syringe_units( 0.2 );
+ $this->assertEqualsWithDelta( 20.0, $result, 0.0001 );
+ }
+
+ /**
+ * 1 mL × 100 = 100 units (full syringe).
+ */
+ public function test_syringe_units_full_ml(): void {
+ $result = PRC_Math::syringe_units( 1.0 );
+ $this->assertEqualsWithDelta( 100.0, $result, 0.0001 );
+ }
+
+ /**
+ * 0 mL → 0 units.
+ */
+ public function test_syringe_units_zero(): void {
+ $result = PRC_Math::syringe_units( 0.0 );
+ $this->assertSame( 0.0, $result );
+ }
+
+ /**
+ * Fractional: 0.05 mL × 100 = 5.0 units.
+ */
+ public function test_syringe_units_fractional(): void {
+ $result = PRC_Math::syringe_units( 0.05 );
+ $this->assertEqualsWithDelta( 5.0, $result, 0.0001 );
+ }
+
+ // ── doses_per_vial ────────────────────────────────────────────────
+
+ /**
+ * 5 mg vial at 0.25 mg dose → 20 doses.
+ */
+ public function test_doses_per_vial_standard(): void {
+ $result = PRC_Math::doses_per_vial( 5.0, 0.25 );
+ $this->assertEqualsWithDelta( 20.0, $result, 0.0001 );
+ }
+
+ /**
+ * 10 mg vial at 0.5 mg dose → 20 doses.
+ */
+ public function test_doses_per_vial_large_vial(): void {
+ $result = PRC_Math::doses_per_vial( 10.0, 0.5 );
+ $this->assertEqualsWithDelta( 20.0, $result, 0.0001 );
+ }
+
+ /**
+ * 5 mg vial at 0.3 mg dose → 16.666... (floor returns 16 in calculate()).
+ */
+ public function test_doses_per_vial_non_integer(): void {
+ $result = PRC_Math::doses_per_vial( 5.0, 0.3 );
+ // Raw float — floor applied in calculate().
+ $this->assertGreaterThan( 16.0, $result );
+ $this->assertLessThan( 17.0, $result );
+ }
+
+ /**
+ * Zero dose → returns 0 (guard against division by zero).
+ */
+ public function test_doses_per_vial_zero_dose_returns_zero(): void {
+ $result = PRC_Math::doses_per_vial( 5.0, 0.0 );
+ $this->assertSame( 0.0, $result );
+ }
+
+ /**
+ * Negative dose → returns 0 (guard condition uses <=).
+ */
+ public function test_doses_per_vial_negative_dose_returns_zero(): void {
+ $result = PRC_Math::doses_per_vial( 5.0, -0.25 );
+ $this->assertSame( 0.0, $result );
+ }
+
+ // ── calculate() — full orchestrator ──────────────────────────────
+
+ /**
+ * BPC-157 typical scenario: 5 mg vial, 2 mL BAC water, 250 mcg dose.
+ *
+ * Expected:
+ * concentration_mg_per_ml = 5 / 2 = 2.5
+ * concentration_mcg_per_unit = (5000) / (200) = 25.0
+ * injection_volume_ml = 0.25 / 2.5 = 0.1
+ * syringe_units = 0.1 * 100 = 10
+ * doses_per_vial = floor(5 / 0.25) = 20
+ */
+ public function test_calculate_bpc157_250mcg_dose(): void {
+ $result = PRC_Math::calculate( 5.0, 2.0, 250.0, 'mcg' );
+
+ $this->assertIsArray( $result );
+ $this->assertEqualsWithDelta( 2.5, $result['concentration_mg_per_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 25.0, $result['concentration_mcg_per_unit'], 0.01 );
+ $this->assertEqualsWithDelta( 0.1, $result['injection_volume_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 10.0, $result['syringe_units'], 0.1 );
+ $this->assertSame( 20.0, $result['doses_per_vial'] );
+ $this->assertSame( 5.0, $result['total_peptide_mg'] );
+ $this->assertSame( 2.0, $result['water_ml'] );
+ $this->assertEqualsWithDelta( 0.25, $result['desired_dose_mg'], 0.0001 );
+ $this->assertSame( 250.0, $result['desired_dose_display'] );
+ $this->assertSame( 'mcg', $result['dose_unit'] );
+ }
+
+ /**
+ * Semaglutide scenario: 5 mg vial, 2 mL BAC water, 0.5 mg dose (mg unit).
+ *
+ * Expected:
+ * concentration_mg_per_ml = 5 / 2 = 2.5
+ * concentration_mcg_per_unit = 25.0
+ * injection_volume_ml = 0.5 / 2.5 = 0.2
+ * syringe_units = 0.2 * 100 = 20
+ * doses_per_vial = floor(5 / 0.5) = 10
+ */
+ public function test_calculate_semaglutide_0_5mg_dose(): void {
+ $result = PRC_Math::calculate( 5.0, 2.0, 0.5, 'mg' );
+
+ $this->assertEqualsWithDelta( 2.5, $result['concentration_mg_per_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 25.0, $result['concentration_mcg_per_unit'], 0.01 );
+ $this->assertEqualsWithDelta( 0.2, $result['injection_volume_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 20.0, $result['syringe_units'], 0.1 );
+ $this->assertSame( 10.0, $result['doses_per_vial'] );
+ $this->assertSame( 'mg', $result['dose_unit'] );
+ }
+
+ /**
+ * TB-500 high-dose scenario: 10 mg vial, 2 mL water, 2000 mcg dose.
+ *
+ * Expected:
+ * concentration_mg_per_ml = 10 / 2 = 5.0
+ * concentration_mcg_per_unit = (10000) / (200) = 50.0
+ * injection_volume_ml = 2.0 / 5.0 = 0.4
+ * syringe_units = 0.4 * 100 = 40
+ * doses_per_vial = floor(10 / 2) = 5
+ */
+ public function test_calculate_tb500_2000mcg_dose(): void {
+ $result = PRC_Math::calculate( 10.0, 2.0, 2000.0, 'mcg' );
+
+ $this->assertEqualsWithDelta( 5.0, $result['concentration_mg_per_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 50.0, $result['concentration_mcg_per_unit'], 0.01 );
+ $this->assertEqualsWithDelta( 0.4, $result['injection_volume_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 40.0, $result['syringe_units'], 0.1 );
+ $this->assertSame( 5.0, $result['doses_per_vial'] );
+ }
+
+ /**
+ * MK-677 mg-dose scenario: 30 mg vial, 3 mL water, 25 mg dose.
+ *
+ * Expected:
+ * concentration_mg_per_ml = 30 / 3 = 10.0
+ * concentration_mcg_per_unit = (30000) / (300) = 100.0
+ * injection_volume_ml = 25 / 10 = 2.5
+ * syringe_units = 2.5 * 100 = 250
+ * doses_per_vial = floor(30 / 25) = 1
+ */
+ public function test_calculate_mk677_25mg_dose(): void {
+ $result = PRC_Math::calculate( 30.0, 3.0, 25.0, 'mg' );
+
+ $this->assertEqualsWithDelta( 10.0, $result['concentration_mg_per_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 100.0, $result['concentration_mcg_per_unit'], 0.01 );
+ $this->assertEqualsWithDelta( 2.5, $result['injection_volume_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 250.0, $result['syringe_units'], 0.1 );
+ $this->assertSame( 1.0, $result['doses_per_vial'] );
+ }
+
+ /**
+ * Minimum BPC-157 dose: 200 mcg from a 5 mg / 2 mL vial.
+ *
+ * Expected:
+ * injection_volume_ml = 0.2 / 2.5 = 0.08
+ * syringe_units = 0.08 * 100 = 8.0
+ * doses_per_vial = floor(5 / 0.2) = 25
+ */
+ public function test_calculate_bpc157_200mcg_minimum_dose(): void {
+ $result = PRC_Math::calculate( 5.0, 2.0, 200.0, 'mcg' );
+
+ $this->assertEqualsWithDelta( 0.08, $result['injection_volume_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 8.0, $result['syringe_units'], 0.1 );
+ $this->assertSame( 25.0, $result['doses_per_vial'] );
+ }
+
+ /**
+ * Very small dose: 100 mcg from 5 mg / 2 mL.
+ *
+ * Expected:
+ * injection_volume_ml = 0.1 / 2.5 = 0.04
+ * syringe_units = 4.0
+ * doses_per_vial = floor(5 / 0.1) = 50
+ */
+ public function test_calculate_ipamorelin_100mcg_dose(): void {
+ $result = PRC_Math::calculate( 5.0, 2.0, 100.0, 'mcg' );
+
+ $this->assertEqualsWithDelta( 0.04, $result['injection_volume_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 4.0, $result['syringe_units'], 0.1 );
+ $this->assertSame( 50.0, $result['doses_per_vial'] );
+ }
+
+ /**
+ * Non-integer doses-per-vial: 5 mg vial, 300 mcg dose → 16.666 → floor → 16.
+ */
+ public function test_calculate_doses_per_vial_is_floored(): void {
+ $result = PRC_Math::calculate( 5.0, 2.0, 300.0, 'mcg' );
+
+ $this->assertSame( 16.0, $result['doses_per_vial'] );
+ }
+
+ /**
+ * mcg/unit output is rounded to 2 decimal places.
+ */
+ public function test_calculate_concentration_mcg_per_unit_rounded_to_two_decimals(): void {
+ // 3 mg / 2 mL: mcg_per_unit = 3000 / 200 = 15.0 — exact, check rounding still works.
+ $result = PRC_Math::calculate( 3.0, 2.0, 150.0, 'mcg' );
+ // 15.0 rounded to 2 dp = 15.0.
+ $this->assertEqualsWithDelta( 15.0, $result['concentration_mcg_per_unit'], 0.005 );
+ }
+
+ /**
+ * concentration_mg_per_ml output is rounded to 4 decimal places.
+ *
+ * 7 mg / 3 mL = 2.333... → rounded to 4 dp = 2.3333.
+ */
+ public function test_calculate_concentration_rounded_to_four_decimals(): void {
+ $result = PRC_Math::calculate( 7.0, 3.0, 100.0, 'mcg' );
+ $this->assertEqualsWithDelta( 2.3333, $result['concentration_mg_per_ml'], 0.00005 );
+ }
+
+ /**
+ * Result array has the expected keys.
+ */
+ public function test_calculate_returns_expected_keys(): void {
+ $result = PRC_Math::calculate( 5.0, 2.0, 250.0, 'mcg' );
+
+ $expected_keys = [
+ 'concentration_mg_per_ml',
+ 'concentration_mcg_per_unit',
+ 'injection_volume_ml',
+ 'syringe_units',
+ 'doses_per_vial',
+ 'total_peptide_mg',
+ 'water_ml',
+ 'desired_dose_mg',
+ 'desired_dose_display',
+ 'dose_unit',
+ ];
+
+ foreach ( $expected_keys as $key ) {
+ $this->assertArrayHasKey( $key, $result, "Missing key: $key" );
+ }
+ }
+
+ /**
+ * mcg unit: desired_dose_mg is divided by 1000.
+ * 500 mcg → 0.5 mg stored as desired_dose_mg.
+ */
+ public function test_calculate_mcg_dose_unit_conversion(): void {
+ $result = PRC_Math::calculate( 10.0, 2.0, 500.0, 'mcg' );
+
+ $this->assertEqualsWithDelta( 0.5, $result['desired_dose_mg'], 0.0001 );
+ $this->assertSame( 500.0, $result['desired_dose_display'] );
+ $this->assertSame( 'mcg', $result['dose_unit'] );
+ }
+
+ /**
+ * mg unit: desired_dose_mg equals the raw input value.
+ */
+ public function test_calculate_mg_dose_unit_no_conversion(): void {
+ $result = PRC_Math::calculate( 10.0, 2.0, 1.5, 'mg' );
+
+ $this->assertEqualsWithDelta( 1.5, $result['desired_dose_mg'], 0.0001 );
+ $this->assertSame( 1.5, $result['desired_dose_display'] );
+ $this->assertSame( 'mg', $result['dose_unit'] );
+ }
+}
diff --git a/tests/unit/PresetProviderTest.php b/tests/unit/PresetProviderTest.php
new file mode 100755
index 0000000..cd2a637
--- /dev/null
+++ b/tests/unit/PresetProviderTest.php
@@ -0,0 +1,96 @@
+get_all_presets();
+
+ $this->assertIsArray( $presets );
+ $this->assertGreaterThan( 0, count( $presets ) );
+ }
+
+ /**
+ * get_all_presets() matches PRC_Default_Presets::get_all() when PR Core absent.
+ */
+ public function test_get_all_presets_matches_defaults(): void {
+ $provider = new PRC_Preset_Provider();
+ $actual = $provider->get_all_presets();
+ $expected = PRC_Default_Presets::get_all();
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ /**
+ * All returned presets have a slug key.
+ */
+ public function test_all_presets_have_slug(): void {
+ $provider = new PRC_Preset_Provider();
+ $presets = $provider->get_all_presets();
+
+ foreach ( $presets as $preset ) {
+ $this->assertArrayHasKey( 'slug', $preset );
+ $this->assertNotEmpty( $preset['slug'] );
+ }
+ }
+
+ // ── Cache invalidation ────────────────────────────────────────────
+
+ /**
+ * invalidate_cache() removes the 'prc_presets_all' transient.
+ */
+ public function test_invalidate_cache_removes_transient(): void {
+ global $_test_transients;
+
+ // Seed the transient manually.
+ $_test_transients['prc_presets_all'] = [ 'cached-data' ];
+
+ $provider = new PRC_Preset_Provider();
+ $provider->invalidate_cache();
+
+ $this->assertArrayNotHasKey( 'prc_presets_all', $_test_transients );
+ }
+
+ /**
+ * Multiple invalidate_cache() calls are safe (idempotent).
+ */
+ public function test_invalidate_cache_is_idempotent(): void {
+ $provider = new PRC_Preset_Provider();
+
+ // Should not throw, even if transient was never set.
+ $provider->invalidate_cache();
+ $provider->invalidate_cache();
+
+ $this->addToAssertionCount( 1 ); // If we got here, no exception.
+ }
+}
diff --git a/tests/unit/RestControllerTest.php b/tests/unit/RestControllerTest.php
new file mode 100755
index 0000000..1ede9c2
--- /dev/null
+++ b/tests/unit/RestControllerTest.php
@@ -0,0 +1,150 @@
+controller = new PRC_Rest_Controller();
+ }
+
+ // ── /calculate callback ───────────────────────────────────────────
+
+ /**
+ * calculate() returns a WP_REST_Response with status 200.
+ */
+ public function test_calculate_returns_200_response(): void {
+ $request = new WP_REST_Request( 'POST' );
+ $request->set_param( 'vial_mg', '5' );
+ $request->set_param( 'water_ml', '2' );
+ $request->set_param( 'desired_dose', '250' );
+ $request->set_param( 'dose_unit', 'mcg' );
+
+ $response = $this->controller->calculate( $request );
+
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+ $this->assertSame( 200, $response->get_status() );
+ }
+
+ /**
+ * calculate() returns the correct BPC-157 math results.
+ *
+ * 5 mg / 2 mL / 250 mcg → concentration 2.5, syringe units 10.
+ */
+ public function test_calculate_bpc157_correct_values(): void {
+ $request = new WP_REST_Request( 'POST' );
+ $request->set_param( 'vial_mg', '5' );
+ $request->set_param( 'water_ml', '2' );
+ $request->set_param( 'desired_dose', '250' );
+ $request->set_param( 'dose_unit', 'mcg' );
+
+ $response = $this->controller->calculate( $request );
+ $data = $response->get_data();
+
+ $this->assertIsArray( $data );
+ $this->assertEqualsWithDelta( 2.5, $data['concentration_mg_per_ml'], 0.0001 );
+ $this->assertEqualsWithDelta( 10.0, $data['syringe_units'], 0.1 );
+ $this->assertSame( 20.0, $data['doses_per_vial'] );
+ }
+
+ /**
+ * calculate() accepts mg dose unit correctly.
+ *
+ * 5 mg / 2 mL / 0.5 mg → syringe units 20.
+ */
+ public function test_calculate_mg_dose_unit(): void {
+ $request = new WP_REST_Request( 'POST' );
+ $request->set_param( 'vial_mg', '5' );
+ $request->set_param( 'water_ml', '2' );
+ $request->set_param( 'desired_dose', '0.5' );
+ $request->set_param( 'dose_unit', 'mg' );
+
+ $response = $this->controller->calculate( $request );
+ $data = $response->get_data();
+
+ $this->assertEqualsWithDelta( 20.0, $data['syringe_units'], 0.1 );
+ $this->assertSame( 10.0, $data['doses_per_vial'] );
+ }
+
+ /**
+ * calculate() returns all expected keys in the response data.
+ */
+ public function test_calculate_response_has_expected_keys(): void {
+ $request = new WP_REST_Request( 'POST' );
+ $request->set_param( 'vial_mg', '5' );
+ $request->set_param( 'water_ml', '2' );
+ $request->set_param( 'desired_dose', '250' );
+ $request->set_param( 'dose_unit', 'mcg' );
+
+ $data = $this->controller->calculate( $request )->get_data();
+
+ $expected_keys = [
+ 'concentration_mg_per_ml',
+ 'concentration_mcg_per_unit',
+ 'injection_volume_ml',
+ 'syringe_units',
+ 'doses_per_vial',
+ 'dose_unit',
+ ];
+
+ foreach ( $expected_keys as $key ) {
+ $this->assertArrayHasKey( $key, $data, "Missing key: $key" );
+ }
+ }
+
+ /**
+ * get_presets() returns a 200 response with an array.
+ */
+ public function test_get_presets_returns_200_with_array(): void {
+ $request = new WP_REST_Request( 'GET' );
+ $response = $this->controller->get_presets( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertIsArray( $response->get_data() );
+ }
+
+ /**
+ * get_preset() returns a WP_Error for an unknown slug.
+ */
+ public function test_get_preset_unknown_slug_returns_wp_error(): void {
+ $request = new WP_REST_Request( 'GET' );
+ $request->set_param( 'slug', 'nonexistent-peptide-xyz' );
+
+ $result = $this->controller->get_preset( $request );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertSame( 'prc_preset_not_found', $result->get_error_code() );
+ }
+
+ /**
+ * get_preset() returns a 200 response for a known slug.
+ */
+ public function test_get_preset_known_slug_returns_200(): void {
+ $request = new WP_REST_Request( 'GET' );
+ $request->set_param( 'slug', 'bpc-157' );
+
+ $result = $this->controller->get_preset( $request );
+
+ $this->assertInstanceOf( WP_REST_Response::class, $result );
+ $this->assertSame( 200, $result->get_status() );
+
+ $data = $result->get_data();
+ $this->assertSame( 'bpc-157', $data['slug'] );
+ }
+}