Files
pr-vision/includes/core/class-prv-costs-renderer.php
peptiderepo 39dbe8fcc1 Adopt reusable CI + PHPUnit + fold onto shared deploy (#9)
* v0.3.2 — adopt estate CI: PHPUnit suite + reusable pipeline + governance workflows

- Convert 17 hand-rolled tests to PHPUnit TestCases (tests: stubs pattern)
  P0 security tests preserved: key-leak/encryption (KeyStoreTest),
  capture-allowlist/executor-path sentinel (CaptureWriterTest)
- Add phpunit.xml.dist + update tests/bootstrap.php (Composer autoloader first)
- Add phpunit/phpunit ^9.6 + yoast/phpunit-polyfills ^2.0 to require-dev
- composer test -> vendor/bin/phpunit --configuration phpunit.xml.dist
- ci.yml: thin caller of peptide-e2e reusable CI (tests: stubs, has_js: true,
  workflow_call trigger, permissions: contents: write)
- deploy.yml: estate pattern caller of peptide-e2e deploy-app.yml@main
  (target_path: wp-content/plugins/pr-vision, gated on reusable CI)
  Pull-deploy on Hostinger stays as break-glass
- Add cto-review.yml (Haiku-only, A/B panel dropped 2026-06-12)
- Add main-push-audit.yml (direct-push tripwire)

Agent-Session: claude-adopt-ci-20260616

* style: auto-fix PHPCS violations via phpcbf

* fix: isolate sentinel-constant test + disable js-lint

Two CI failures fixed:

1. PHPUnit constant-scoping bleed: test_p0_executor_path_sentinel_key_does_not_
   leak defines PRV_OPENROUTER_API_KEY as a process-scoped PHP constant.
   Running after CaptureWriter (alphabetically earlier), KeyStoreTest's
   resolver tests saw the canary value instead of empty/option.
   Fix: @runInSeparateProcess + @preserveGlobalState disabled so the
   constant is confined to its own forked PHP process.

2. JS lint job failure: has_js: true caused the reusable CI's lint-js job
   to attempt npm ci, but pr-vision has no package.json.
   Fixed: has_js: false.

Agent-Session: claude/adopt-ci-20260616

* fix: resolve all PHPUnit constant-scoping + crypto + collector test failures

Root causes identified and fixed:

1. Missing salts in bootstrap: derive_encryption_key() returned '' (no 32-byte
   key) because AUTH_KEY/SECURE_AUTH_KEY were undefined. All crypto group tests
   in KeyStoreTest failed as a result. Fix: define AUTH_KEY + SECURE_AUTH_KEY
   in tests/bootstrap.php with deterministic 64-char test salts.

2. PHP constant mid-run redefinition: ConfigVersionTest.setUp() defined
   PRV_OPENROUTER_API_KEY='sk-or-test-key', which bled into KeyStoreTest
   (runs after ConfigVersionTest alphabetically). Same constant was defined in
   5 test classes (ConfigVersion, ProbeRunner, ProjectedCost, RunLock, Uninstall).
   Fix: define PRV_OPENROUTER_API_KEY once in bootstrap; remove per-class defines.
   Update KeyStoreTest expectations to reflect constant-is-always-set reality:
   get_source() => SOURCE_CONSTANT, get_key() => 'sk-or-test-key'.

3. Remaining @runInSeparateProcess complexity: CaptureWriterTest sentinel test
   was using @runInSeparateProcess to avoid constant bleed, but that caused
   error_log() calls inside the executor to surface as PHPUnit\Framework\Exception
   in the child process output. Now that constant is defined in bootstrap, the
   sentinel test runs in the main process and just asserts 'sk-or-test-key'
   doesn't appear in stored rows (equivalent P0 security coverage).

4. DashboardPanelTest collector calls get_results() twice (trendline + standings).
   The wpdb stub returned the same array for both calls; the second call expected
   peptide_slug keys but got trendline keys, causing 'Undefined array key' error.
   Fix: add wpdb_results_queue support to the stub (shifts one result set per
   call when queue is non-empty); update collector tests to use the queue.

Agent-Session: claude/adopt-ci-20260616

* fix: update KeyStoreTest Group 2 for always-defined constant

Group 2 tests (test_get_source_none_when_no_key, test_store_key_writes_encrypted_option,
test_clear_key_removes_option) were still expecting SOURCE_NONE and SOURCE_OPTION.
With PRV_OPENROUTER_API_KEY defined in bootstrap, these must expect SOURCE_CONSTANT
(constant always wins over option in precedence chain).

- Renamed test_get_source_none_when_no_key -> test_get_source_constant_when_constant_defined
- Updated test_store_key_writes_encrypted_option: SOURCE_OPTION -> SOURCE_CONSTANT assertion
- Updated test_clear_key_removes_option: SOURCE_NONE -> SOURCE_CONSTANT assertion

Agent-Session: claude/adopt-ci-20260616

---------

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-06-17 01:16:37 +07:00

298 lines
11 KiB
PHP

<?php
/**
* HTML renderer for the PR Vision Costs admin page (v0.3.0).
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Renders the Costs admin page in the dark "Assay" theme.
*
* Renders: cap progress card, summary bento (3 tiles), model breakdown,
* and the cost drill-down table. All output is escaped at render.
*
* Who triggers: PRV_Costs_Page::render_page().
* Dependencies: PRV_Admin_Page (subnav helper), no DB access.
*
* @see class-prv-costs-page.php — Caller, passes data array.
* @see class-prv-call-log-page.php — Sibling sub-page.
* @see ARCHITECTURE.md — §Admin surfaces v0.3.0.
* @package PrVision
*/
class PRV_Costs_Renderer {
/**
* Render the full Costs page.
*
* Side effects: Outputs HTML.
*
* @param array<string, mixed> $data Keys: mtd_cost, total_calls, avg_cost, cap,
* projected, models, drill, level, offset.
*
* @return void
*/
public function render( array $data ): void {
$mtd_cost = (float) $data['mtd_cost'];
$total_calls = (int) $data['total_calls'];
$avg_cost = (float) $data['avg_cost'];
$cap = (float) $data['cap'];
$projected = (float) $data['projected'];
$models = (array) $data['models'];
$drill = (array) $data['drill'];
$level = (string) $data['level'];
$offset = (int) $data['offset'];
$meter_pct = $cap > 0 ? min( 100, round( ( $mtd_cost / $cap ) * 100 ) ) : 0;
$meter_class = $meter_pct >= 90 ? ' prv-meter--capped' : '';
$projected_warn = $projected > $cap;
echo '<div class="wrap prv-page-wrap">';
$this->render_subnav( 'costs' );
echo '<div class="prv-page-header">';
echo '<h1 class="prv-page-title">' . esc_html__( 'Cost Accountability', 'pr-vision' ) . '</h1>';
echo '<p class="prv-page-sub">' . esc_html__( 'Per-call spend against the $5.00 monthly cap.', 'pr-vision' ) . '</p>';
echo '</div>';
// Cap progress card.
echo '<div class="prv-card prv-card--cap">';
echo '<div class="prv-card-head"><h2>' . esc_html__( 'Budget', 'pr-vision' ) . '</h2></div>';
echo '<div class="prv-card-body">';
echo '<div class="prv-cap-row">';
echo '<div class="prv-cap-left">';
echo '<span class="prv-cost-big">$' . esc_html( number_format( $mtd_cost, 2 ) ) . '</span>';
echo '<span class="prv-cost-cap"> ' . esc_html( sprintf( /* translators: %s: cap amount */ __( 'of $%s', 'pr-vision' ), number_format( $cap, 2 ) ) ) . '</span>';
echo '</div>';
echo '<div class="prv-cap-right">';
$proj_class = $projected_warn ? ' style="color:#FF9D4D"' : '';
echo '<div class="prv-cap-projected"' . $proj_class . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string
echo '<span class="prv-cap-proj-label">' . esc_html__( 'Linear estimate', 'pr-vision' ) . '</span>';
echo '<span class="prv-cap-proj-val">$' . esc_html( number_format( $projected, 2 ) ) . '</span>';
echo '</div>';
$days_left = (int) gmdate( 't' ) - (int) gmdate( 'j' );
echo '<div class="prv-cap-days">' . esc_html( sprintf( /* translators: %d: days */ __( '%d days remaining', 'pr-vision' ), $days_left ) ) . '</div>';
echo '</div>';
echo '</div>';
echo '<div class="prv-meter' . esc_attr( $meter_class ) . '" role="progressbar" aria-valuenow="' . esc_attr( (string) $meter_pct ) . '" aria-valuemin="0" aria-valuemax="100">';
echo '<span style="width:' . esc_attr( $meter_pct . '%' ) . '"></span>';
echo '</div>';
if ( $total_calls > 0 ) {
echo '<p class="prv-cap-reconcile">';
echo esc_html(
sprintf(
/* translators: 1: cost, 2: call count */
__( '$%1$s recorded across %2$d calls this month — approximately reconciles to the Dashboard MTD total.', 'pr-vision' ),
number_format( $mtd_cost, 4 ),
$total_calls
)
);
echo '</p>';
}
echo '</div></div>';
// Summary bento.
echo '<div class="prv-bento">';
$this->render_bento_tile( __( 'Total calls', 'pr-vision' ), (string) $total_calls, __( 'this month', 'pr-vision' ) );
$this->render_bento_tile( __( 'Avg cost / call', 'pr-vision' ), '$' . number_format( $avg_cost, 4 ), '' );
$top_model = ! empty( $models ) ? $models[0]['model'] . ' ($' . number_format( $models[0]['mtd_cost'], 4 ) . ')' : __( '—', 'pr-vision' );
$this->render_bento_tile( __( 'Most expensive model', 'pr-vision' ), $top_model, '' );
echo '</div>';
// Model breakdown table.
$this->render_model_table( $models );
// Drill-down card.
$this->render_drill_down( $drill, $level, $offset );
echo '</div>'; // .wrap
}
/**
* Render the subnav tab strip (shared across all PR Vision pages).
*
* Side effects: Outputs HTML.
*
* @param string $active Active tab key: dashboard|costs|calls|settings.
*
* @return void
*/
public function render_subnav( string $active ): void {
$tabs = array(
'dashboard' => array(
'label' => __( 'Dashboard', 'pr-vision' ),
'slug' => 'pr-vision',
),
'costs' => array(
'label' => __( 'Costs', 'pr-vision' ),
'slug' => 'pr-vision-costs',
),
'calls' => array(
'label' => __( 'Call Log', 'pr-vision' ),
'slug' => 'pr-vision-calls',
),
'settings' => array(
'label' => __( 'Settings', 'pr-vision' ),
'slug' => 'pr-vision-settings',
),
);
echo '<nav class="prv-subnav" aria-label="' . esc_attr__( 'PR Vision navigation', 'pr-vision' ) . '">';
foreach ( $tabs as $key => $tab ) {
$is_active = ( $key === $active );
$aria_current = $is_active ? ' aria-current="page"' : '';
$tab_class = 'prv-subnav-tab' . ( $is_active ? ' prv-subnav-tab--active' : '' );
$url = admin_url( 'admin.php?page=' . rawurlencode( $tab['slug'] ) );
echo '<a href="' . esc_url( $url ) . '" class="' . esc_attr( $tab_class ) . '"' . $aria_current . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- aria-current is safe static string
echo esc_html( $tab['label'] );
echo '</a>';
}
echo '</nav>';
}
/**
* Render a single bento tile.
*
* Side effects: Outputs HTML.
*
* @param string $label Label text.
* @param string $value Primary value.
* @param string $sub Sub-label (empty to omit).
*
* @return void
*/
private function render_bento_tile( string $label, string $value, string $sub ): void {
echo '<div class="prv-tile">';
echo '<div class="prv-tile-label">' . esc_html( $label ) . '</div>';
echo '<div class="prv-tile-big prv-tile-big--sm">' . esc_html( $value ) . '</div>';
if ( '' !== $sub ) {
echo '<div class="prv-tile-sub">' . esc_html( $sub ) . '</div>';
}
echo '</div>';
}
/**
* Render the per-model cost breakdown table.
*
* Side effects: Outputs HTML.
*
* @param array<int, array<string, mixed>> $models Model rows.
*
* @return void
*/
private function render_model_table( array $models ): void {
echo '<div class="prv-card"><div class="prv-card-head"><h2>';
echo esc_html__( 'By Model', 'pr-vision' );
echo '</h2></div><div class="prv-card-body">';
if ( empty( $models ) ) {
echo '<p class="prv-empty-msg">' . esc_html__( 'No cost data yet. Run a probe to begin recording per-call costs.', 'pr-vision' ) . '</p>';
echo '</div></div>';
return;
}
echo '<div class="prv-table-wrap"><table class="prv-standings" cellspacing="0">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Model', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Calls', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'MTD Cost', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Cost / Call', 'pr-vision' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $models as $row ) {
echo '<tr>';
echo '<td><code>' . esc_html( (string) $row['model'] ) . '</code></td>';
echo '<td>' . esc_html( (string) (int) $row['calls'] ) . '</td>';
echo '<td>$' . esc_html( number_format( (float) $row['mtd_cost'], 6 ) ) . '</td>';
echo '<td>$' . esc_html( number_format( (float) $row['cost_per_call'], 6 ) ) . '</td>';
echo '</tr>';
}
echo '</tbody></table></div>';
echo '</div></div>';
}
/**
* Render the drill-down card with level picker and results table.
*
* Side effects: Outputs HTML.
*
* @param array<int, array<string, mixed>> $drill Drill-down rows.
* @param string $level Current aggregation level.
* @param int $offset Current pagination offset.
*
* @return void
*/
private function render_drill_down( array $drill, string $level, int $offset ): void {
echo '<div class="prv-card"><div class="prv-card-head"><h2>';
echo esc_html__( 'Cost Drill-Down', 'pr-vision' );
echo '</h2></div><div class="prv-card-body">';
$levels = array( 'run', 'peptide', 'intent', 'model' );
$base_url = admin_url( 'admin.php?page=pr-vision-costs' );
echo '<div class="prv-seg" role="group" aria-label="' . esc_attr__( 'Aggregation level', 'pr-vision' ) . '">';
foreach ( $levels as $lvl ) {
$is_active = ( $lvl === $level );
$cls = 'prv-seg-btn' . ( $is_active ? ' prv-seg-btn--active' : '' );
$url = add_query_arg(
array(
'prv_level' => $lvl,
'prv_offset' => 0,
),
$base_url
);
echo '<a href="' . esc_url( $url ) . '" class="' . esc_attr( $cls ) . '"' . ( $is_active ? ' aria-current="true"' : '' ) . '>';
echo esc_html( ucfirst( $lvl ) );
echo '</a>';
}
echo '</div>';
if ( empty( $drill ) ) {
echo '<p class="prv-empty-msg">' . esc_html__( 'No data for this level.', 'pr-vision' ) . '</p>';
echo '</div></div>';
return;
}
echo '<div class="prv-table-wrap"><table class="prv-standings" cellspacing="0">';
echo '<thead><tr>';
echo '<th>' . esc_html( ucfirst( $level ) ) . '</th>';
echo '<th>' . esc_html__( 'Calls', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Cost', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'View', 'pr-vision' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $drill as $row ) {
$filter_url = add_query_arg(
array( $this->level_to_filter_key( $level ) => rawurlencode( (string) $row['group_key'] ) ),
admin_url( 'admin.php?page=pr-vision-calls' )
);
echo '<tr>';
echo '<td class="prv-mono">' . esc_html( (string) $row['group_key'] ) . '</td>';
echo '<td>' . esc_html( (string) (int) $row['calls'] ) . '</td>';
echo '<td>$' . esc_html( number_format( (float) $row['total_cost'], 6 ) ) . '</td>';
echo '<td><a href="' . esc_url( $filter_url ) . '">' . esc_html__( 'View calls →', 'pr-vision' ) . '</a></td>';
echo '</tr>';
}
echo '</tbody></table></div>';
echo '</div></div>';
}
/**
* Map drill-down level to call-log filter query param key.
*
* @param string $level Drill-down level: run|peptide|intent|model.
*
* @return string Filter parameter name.
*/
private function level_to_filter_key( string $level ): string {
$map = array(
'run' => 'prv_filter_run',
'peptide' => 'prv_filter_peptide',
'intent' => 'prv_filter_intent',
'model' => 'prv_filter_model',
);
return $map[ $level ] ?? 'prv_filter_run';
}
}