PR Vision v0.2.0: admin/settings interface + correctness fixes (#3)

Model manager (UI-swappable models) + versioned/idempotent prv_models migration; metric comparability (config-version stamping); run-lock; projected cost + truncation state; per-model run-health; cadence reschedule; API-key 3-state; design/a11y fixes (dark v2).

QA: APPROVE-WITH-P2-NOTES — qa-reviews/pr-vision/2026-06-14-ee4371d9163f992f60e09c7e4ac6cf8cd1d3c5eb.md

Agent-Session: pr-vision admin v0.2.0 merge
This commit is contained in:
peptiderepo
2026-06-14 14:48:25 +07:00
committed by GitHub
parent 401077fe45
commit 2fcef7a323
28 changed files with 2923 additions and 388 deletions

View File

@@ -1,6 +1,6 @@
<?php
/**
* Minimal test bootstrap — stubs all WordPress functions used by the plugin
* Minimal test bootstrap -- stubs all WordPress functions used by the plugin
* so classes can be exercised in plain PHP without a WP install.
*
* Pattern mirrors peptide-repo-core's bootstrap (flat PHP, no PHPUnit).
@@ -16,11 +16,11 @@ if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
define( 'PRV_VERSION', '0.1.1' );
define( 'PRV_VERSION', '0.2.0' );
define( 'PRV_PLUGIN_FILE', __DIR__ . '/../pr-vision.php' );
define( 'PRV_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
define( 'PRV_PLUGIN_URL', 'http://example.test/wp-content/plugins/pr-vision/' );
define( 'PRV_SCHEMA_VERSION', 1 );
define( 'PRV_SCHEMA_VERSION', 2 );
define( 'PRV_MAX_RETRIES', 3 );
define( 'PRV_API_TIMEOUT_SECONDS', 60 );
define( 'PRV_RETRY_BASE_DELAY_SECONDS', 2 );
@@ -32,6 +32,7 @@ define( 'PRV_TARGET_DOMAIN', 'peptiderepo.com' );
$GLOBALS['prv_test_state'] = [
'options' => [],
'transients' => [],
'actions' => [],
'wpdb_insert_id' => 1,
'wpdb_results' => [],
@@ -43,6 +44,7 @@ $GLOBALS['prv_test_state'] = [
function prv_test_reset(): void {
$GLOBALS['prv_test_state'] = [
'options' => [],
'transients' => [],
'actions' => [],
'wpdb_insert_id' => 1,
'wpdb_results' => [],
@@ -71,23 +73,41 @@ function add_option( string $name, $value = '' ): bool {
return true;
}
function delete_option( string $name ): bool {
unset( $GLOBALS['prv_test_state']['options'][ $name ] );
return true;
}
function get_transient( string $name ) {
return $GLOBALS['prv_test_state']['transients'][ $name ] ?? false;
}
function set_transient( string $name, $value, int $ttl = 0 ): bool {
$GLOBALS['prv_test_state']['transients'][ $name ] = $value;
return true;
}
function delete_transient( string $name ): bool {
unset( $GLOBALS['prv_test_state']['transients'][ $name ] );
return true;
}
function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool {
$GLOBALS['prv_test_state']['actions'][] = compact( 'hook', 'callback', 'priority' );
return true;
}
function remove_action( string $hook, $callback, int $priority = 10 ): bool {
return true;
}
function remove_action( string $hook, $callback, int $priority = 10 ): bool { return true; }
function add_menu_page( ...$args ): void {}
function add_submenu_page( ...$args ): void {}
function wp_schedule_event( int $timestamp, string $recurrence, string $hook ): void {
$GLOBALS['prv_test_state']['cron_events'][ $hook ] = $timestamp;
$GLOBALS['prv_test_state']['cron_events'][ $hook ] = [ 'timestamp' => $timestamp, 'schedule' => $recurrence ];
}
function wp_next_scheduled( string $hook ) {
return $GLOBALS['prv_test_state']['cron_events'][ $hook ] ?? false;
return $GLOBALS['prv_test_state']['cron_events'][ $hook ]['timestamp'] ?? false;
}
function wp_unschedule_event( int $timestamp, string $hook ): void {
@@ -98,6 +118,14 @@ function wp_clear_scheduled_hook( string $hook ): void {
unset( $GLOBALS['prv_test_state']['cron_events'][ $hook ] );
}
function _get_cron_array(): array {
$out = [];
foreach ( $GLOBALS['prv_test_state']['cron_events'] as $hook => $data ) {
$out[ $data['timestamp'] ][ $hook ] = [ '' => [ 'schedule' => $data['schedule'], 'interval' => 604800 ] ];
}
return $out;
}
function wp_rand( int $min = 0, int $max = 0 ): int {
return random_int( $min ?: 0, $max ?: PHP_INT_MAX );
}
@@ -106,29 +134,15 @@ function current_time( string $type, bool $gmt = false ): string {
return gmdate( 'Y-m-d H:i:s' );
}
function home_url( string $path = '' ): string {
return 'http://example.test' . $path;
}
function home_url( string $path = '' ): string { return 'http://example.test' . $path; }
function __( string $text, string $domain = 'default' ): string {
return $text;
}
function esc_html( string $text ): string {
return htmlspecialchars( $text, ENT_QUOTES );
}
function esc_html__( string $text, string $domain = 'default' ): string {
return esc_html( $text );
}
function esc_js( string $text ): string {
return addslashes( $text );
}
function esc_url( string $url ): string {
return $url;
}
function __( string $text, string $domain = 'default' ): string { return $text; }
function esc_html( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); }
function esc_html__( string $text, string $domain = 'default' ): string { return esc_html( $text ); }
function esc_attr( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); }
function esc_textarea( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); }
function esc_js( string $text ): string { return addslashes( $text ); }
function esc_url( string $url ): string { return $url; }
function wp_json_encode( $data, int $flags = 0 ): string {
return json_encode( $data, $flags ) ?: 'null';
@@ -138,66 +152,50 @@ function wp_parse_url( string $url, int $component = -1 ) {
return parse_url( $url, $component );
}
function absint( $value ): int {
return abs( (int) $value );
}
function sanitize_text_field( string $str ): string {
return trim( $str );
}
function current_user_can( string $cap ): bool {
return true;
}
function absint( $value ): int { return abs( (int) $value ); }
function sanitize_text_field( string $str ): string { return trim( $str ); }
function sanitize_textarea_field( string $str ): string { return trim( $str ); }
function wp_unslash( $value ) { return is_string( $value ) ? stripslashes( $value ) : $value; }
function current_user_can( string $cap ): bool { return true; }
function wp_die( $message = '', $code = 0 ): void {
throw new \RuntimeException( is_string( $message ) ? $message : 'wp_die' );
}
function wp_verify_nonce( string $nonce, string $action ): bool {
return true;
}
function wp_nonce_field( string $action, string $name ): string {
return '';
}
function wp_verify_nonce( string $nonce, string $action ): bool { return true; }
function wp_nonce_field( string $action, string $name, bool $referer = true, bool $echo = true ): string { return ''; }
function wp_create_nonce( string $action ): string { return 'test_nonce'; }
function submit_button( string $text, string $type = 'primary', string $name = 'submit', bool $wrap = true ): void {}
function add_query_arg( array $args, string $url ): string {
return $url . '?' . http_build_query( $args );
}
function admin_url( string $path ): string {
return 'http://example.test/wp-admin/' . ltrim( $path, '/' );
}
function add_query_arg( array $args, string $url ): string { return $url . '?' . http_build_query( $args ); }
function admin_url( string $path ): string { return 'http://example.test/wp-admin/' . ltrim( $path, '/' ); }
function wp_safe_redirect( string $url ): void {}
function wp_send_json_success( $data = null ): void { throw new \RuntimeException( 'json_success:' . json_encode( $data ) ); }
function wp_send_json_error( $data = null ): void { throw new \RuntimeException( 'json_error:' . json_encode( $data ) ); }
function is_wp_error( $thing ): bool {
return $thing instanceof WP_Error;
function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; }
function selected( $selected, $current = true, bool $echo = true ): string {
$str = ( (string) $selected === (string) $current ) ? ' selected="selected"' : '';
if ( $echo ) { echo $str; }
return $str;
}
function wp_remote_post( string $url, array $args = array() ) {
// Return from test queue if set, else a generic success.
$queue = &$GLOBALS['prv_test_state']['remote_posts'];
if ( ! empty( $queue ) ) {
return array_shift( $queue );
}
if ( ! empty( $queue ) ) { return array_shift( $queue ); }
return array( 'response' => array( 'code' => 200 ), 'body' => '{"choices":[{"message":{"content":"test"}}],"citations":[],"usage":{"total_tokens":100}}' );
}
function wp_remote_retrieve_response_code( $response ): int {
return (int) ( $response['response']['code'] ?? 200 );
}
function wp_remote_retrieve_body( $response ): string { return (string) ( $response['body'] ?? '' ); }
function wp_remote_retrieve_header( $response, string $header ): string { return (string) ( $response['headers'][ $header ] ?? '' ); }
function wp_remote_retrieve_body( $response ): string {
return (string) ( $response['body'] ?? '' );
}
function wp_remote_retrieve_header( $response, string $header ): string {
return (string) ( $response['headers'][ $header ] ?? '' );
}
function is_admin(): bool { return false; }
function wp_enqueue_script( ...$args ): void {}
function wp_add_inline_style( ...$args ): void {}
class WP_Error {
public function __construct( private string $code = '', private string $message = '' ) {}
@@ -207,10 +205,11 @@ class WP_Error {
/* ── Minimal $wpdb stub ────────────────────────────────────────────────── */
class stdClass_wpdb {
public string $prefix = 'wp_';
public string $prefix = 'wp_';
public int $insert_id = 0;
public array $_results = [];
public mixed $_var = null;
public string $options = 'wp_options';
public function get_charset_collate(): string {
return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci';
@@ -223,15 +222,11 @@ class stdClass_wpdb {
$this->insert_id = $GLOBALS['prv_test_state']['wpdb_insert_id']++;
return 1;
}
public function update( string $table, array $data, array $where, array $data_format = [], array $where_format = [] ): int {
return 1;
}
public function update( string $table, array $data, array $where, array $df = [], array $wf = [] ): int { return 1; }
public function get_results( string $query, string $output = 'OBJECT' ): array {
return is_array( $GLOBALS['prv_test_state']['wpdb_results'] ) ? $GLOBALS['prv_test_state']['wpdb_results'] : [];
}
public function get_var( string $query ): mixed {
return $GLOBALS['prv_test_state']['wpdb_var'];
}
public function get_var( string $query ): mixed { return $GLOBALS['prv_test_state']['wpdb_var']; }
}
$wpdb = new stdClass_wpdb();
@@ -255,15 +250,15 @@ function prv_assert( bool $condition, string $label ): void {
function prv_assert_equals( $expected, $actual, string $label ): void {
$pass = ( $expected === $actual );
prv_assert( $pass, $label . ( $pass ? '' : ' — expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ) ) );
prv_assert( $pass, $label . ( $pass ? '' : ' -- expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ) ) );
}
function prv_assert_throws( callable $fn, string $exception_class, string $label ): void {
try {
$fn();
prv_assert( false, $label . ' — expected ' . $exception_class . ' but no exception thrown' );
prv_assert( false, $label . ' -- expected ' . $exception_class . ' but no exception thrown' );
} catch ( \Throwable $e ) {
prv_assert( $e instanceof $exception_class, $label . ' — got ' . get_class( $e ) );
prv_assert( $e instanceof $exception_class, $label . ' -- got ' . get_class( $e ) );
}
}
@@ -273,9 +268,7 @@ function prv_test_summary(): int {
echo "Totals: {$r['pass']} passed, {$r['fail']} failed\n";
if ( $r['fail'] > 0 ) {
echo "Failures:\n";
foreach ( $r['failures'] as $f ) {
echo " - {$f}\n";
}
foreach ( $r['failures'] as $f ) { echo " - {$f}\n"; }
return 1;
}
return 0;