v0.9.0: Runware FLUX.1 schnell as default image provider
New Runware image backend at ~$0.0006/image (FLUX.1 schnell) — 65× cheaper than Gemini 2.5 Flash Image. Provider mirrors OpenRouter's split: interface + provider + support + pricing + batch, all under the 300-line cap. Batch class uses curl_multi for true parallel A/B dispatch — wall-clock drops to the slowest single image instead of the sum. Changes: - Add PRAutoBlogger_Runware_Image_Provider + Pricing + Support + Batch - Flip PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER/MODEL to runware:100@1 - Add prautoblogger_runware_api_key settings field (encrypted at rest) - v0.9.0 one-time migration flips legacy default model + re-derives provider - Wire runware branch into Image_Pipeline::create_default_provider() - Add runware:100@1 and runware:101@1 to Image_Model_Registry (defaults top) - Update image prompt builder: no more anthropomorphized molecules; prefer concrete human-scale scenes (gym bros, doctors, scientists) - ARCHITECTURE.md + CHANGELOG.md updated with new integration + decision - Unit tests for pricing, support, provider; update model registry test Cost impact at 100 images/day (50 articles × A/B): - Before (Gemini via OpenRouter): $3.90/day ≈ $117/mo - After (FLUX schnell via Runware): $0.06/day ≈ $1.80/mo Net saving: ~$115/mo
This commit is contained in:
91
tests/unit/Providers/RunwareImagePricingTest.php
Normal file
91
tests/unit/Providers/RunwareImagePricingTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Runware_Image_Pricing.
|
||||
*
|
||||
* Validates model resolution fallback logic and per-image cost estimation
|
||||
* for the Runware FLUX models.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Providers
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Providers;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class RunwareImagePricingTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = [];
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
}
|
||||
|
||||
/** Empty hint resolves to the v0.9.0 default (runware schnell). */
|
||||
public function test_resolve_model_empty_hint_uses_schnell_default(): void {
|
||||
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||
$model = $pricing->resolve_model( '' );
|
||||
$this->assertSame( 'runware:100@1', $model );
|
||||
}
|
||||
|
||||
/** Explicit schnell hint resolves to itself. */
|
||||
public function test_resolve_model_schnell_hint(): void {
|
||||
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||
$this->assertSame( 'runware:100@1', $pricing->resolve_model( 'runware:100@1' ) );
|
||||
}
|
||||
|
||||
/** Explicit dev hint resolves to itself. */
|
||||
public function test_resolve_model_dev_hint(): void {
|
||||
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||
$this->assertSame( 'runware:101@1', $pricing->resolve_model( 'runware:101@1' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-Runware hint (e.g. a Gemini slug) falls back to schnell because
|
||||
* the Runware provider was selected explicitly — the user wants a
|
||||
* Runware model even if the admin option still holds a foreign slug.
|
||||
*/
|
||||
public function test_resolve_model_unknown_hint_falls_back_to_schnell(): void {
|
||||
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||
$this->assertSame( 'runware:100@1', $pricing->resolve_model( 'google/gemini-2.5-flash-image' ) );
|
||||
}
|
||||
|
||||
/** FLUX.1 schnell costs ~$0.0006/image. */
|
||||
public function test_estimate_cost_schnell(): void {
|
||||
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||
$cost = $pricing->estimate_cost( 1216, 640, 'runware:100@1' );
|
||||
$this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 );
|
||||
}
|
||||
|
||||
/** FLUX.1 dev costs ~$0.02/image. */
|
||||
public function test_estimate_cost_dev(): void {
|
||||
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||
$cost = $pricing->estimate_cost( 1216, 640, 'runware:101@1' );
|
||||
$this->assertEqualsWithDelta( 0.02, $cost, 0.000001 );
|
||||
}
|
||||
|
||||
/** Unknown model falls back to conservative $0.01 estimate. */
|
||||
public function test_estimate_cost_unknown_model(): void {
|
||||
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||
$cost = $pricing->estimate_cost( 1216, 640, 'runware:999@1' );
|
||||
$this->assertEqualsWithDelta( 0.01, $cost, 0.000001 );
|
||||
}
|
||||
|
||||
/** Default step count: schnell=4, dev=28. */
|
||||
public function test_default_steps(): void {
|
||||
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
|
||||
$this->assertSame( 4, $pricing->default_steps( 'runware:100@1' ) );
|
||||
$this->assertSame( 28, $pricing->default_steps( 'runware:101@1' ) );
|
||||
$this->assertSame( 4, $pricing->default_steps( 'runware:999@1' ) );
|
||||
}
|
||||
|
||||
/** get_model_costs returns a non-empty map with Runware models. */
|
||||
public function test_get_model_costs(): void {
|
||||
$costs = \PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
|
||||
$this->assertIsArray( $costs );
|
||||
$this->assertArrayHasKey( 'runware:100@1', $costs );
|
||||
$this->assertArrayHasKey( 'runware:101@1', $costs );
|
||||
}
|
||||
}
|
||||
247
tests/unit/Providers/RunwareImageProviderTest.php
Normal file
247
tests/unit/Providers/RunwareImageProviderTest.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Runware_Image_Provider.
|
||||
*
|
||||
* Validates interface compliance, two-step POST-then-GET flow, retry
|
||||
* behavior on 5xx, immediate-fail on 4xx, and dimension/prompt guards.
|
||||
* All HTTP calls are mocked — no real API traffic.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Providers
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Providers;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class RunwareImageProviderTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = [
|
||||
'prautoblogger_runware_api_key' => 'enc:fake-key',
|
||||
'prautoblogger_image_model' => 'runware:100@1',
|
||||
];
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
|
||||
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
|
||||
return $thing instanceof \WP_Error;
|
||||
} );
|
||||
|
||||
Functions\when( 'wp_generate_uuid4' )->justReturn( '11111111-2222-3333-4444-555555555555' );
|
||||
|
||||
if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) {
|
||||
eval( '
|
||||
class PRAutoBlogger_Encryption {
|
||||
public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); }
|
||||
public static function decrypt( $v ) { return "rw-test-key-123"; }
|
||||
public static function encrypt( $v ) { return "enc:" . $v; }
|
||||
}
|
||||
' );
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider name is 'runware'. */
|
||||
public function test_get_provider_name(): void {
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$this->assertSame( 'runware', $provider->get_provider_name() );
|
||||
}
|
||||
|
||||
/** estimate_cost returns a positive float for schnell. */
|
||||
public function test_estimate_cost_schnell(): void {
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$cost = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] );
|
||||
$this->assertIsFloat( $cost );
|
||||
$this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 );
|
||||
}
|
||||
|
||||
/** estimate_cost returns higher value for dev. */
|
||||
public function test_estimate_cost_dev_higher_than_schnell(): void {
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$schnell = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] );
|
||||
$dev = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:101@1' ] );
|
||||
$this->assertGreaterThan( $schnell, $dev );
|
||||
}
|
||||
|
||||
/** Empty prompt throws InvalidArgumentException. */
|
||||
public function test_generate_image_empty_prompt_throws(): void {
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$provider->generate_image( '', 1200, 640 );
|
||||
}
|
||||
|
||||
/** Whitespace-only prompt throws InvalidArgumentException. */
|
||||
public function test_generate_image_whitespace_prompt_throws(): void {
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$provider->generate_image( " \t\n ", 1200, 640 );
|
||||
}
|
||||
|
||||
/** Dimension below minimum throws. */
|
||||
public function test_generate_image_tiny_dimensions_throws(): void {
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$provider->generate_image( 'A test prompt', 10, 10 );
|
||||
}
|
||||
|
||||
/** Dimension above maximum throws. */
|
||||
public function test_generate_image_huge_dimensions_throws(): void {
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$this->expectException( \InvalidArgumentException::class );
|
||||
$provider->generate_image( 'A test prompt', 5000, 5000 );
|
||||
}
|
||||
|
||||
/** Happy path: POST returns URL, GET returns bytes, result shape correct. */
|
||||
public function test_generate_image_success(): void {
|
||||
$image_url = 'https://im.runware.ai/image/abc.png';
|
||||
$post_body = wp_json_encode( [
|
||||
'data' => [
|
||||
[ 'taskType' => 'imageInference', 'imageURL' => $image_url ],
|
||||
],
|
||||
] );
|
||||
$fake_bytes = 'fake-png-bytes';
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [],
|
||||
'body' => $post_body,
|
||||
] );
|
||||
Functions\when( 'wp_remote_get' )->justReturn( [
|
||||
'response' => [ 'code' => 200 ],
|
||||
'body' => $fake_bytes,
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias( function ( $r ) {
|
||||
return isset( $r['response']['code'] ) ? (int) $r['response']['code'] : 0;
|
||||
} );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->alias( function ( $r ) {
|
||||
return isset( $r['body'] ) ? (string) $r['body'] : '';
|
||||
} );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$result = $provider->generate_image( 'A gym bro holding a vial', 1200, 632 );
|
||||
|
||||
$this->assertArrayHasKey( 'bytes', $result );
|
||||
$this->assertArrayHasKey( 'mime_type', $result );
|
||||
$this->assertArrayHasKey( 'width', $result );
|
||||
$this->assertArrayHasKey( 'height', $result );
|
||||
$this->assertArrayHasKey( 'model', $result );
|
||||
$this->assertArrayHasKey( 'seed', $result );
|
||||
$this->assertArrayHasKey( 'cost_usd', $result );
|
||||
$this->assertArrayHasKey( 'latency_ms', $result );
|
||||
|
||||
$this->assertSame( $fake_bytes, $result['bytes'] );
|
||||
$this->assertSame( 'image/png', $result['mime_type'] );
|
||||
// Snapped to multiples of 64: 1200 → 1216, 632 → 640.
|
||||
$this->assertSame( 1216, $result['width'] );
|
||||
$this->assertSame( 640, $result['height'] );
|
||||
$this->assertSame( 'runware:100@1', $result['model'] );
|
||||
$this->assertGreaterThan( 0, $result['seed'] );
|
||||
}
|
||||
|
||||
/** 4xx (non-429) fails immediately with RuntimeException. */
|
||||
public function test_generate_image_4xx_throws_immediately(): void {
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'response' => [ 'code' => 401 ],
|
||||
'headers' => [],
|
||||
'body' => '{"errors":[{"message":"invalid key"}]}',
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$this->expectExceptionMessage( 'HTTP 401' );
|
||||
$provider->generate_image( 'A test prompt', 1200, 632 );
|
||||
}
|
||||
|
||||
/** 5xx triggers retry loop and eventually throws after PRAUTOBLOGGER_MAX_RETRIES. */
|
||||
public function test_generate_image_5xx_retries_then_throws(): void {
|
||||
$call_count = 0;
|
||||
Functions\when( 'wp_remote_post' )->alias( function () use ( &$call_count ) {
|
||||
$call_count++;
|
||||
return [
|
||||
'response' => [ 'code' => 503 ],
|
||||
'headers' => [],
|
||||
'body' => '{"errors":[{"message":"temporarily unavailable"}]}',
|
||||
];
|
||||
} );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 503 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"temporarily unavailable"}]}' );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
try {
|
||||
$provider->generate_image( 'A test prompt', 1200, 632 );
|
||||
$this->fail( 'Expected RuntimeException after max retries' );
|
||||
} catch ( \RuntimeException $e ) {
|
||||
$this->assertSame(
|
||||
PRAUTOBLOGGER_MAX_RETRIES,
|
||||
$call_count,
|
||||
'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** errors[] in a 200 body is treated as an upstream error. */
|
||||
public function test_generate_image_errors_in_200_body_throws(): void {
|
||||
$body = wp_json_encode( [
|
||||
'errors' => [ [ 'message' => 'moderation rejected' ] ],
|
||||
] );
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [],
|
||||
'body' => $body,
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
|
||||
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$provider->generate_image( 'A test prompt', 1200, 632 );
|
||||
}
|
||||
|
||||
/** validate_credentials_detailed returns ok for a 200 response with no errors. */
|
||||
public function test_validate_credentials_detailed_ok(): void {
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'response' => [ 'code' => 200 ],
|
||||
'body' => '{"data":[{"taskType":"authentication"}]}',
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"data":[{"taskType":"authentication"}]}' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$result = $provider->validate_credentials_detailed();
|
||||
$this->assertSame( 'ok', $result['status'] );
|
||||
}
|
||||
|
||||
/** validate_credentials_detailed returns error when key missing. */
|
||||
public function test_validate_credentials_detailed_missing_key(): void {
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
return $default;
|
||||
} );
|
||||
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$result = $provider->validate_credentials_detailed();
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
}
|
||||
|
||||
/** validate_credentials_detailed returns error when response contains errors[]. */
|
||||
public function test_validate_credentials_detailed_rejected_key(): void {
|
||||
Functions\when( 'wp_remote_post' )->justReturn( [
|
||||
'response' => [ 'code' => 200 ],
|
||||
'body' => '{"errors":[{"message":"invalid key"}]}',
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' );
|
||||
|
||||
$provider = new \PRAutoBlogger_Runware_Image_Provider();
|
||||
$result = $provider->validate_credentials_detailed();
|
||||
$this->assertSame( 'error', $result['status'] );
|
||||
}
|
||||
}
|
||||
184
tests/unit/Providers/RunwareImageSupportTest.php
Normal file
184
tests/unit/Providers/RunwareImageSupportTest.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Runware_Image_Support.
|
||||
*
|
||||
* Covers response parsing (URL extraction), dimension snapping to FLUX
|
||||
* constraints, seed normalization, and API-key lookup.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Providers
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Providers;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class RunwareImageSupportTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
Functions\when( 'esc_html' )->returnArg();
|
||||
Functions\when( 'esc_html__' )->returnArg();
|
||||
Functions\when( '__' )->returnArg();
|
||||
|
||||
if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) {
|
||||
eval( '
|
||||
class PRAutoBlogger_Encryption {
|
||||
public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); }
|
||||
public static function decrypt( $v ) { return "rw-test-key-123"; }
|
||||
public static function encrypt( $v ) { return "enc:" . $v; }
|
||||
}
|
||||
' );
|
||||
}
|
||||
}
|
||||
|
||||
/** A well-formed imageInference entry returns its imageURL. */
|
||||
public function test_extract_image_url_valid_response(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$raw = wp_json_encode( [
|
||||
'data' => [
|
||||
[
|
||||
'taskType' => 'imageInference',
|
||||
'imageURL' => 'https://im.runware.ai/image/abc123.png',
|
||||
],
|
||||
],
|
||||
] );
|
||||
$this->assertSame(
|
||||
'https://im.runware.ai/image/abc123.png',
|
||||
$support->extract_image_url( $raw )
|
||||
);
|
||||
}
|
||||
|
||||
/** An errors[] array in the response causes a RuntimeException. */
|
||||
public function test_extract_image_url_error_response_throws(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$raw = wp_json_encode( [
|
||||
'errors' => [
|
||||
[ 'message' => 'invalid api key' ],
|
||||
],
|
||||
] );
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$this->expectExceptionMessage( 'invalid api key' );
|
||||
$support->extract_image_url( $raw );
|
||||
}
|
||||
|
||||
/** Malformed JSON throws RuntimeException. */
|
||||
public function test_extract_image_url_invalid_json_throws(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$support->extract_image_url( 'not json at all' );
|
||||
}
|
||||
|
||||
/** Data array present but no imageInference entry throws. */
|
||||
public function test_extract_image_url_no_inference_entry_throws(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$raw = wp_json_encode( [
|
||||
'data' => [
|
||||
[ 'taskType' => 'authentication' ],
|
||||
],
|
||||
] );
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$support->extract_image_url( $raw );
|
||||
}
|
||||
|
||||
/** imageInference present but empty imageURL throws. */
|
||||
public function test_extract_image_url_empty_url_throws(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$raw = wp_json_encode( [
|
||||
'data' => [
|
||||
[ 'taskType' => 'imageInference', 'imageURL' => '' ],
|
||||
],
|
||||
] );
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$support->extract_image_url( $raw );
|
||||
}
|
||||
|
||||
/** snap_dimension rounds to nearest multiple of 64. */
|
||||
public function test_snap_dimension_rounds_to_64(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$this->assertSame( 1216, $support->snap_dimension( 1200 ) );
|
||||
$this->assertSame( 640, $support->snap_dimension( 632 ) );
|
||||
$this->assertSame( 1024, $support->snap_dimension( 1024 ) );
|
||||
$this->assertSame( 1024, $support->snap_dimension( 1050 ) );
|
||||
}
|
||||
|
||||
/** snap_dimension clamps to minimum 512. */
|
||||
public function test_snap_dimension_clamps_min(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$this->assertSame( 512, $support->snap_dimension( 64 ) );
|
||||
$this->assertSame( 512, $support->snap_dimension( 200 ) );
|
||||
}
|
||||
|
||||
/** snap_dimension clamps to maximum 2048. */
|
||||
public function test_snap_dimension_clamps_max(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$this->assertSame( 2048, $support->snap_dimension( 4096 ) );
|
||||
$this->assertSame( 2048, $support->snap_dimension( 2500 ) );
|
||||
}
|
||||
|
||||
/** A valid positive seed passes through unchanged. */
|
||||
public function test_normalize_seed_positive_passthrough(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$this->assertSame( 42, $support->normalize_seed( 42 ) );
|
||||
$this->assertSame( 1234567, $support->normalize_seed( 1234567 ) );
|
||||
}
|
||||
|
||||
/** Null seed derives a positive integer. */
|
||||
public function test_normalize_seed_null_derives_positive(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$seed = $support->normalize_seed( null );
|
||||
$this->assertGreaterThan( 0, $seed );
|
||||
}
|
||||
|
||||
/** Zero seed is replaced with a derived positive integer. */
|
||||
public function test_normalize_seed_zero_replaced(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$seed = $support->normalize_seed( 0 );
|
||||
$this->assertGreaterThan( 0, $seed );
|
||||
}
|
||||
|
||||
/** Negative seed is replaced with a derived positive integer. */
|
||||
public function test_normalize_seed_negative_replaced(): void {
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$seed = $support->normalize_seed( -5 );
|
||||
$this->assertGreaterThan( 0, $seed );
|
||||
}
|
||||
|
||||
/** Missing API key option throws RuntimeException. */
|
||||
public function test_get_api_key_missing_throws(): void {
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
return $default;
|
||||
} );
|
||||
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$this->expectException( \RuntimeException::class );
|
||||
$support->get_api_key();
|
||||
}
|
||||
|
||||
/** Encrypted key is decrypted on read. */
|
||||
public function test_get_api_key_decrypts_encrypted(): void {
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
if ( 'prautoblogger_runware_api_key' === $key ) {
|
||||
return 'enc:stored-ciphertext';
|
||||
}
|
||||
return $default;
|
||||
} );
|
||||
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$this->assertSame( 'rw-test-key-123', $support->get_api_key() );
|
||||
}
|
||||
|
||||
/** A plaintext key (pre-migration) passes through unchanged. */
|
||||
public function test_get_api_key_plaintext_passthrough(): void {
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
if ( 'prautoblogger_runware_api_key' === $key ) {
|
||||
return 'rw-plain-key';
|
||||
}
|
||||
return $default;
|
||||
} );
|
||||
|
||||
$support = new \PRAutoBlogger_Runware_Image_Support();
|
||||
$this->assertSame( 'rw-plain-key', $support->get_api_key() );
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ class ImageModelRegistryTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Every entry returned by get_models() must carry a non-empty id and
|
||||
* a provider of 'openrouter' or 'cloudflare'. If this breaks, the
|
||||
* a provider of 'runware', 'openrouter', or 'cloudflare'. If this breaks, the
|
||||
* admin save flow will silently drop the provider derivation.
|
||||
*/
|
||||
public function test_every_registry_entry_has_id_and_known_provider(): void {
|
||||
@@ -31,7 +31,7 @@ class ImageModelRegistryTest extends BaseTestCase {
|
||||
$this->assertArrayHasKey( 'provider', $model );
|
||||
$this->assertContains(
|
||||
$model['provider'],
|
||||
[ 'openrouter', 'cloudflare' ],
|
||||
[ 'runware', 'openrouter', 'cloudflare' ],
|
||||
sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] )
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,22 @@ class ImageModelRegistryTest extends BaseTestCase {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runware model ids (added v0.9.0) resolve to the 'runware' provider.
|
||||
* This test pins the v0.9.0 default flip so a registry regression
|
||||
* that loses the Runware entries fails CI loudly.
|
||||
*/
|
||||
public function test_provider_for_runware_models(): void {
|
||||
$this->assertSame(
|
||||
'runware',
|
||||
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:100@1' )
|
||||
);
|
||||
$this->assertSame(
|
||||
'runware',
|
||||
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:101@1' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown model ids return an empty string so callers can distinguish
|
||||
* "not in registry" from a valid provider and show a settings error.
|
||||
|
||||
Reference in New Issue
Block a user