diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b86110c..dbf480d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -120,14 +120,17 @@ prautoblogger/ │ ├── css/ │ │ ├── admin.css # Admin page styles (wp-admin conventions) │ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0) +│ │ ├── dossier.css # Article dossier page styles: verdict pills, cost receipt, trace toggle (v0.19.2) │ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars) │ └── js/ │ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js) │ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0) +│ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2) │ └── posts-widget.js # React component for frontend post cards (wp.element) │ ├── includes/ -│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution +│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (258 lines; split v0.19.2) +│ ├── class-db-migrations.php # DB migration methods extracted from class-prautoblogger.php (v0.19.2 split, binding 1) │ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages) │ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration │ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry @@ -140,8 +143,11 @@ prautoblogger/ │ │ │ ├── admin/ │ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0) -│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot, delegates gen_log queries (v0.19.0) +│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2) │ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0) +│ │ ├── class-dossier-page.php # Article dossier admin page: hidden-submenu, priority 12, enqueue, nonce (v0.19.2) +│ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2) +│ │ ├── class-dossier-gen-log-query.php # Dossier gen_log queries: per-stage cost receipt + raw trace (v0.19.2) │ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI) │ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources) │ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images @@ -245,11 +251,14 @@ prautoblogger/ ├── templates/ │ └── admin/ │ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0) +│ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2) +│ ├── dossier-stage-section.php # Per-stage block: rendered output + raw-trace toggle + cost receipt (v0.19.2) +│ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2) │ ├── settings-page.php # Settings page template (tabbed sidebar layout) │ ├── metrics-page.php # Metrics/cost dashboard template │ ├── review-queue.php # Review queue table template │ ├── log-viewer.php # Activity log viewer template -│ └── metabox-generation-info.php# Post metabox template +│ └── metabox-generation-info.php# Post metabox template (superseded by metabox-dossier-link.php in v0.19.2) │ ├── languages/ │ └── prautoblogger.pot # i18n translation template @@ -887,6 +896,61 @@ checks `prautoblogger_page_prautoblogger-board` and would similarly never fire w wrong hookname (board.css/board.js would silently not load). Regression test: `tests/unit/Admin/BoardMenuRegistrationTest.php`. + +### #23: Article Dossier page — per-article inspection (v0.19.2) + +The dossier is a read-only per-article admin page that exposes the full substrate view for +a single post: generation run, stage I/O, cost receipts, raw-trace toggles, editorial +decisions, and image A/B pairs. It is link-accessed only (hidden submenu) — no navigation +entry appears in the WP sidebar. + +**Page registration** (`class-dossier-page.php`): registered as a hidden submenu under +`prautoblogger-settings` at `admin_menu` priority **12**, one step after the board (priority +11), which is itself after the parent menu (priority 10). The page is immediately removed +from `$submenu` so it does not appear in the WP admin sidebar, but remains reachable by +direct URL. URL shape: `admin.php?page=prautoblogger-dossier&post_id=`. Secured by +`manage_options` capability + nonce (`prautoblogger_dossier`). Static `url_for_post(int +$post_id): string` provides the canonical deep-link. Regression test: +`tests/unit/Admin/DossierMenuRegistrationTest.php`. + +**5-query data assembly** (`class-dossier-data-assembler.php`): + +``` +1. wp_prautoblogger_runs WHERE run_id = %s -- 1 row (run metadata) +2. wp_prautoblogger_run_stages WHERE run_id = %s -- N rows (per-stage status/cost) +3. wp_prautoblogger_generation_log WHERE run_id = %s -- N rows (model/pv/tokens/request) +4. wp_prautoblogger_run_decisions WHERE run_id = %s -- N rows (editorial rationale) +5. get_post_meta($post_id) -- WP object cache hit +``` + +All queries are keyed on `run_id` (indexed). No N+1. Stage output is reconstructed from +`run_stages.meta_json → json_decode → meta['output']` (same path as `Run_Stage_State:: +get_output()`). The dossier assembler does NOT call `Run_Stage_State` directly; it performs +its own raw queries so the view is not coupled to the cache layer. + +**Legacy graceful state (binding 5):** Posts published before v0.18.0 (no runs/run_stages +rows) and posts where `_prautoblogger_run_id` is absent or blank return `['has_run' => false]`. +The template renders a clean "No generation record for this article" notice. No PHP notices +or fatals. + +**Amortized research rows:** `generation_log` rows with `prompt_version = NULL` and +`agent_role = ''` (from the `llm_research` stage orphan-reaper path) are rendered with +model shown as "—" and no raw-trace toggle (request_json may be NULL). No fatal/notice. + +**Raw-trace security (binding 4):** `request_json` stores the OpenRouter request body — +model identifier, messages array, temperature. Authorization headers are never logged (the +outbound HTTP client strips them before logging, per the original architecture decision on +`wp_prautoblogger_generation_log`). The dossier template renders `request_json` via +`esc_html()` inside a `
` block. All model-generated text rendered via `wp_kses_post()`
+(rendered view) or `esc_html()` (raw trace). Both paths treat content as untrusted HTML.
+
+**File naming note:** `PRAutoBlogger_DB_Migrations` is loaded via explicit `require_once`
+in `prautoblogger.php` (not the autoloader). The autoloader converts `DB_Migrations` to the
+kebab segment `d-b-migrations`, producing `class-d-b-migrations.php` — which does not match
+the file `class-db-migrations.php`. Explicit loading is the canonical pattern for classes
+whose names do not round-trip cleanly through the kebab converter. `class-prautoblogger.php`
+uses the same pattern (also explicitly required in `prautoblogger.php`).
+
 ## Cross-System LLM Budget Coordination
 
 PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a46d12b..772e01c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,42 @@ All notable changes to PRAutoBlogger will be documented in this file.
 Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project uses [Semantic Versioning](https://semver.org/).
 
+## [0.19.2] - 2026-06-12
+
+### Added
+- **Article Dossier page (M2).** Per-article read-only inspection page at
+  `admin.php?page=prautoblogger-dossier&post_id=`. Accessible via board card
+  deep-links and the slim post metabox link. Shows: header (title, verdict pill, tier,
+  status), sidebar (run metadata, per-stage cost receipt with model + prompt-version),
+  vertical stage sections (rendered output default, per-stage raw-trace toggle exposing
+  `generation_log` request JSON + tokens + cost + role), editorial decisions rationale,
+  image A/B pair sections.
+- **Board card deep-links.** All Kanban cards now deep-link directly to the article
+  dossier page (`dossier_url` on card data; `click_action = dossier` in board JS).
+- **Slim post metabox.** `PRAutoBlogger_Post_Metabox` now renders a single "View
+  generation dossier →" link (was a verbose generation-metadata dump).
+- **Dossier CSS/JS.** `assets/css/dossier.css` (warm editorial palette, verdict pills,
+  cost receipt, raw-trace toggle) + `assets/js/dossier.js` (vanilla JS toggle,
+  `aria-expanded` / `aria-controls` accessible).
+- **Menu registration test coverage.** `DossierMenuRegistrationTest.php` — asserts
+  dossier hook fires at priority > board (> parent), uses hidden-submenu pattern,
+  correct slug, and `url_for_post()` URL shape. Mirrors `BoardMenuRegistrationTest`.
+- **Raw-trace credential check (binding 4).** `request_json` column contains only
+  OpenRouter request bodies (model, messages, temperature — no Authorization headers
+  per architecture). All model output escaped via `esc_html()` / `wp_kses_post()`;
+  treated as untrusted HTML throughout the render path.
+- **Legacy graceful state (binding 5).** Posts with no run record (pre-v0.18.0) render
+  a clean "No generation record" notice — no notices, no fatals. Amortized research
+  rows (pv=null, role='') render model as "—" with no raw-trace toggle.
+
+### Changed
+- **`class-prautoblogger.php` → `class-db-migrations.php` split (binding 1).**
+  All DB-migration methods extracted byte-identically into new static class
+  `PRAutoBlogger_DB_Migrations` (`includes/class-db-migrations.php`, 129 lines).
+  `class-prautoblogger.php` shrinks from 407 → 258 lines; delegates via thin proxy.
+  Loaded via explicit `require_once` in `prautoblogger.php` (autoloader filename
+  collision avoidance — see ARCHITECTURE.md §File Naming Notes).
+
 ## [0.19.1] - 2026-06-12
 
 ### Fixed
diff --git a/assets/css/dossier.css b/assets/css/dossier.css
new file mode 100644
index 0000000..259b2b7
--- /dev/null
+++ b/assets/css/dossier.css
@@ -0,0 +1,441 @@
+/**
+ * PRAutoBlogger Article Dossier styles (M2).
+ *
+ * Design contract: Proposal C "Editorial Record" -- warm editorial,
+ * generous whitespace, typographic hierarchy, verdict pills, receipt
+ * sidebar. Scoped to .prab-dossier-wrap to avoid WP admin conflicts.
+ *
+ * @see admin/class-dossier-page.php
+ * @see templates/admin/dossier-page.php
+ */
+
+/* ── Layout ──────────────────────────────────────────────────────────── */
+
+.prab-dossier-wrap {
+	max-width: 1400px;
+}
+
+.prab-dossier-back {
+	margin-bottom: 12px;
+}
+
+.prab-dossier-back a {
+	color: #1B8A92;
+	text-decoration: none;
+}
+
+.prab-dossier-back a:hover {
+	text-decoration: underline;
+}
+
+.prab-dossier--empty {
+	background: #fff;
+	border: 1px solid #ddd;
+	border-radius: 4px;
+	padding: 32px;
+}
+
+.prab-dossier-empty-notice h2 {
+	font-size: 1.3em;
+	margin: 0 0 12px;
+	color: #444;
+}
+
+.prab-dossier {
+	background: #fff;
+}
+
+/* ── Header ──────────────────────────────────────────────────────────── */
+
+.prab-dossier-header {
+	display: flex;
+	align-items: flex-start;
+	justify-content: space-between;
+	padding: 24px 28px 20px;
+	border-bottom: 1px solid #e8e0d8;
+	background: #FAF6EE;
+	border-radius: 4px 4px 0 0;
+}
+
+.prab-dossier-title {
+	font-size: 1.6em;
+	margin: 0 0 8px;
+	color: #1a1a1a;
+	line-height: 1.3;
+}
+
+.prab-dossier-header-meta {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	flex-wrap: wrap;
+}
+
+/* ── Body: two-column ─────────────────────────────────────────────────── */
+
+.prab-dossier-body {
+	display: flex;
+	align-items: flex-start;
+	gap: 0;
+}
+
+.prab-dossier-main {
+	flex: 1;
+	min-width: 0;
+	padding: 24px 28px;
+}
+
+.prab-dossier-sidebar {
+	width: 260px;
+	flex-shrink: 0;
+	padding: 24px 20px;
+	border-left: 1px solid #e8e0d8;
+}
+
+/* ── Verdict pills ────────────────────────────────────────────────────── */
+
+.prab-verdict-pill {
+	display: inline-block;
+	padding: 3px 10px;
+	border-radius: 12px;
+	font-size: 11px;
+	font-weight: 600;
+	text-transform: uppercase;
+	letter-spacing: 0.04em;
+}
+
+.prab-verdict-pill--approved  { background: #e6f5ee; color: #1a7a45; }
+.prab-verdict-pill--revised   { background: #fff4e0; color: #a06000; }
+.prab-verdict-pill--rejected  { background: #fde8e8; color: #b91c1c; }
+.prab-verdict-pill--halted    { background: #f0f0f0; color: #555; }
+.prab-verdict-pill--pending   { background: #e8f0fe; color: #1a55a0; }
+
+/* ── Status pills ─────────────────────────────────────────────────────── */
+
+.prab-dossier-tier {
+	font-size: 12px;
+	color: #666;
+	padding: 2px 8px;
+	border: 1px solid #ddd;
+	border-radius: 10px;
+}
+
+.prab-dossier-status {
+	font-size: 12px;
+	color: #777;
+}
+
+/* ── Stage sections ───────────────────────────────────────────────────── */
+
+.prab-dossier-section {
+	margin-bottom: 28px;
+	border: 1px solid #e5ddd5;
+	border-radius: 4px;
+	overflow: hidden;
+}
+
+.prab-dossier-section--stage {
+	border-left: 4px solid #ccc;
+}
+
+.prab-stage-status--done    { border-left-color: #1B8A92; }
+.prab-stage-status--running { border-left-color: #FF8A3D; }
+.prab-stage-status--failed  { border-left-color: #e53e3e; }
+.prab-stage-status--pending { border-left-color: #a0aec0; }
+
+.prab-stage-header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	padding: 12px 16px;
+	background: #faf8f5;
+	border-bottom: 1px solid #ede6dd;
+}
+
+.prab-section-title {
+	font-size: 1em;
+	font-weight: 600;
+	margin: 0;
+	color: #2d2d2d;
+}
+
+.prab-stage-role {
+	font-weight: 400;
+	color: #888;
+	font-size: 0.9em;
+	margin-left: 4px;
+}
+
+.prab-stage-header-meta {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	font-size: 12px;
+	color: #666;
+}
+
+.prab-stage-status-pill {
+	display: inline-block;
+	padding: 2px 8px;
+	border-radius: 10px;
+	font-size: 11px;
+	font-weight: 600;
+}
+
+.prab-stage-status-pill--done    { background: #e6f5ee; color: #1a7a45; }
+.prab-stage-status-pill--running { background: #fff4e0; color: #a06000; }
+.prab-stage-status-pill--failed  { background: #fde8e8; color: #b91c1c; }
+.prab-stage-status-pill--pending { background: #f0f0f0; color: #555; }
+
+.prab-stage-cost {
+	color: #1B8A92;
+	font-weight: 600;
+}
+
+.prab-stage-pv {
+	background: #f0f0f0;
+	padding: 1px 6px;
+	border-radius: 8px;
+	font-size: 11px;
+}
+
+/* ── Stage output ─────────────────────────────────────────────────────── */
+
+.prab-stage-output {
+	padding: 16px;
+	line-height: 1.7;
+}
+
+.prab-stage-output-rendered {
+	font-size: 14px;
+	color: #333;
+	max-height: 480px;
+	overflow-y: auto;
+}
+
+.prab-output-pruned,
+.prab-output-absent {
+	color: #888;
+	font-style: italic;
+	margin: 0;
+}
+
+/* ── Raw trace toggle ─────────────────────────────────────────────────── */
+
+.prab-stage-trace {
+	padding: 0 16px 12px;
+	border-top: 1px solid #f0ece6;
+}
+
+.prab-trace-toggle {
+	font-size: 12px;
+	color: #1B8A92;
+	cursor: pointer;
+	padding: 6px 0;
+	background: none;
+	border: none;
+}
+
+.prab-trace-toggle:hover {
+	color: #0d6066;
+}
+
+.prab-trace-detail {
+	margin-top: 8px;
+}
+
+.prab-trace-entry {
+	background: #f7f4ef;
+	border: 1px solid #e5ddd5;
+	border-radius: 4px;
+	padding: 10px 12px;
+	margin-bottom: 8px;
+}
+
+.prab-trace-meta {
+	display: flex;
+	gap: 12px;
+	font-size: 11px;
+	color: #666;
+	margin-bottom: 6px;
+	flex-wrap: wrap;
+}
+
+.prab-trace-status--success { color: #1a7a45; }
+.prab-trace-status--error   { color: #b91c1c; }
+
+.prab-trace-request summary {
+	font-size: 12px;
+	cursor: pointer;
+	color: #888;
+	padding: 4px 0;
+}
+
+.prab-trace-pre {
+	background: #1a1a1a;
+	color: #e2e8f0;
+	padding: 10px;
+	border-radius: 3px;
+	font-size: 11px;
+	overflow-x: auto;
+	max-height: 300px;
+	white-space: pre-wrap;
+	word-break: break-all;
+}
+
+.prab-trace-error {
+	color: #b91c1c;
+	font-size: 12px;
+	margin: 4px 0 0;
+}
+
+/* ── Stage timestamps ─────────────────────────────────────────────────── */
+
+.prab-stage-timestamps {
+	padding: 4px 16px 10px;
+	font-size: 11px;
+	color: #aaa;
+}
+
+.prab-ts-sep {
+	margin: 0 4px;
+}
+
+/* ── Decisions section ───────────────────────────────────────────────── */
+
+.prab-dossier-section--decisions {
+	padding: 16px;
+}
+
+.prab-dossier-section--decisions .prab-section-title {
+	font-size: 1.1em;
+	margin-bottom: 12px;
+}
+
+.prab-decision-row {
+	display: flex;
+	align-items: flex-start;
+	gap: 10px;
+	flex-wrap: wrap;
+	margin-bottom: 12px;
+	padding-bottom: 12px;
+	border-bottom: 1px solid #f0ece6;
+}
+
+.prab-decision-row:last-child {
+	border-bottom: none;
+	margin-bottom: 0;
+	padding-bottom: 0;
+}
+
+.prab-decision-stage {
+	font-weight: 600;
+	font-size: 13px;
+	min-width: 120px;
+}
+
+.prab-decision-rationale {
+	margin: 4px 0 0;
+	width: 100%;
+	color: #555;
+	font-size: 13px;
+}
+
+/* ── Sidebar ──────────────────────────────────────────────────────────── */
+
+.prab-sidebar-card {
+	background: #faf8f5;
+	border: 1px solid #e5ddd5;
+	border-radius: 4px;
+	padding: 14px 16px;
+	margin-bottom: 16px;
+}
+
+.prab-sidebar-heading {
+	font-size: 12px;
+	font-weight: 700;
+	text-transform: uppercase;
+	letter-spacing: 0.06em;
+	color: #888;
+	margin: 0 0 10px;
+}
+
+.prab-sidebar-dl {
+	margin: 0;
+	display: grid;
+	grid-template-columns: auto 1fr;
+	gap: 4px 10px;
+	font-size: 12px;
+}
+
+.prab-sidebar-dl dt {
+	color: #888;
+	white-space: nowrap;
+}
+
+.prab-sidebar-dl dd {
+	margin: 0;
+	color: #333;
+	word-break: break-all;
+}
+
+.prab-run-id {
+	font-family: monospace;
+	font-size: 10px;
+}
+
+/* ── Cost receipt ─────────────────────────────────────────────────────── */
+
+.prab-receipt-table {
+	width: 100%;
+	border-collapse: collapse;
+	font-size: 12px;
+}
+
+.prab-receipt-table td {
+	padding: 3px 0;
+}
+
+.prab-receipt-stage {
+	color: #555;
+}
+
+.prab-receipt-cost {
+	text-align: right;
+	color: #1B8A92;
+	font-family: monospace;
+}
+
+.prab-receipt-total {
+	border-top: 1px solid #ddd;
+	font-weight: 700;
+}
+
+.prab-receipt-total td {
+	padding-top: 6px;
+}
+
+/* ── Run status ───────────────────────────────────────────────────────── */
+
+.prab-run-status {
+	font-size: 12px;
+}
+
+.prab-run-status--done    { color: #1a7a45; }
+.prab-run-status--running { color: #a06000; }
+.prab-run-status--failed  { color: #b91c1c; }
+
+/* ── No stages ─────────────────────────────────────────────────────────── */
+
+.prab-dossier-no-stages {
+	padding: 24px 0;
+	color: #888;
+}
+
+/* ── Metabox ──────────────────────────────────────────────────────────── */
+
+.prab-metabox-dossier .prab-run-id-label {
+	font-size: 11px;
+	color: #999;
+	margin-top: 4px;
+}
diff --git a/assets/js/board.js b/assets/js/board.js
index f06e566..38f697a 100644
--- a/assets/js/board.js
+++ b/assets/js/board.js
@@ -93,7 +93,10 @@
 		// Click-through link.
 		var linkUrl  = '';
 		var linkText = '';
-		if ( card.click_action === 'review' && card.review_url ) {
+		if ( card.click_action === 'dossier' && card.dossier_url ) {
+			linkUrl  = card.dossier_url;
+			linkText = cfg.strings.view;
+		} else if ( card.click_action === 'review' && card.review_url ) {
 			linkUrl  = card.review_url;
 			linkText = cfg.strings.view;
 		} else if ( card.click_action === 'edit' && card.edit_url ) {
diff --git a/assets/js/dossier.js b/assets/js/dossier.js
new file mode 100644
index 0000000..4a597fe
--- /dev/null
+++ b/assets/js/dossier.js
@@ -0,0 +1,41 @@
+/**
+ * PRAutoBlogger Article Dossier -- raw-trace toggle (M2).
+ *
+ * Toggles the hidden raw-trace 
per stage when the "Show raw trace" + * button is clicked. Vanilla JS, no build step, no external deps. + * + * Accessible: uses aria-expanded + aria-controls; respects the hidden attribute. + * + * @see admin/class-dossier-page.php -- Enqueues this file. + * @see templates/admin/dossier-page.php -- Renders the toggle buttons. + */ +( function () { + 'use strict'; + + document.addEventListener( 'DOMContentLoaded', function () { + var toggles = document.querySelectorAll( '.prab-trace-toggle' ); + + toggles.forEach( function ( btn ) { + btn.addEventListener( 'click', function () { + var targetId = btn.getAttribute( 'aria-controls' ); + if ( ! targetId ) { + return; + } + var panel = document.getElementById( targetId ); + if ( ! panel ) { + return; + } + var isOpen = btn.getAttribute( 'aria-expanded' ) === 'true'; + if ( isOpen ) { + panel.hidden = true; + btn.setAttribute( 'aria-expanded', 'false' ); + btn.textContent = btn.textContent.replace( /hide/i, 'Show' ); + } else { + panel.hidden = false; + btn.setAttribute( 'aria-expanded', 'true' ); + btn.textContent = btn.textContent.replace( /show/i, 'Hide' ); + } + } ); + } ); + } ); +}() ); diff --git a/includes/admin/class-board-data-provider.php b/includes/admin/class-board-data-provider.php index 35a25dc..d2fbf15 100644 --- a/includes/admin/class-board-data-provider.php +++ b/includes/admin/class-board-data-provider.php @@ -13,15 +13,19 @@ declare(strict_types=1); * PRAutoBlogger_Board_Gen_Log_Query. WP_Query columns (In Review, Published) * are handled here. * + * M2: cards now include `dossier_url` so board.js can deep-link to the + * Article Dossier instead of the old per-column destinations. + * * No schema changes -- all data comes from tables that already exist. * * Triggered by: PRAutoBlogger_Board_Page::on_ajax_board_status(). - * Dependencies: PRAutoBlogger_Board_Gen_Log_Query, WordPress WP_Query, get_option(). + * Dependencies: PRAutoBlogger_Board_Gen_Log_Query, PRAutoBlogger_Dossier_Page, + * WordPress WP_Query, get_option(). * - * @see admin/class-board-gen-log-query.php -- Raw generation-log queries (Generating/Failed). + * @see admin/class-board-gen-log-query.php -- Raw gen_log queries (Generating/Failed). * @see admin/class-board-page.php -- Calls get_board_snapshot(). - * @see core/class-cost-reporter.php -- Parallel cost-query pattern. - * @see ARCHITECTURE.md -- Database schema (generation_log, post meta). + * @see admin/class-dossier-page.php -- Provides url_for_post() for deep-links. + * @see ARCHITECTURE.md -- Database schema. */ class PRAutoBlogger_Board_Data_Provider { @@ -38,9 +42,6 @@ class PRAutoBlogger_Board_Data_Provider { /** * Return the full board snapshot consumed by the AJAX poller. * - * Each column is an array of card objects. Cards contain only the data - * needed to render the board card and provide click-throughs. - * * @return array{ * generating: array>, * in_review: array>, @@ -66,7 +67,6 @@ class PRAutoBlogger_Board_Data_Provider { /** * Cards for runs that are currently in-progress. - * Delegates to PRAutoBlogger_Board_Gen_Log_Query. * * @return array> */ @@ -76,7 +76,6 @@ class PRAutoBlogger_Board_Data_Provider { /** * Cards for generation runs that ended in an error state. - * Delegates to PRAutoBlogger_Board_Gen_Log_Query. * * @return array> */ @@ -86,6 +85,7 @@ class PRAutoBlogger_Board_Data_Provider { /** * Cards for generated draft posts awaiting human review. + * M2: cards include dossier_url for deep-linking. * * @return array> */ @@ -120,7 +120,8 @@ class PRAutoBlogger_Board_Data_Provider { 'title' => $post->post_title, 'cost_total' => $cost_total, 'created_at' => strtotime( $post->post_date_gmt ), - 'click_action' => 'review', + 'click_action' => 'dossier', + 'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ), 'review_url' => admin_url( 'admin.php?page=prautoblogger-review-queue' ), 'edit_url' => get_edit_post_link( $post->ID, 'raw' ), ); @@ -131,6 +132,7 @@ class PRAutoBlogger_Board_Data_Provider { /** * Cards for published posts within the configured window. + * M2: cards include dossier_url for deep-linking. * * @return array> */ @@ -174,7 +176,8 @@ class PRAutoBlogger_Board_Data_Provider { 'title' => $post->post_title, 'cost_total' => $cost_total, 'published_at' => strtotime( $post->post_date_gmt ), - 'click_action' => 'edit', + 'click_action' => 'dossier', + 'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ), 'edit_url' => get_edit_post_link( $post->ID, 'raw' ), 'post_url' => get_permalink( $post->ID ), ); diff --git a/includes/admin/class-dossier-data-assembler.php b/includes/admin/class-dossier-data-assembler.php new file mode 100644 index 0000000..23a8359 --- /dev/null +++ b/includes/admin/class-dossier-data-assembler.php @@ -0,0 +1,191 @@ + false] -- never notices. + * Amortized research rows (pv=null, role='') render gracefully. + * + * Triggered by: PRAutoBlogger_Dossier_Page::render_page(). + * Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map, + * PRAutoBlogger_Run_Stage_State. + * + * @see admin/class-dossier-gen-log-query.php -- Raw generation_log queries. + * @see core/class-run-stage-state.php -- Stage I/O access. + * @see ARCHITECTURE.md -- Substrate schema. + */ +class PRAutoBlogger_Dossier_Data_Assembler { + + /** @var PRAutoBlogger_Dossier_Gen_Log_Query */ + private PRAutoBlogger_Dossier_Gen_Log_Query $log_query; + + /** + * @param PRAutoBlogger_Dossier_Gen_Log_Query|null $log_query Injectable for tests. + */ + public function __construct( ?PRAutoBlogger_Dossier_Gen_Log_Query $log_query = null ) { + $this->log_query = $log_query ?? new PRAutoBlogger_Dossier_Gen_Log_Query(); + } + + /** + * Assemble the full dossier view model for a post. + * + * @param int $post_id WordPress post ID (0 = bogus/not found). + * @return array View model consumed by dossier-page.php. + */ + public function assemble( int $post_id ): array { + $base = array( + 'post_id' => $post_id, + 'has_run' => false, + 'post_title' => '', + 'post_status' => '', + 'verdict' => '', + 'tier' => '', + 'run_id' => '', + 'run' => null, + 'stages' => array(), + 'decisions' => array(), + 'gen_log_index' => array(), + ); + + if ( $post_id <= 0 ) { + return $base; + } + + $post = get_post( $post_id ); + if ( ! ( $post instanceof WP_Post ) ) { + return $base; + } + + $base['post_title'] = $post->post_title; + $base['post_status'] = $post->post_status; + $base['verdict'] = (string) get_post_meta( $post_id, '_prautoblogger_editor_verdict', true ); + $base['tier'] = (string) get_post_meta( $post_id, '_prautoblogger_article_type', true ); + + $run_id = (string) get_post_meta( $post_id, '_prautoblogger_run_id', true ); + if ( '' === $run_id ) { + return $base; + } + $base['run_id'] = $run_id; + + $run = $this->get_run_row( $run_id ); + if ( null === $run ) { + return $base; + } + + $base['has_run'] = true; + $base['run'] = $run; + $base['stages'] = $this->get_stage_rows( $run_id ); + $base['decisions'] = $this->get_decision_rows( $run_id ); + $base['gen_log_index'] = $this->log_query->get_by_run( $run_id ); + + return $base; + } + + /** + * Fetch the run ledger row. + * + * @param string $run_id Pipeline run UUID. + * @return array|null + */ + private function get_run_row( string $run_id ): ?array { + global $wpdb; + if ( null === $wpdb ) { + return null; + } + $table = $wpdb->prefix . 'prautoblogger_runs'; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s", $run_id ), + ARRAY_A + ); + return is_array( $row ) ? $row : null; + } + + /** + * Fetch all run_stages rows for a run, indexed by stage name. + * + * @param string $run_id Pipeline run UUID. + * @return array> Stage rows keyed by stage name. + */ + private function get_stage_rows( string $run_id ): array { + if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) { + return array(); + } + global $wpdb; + if ( null === $wpdb ) { + return array(); + } + $table = PRAutoBlogger_Run_Stage_State::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ), + ARRAY_A + ); + + $index = array(); + foreach ( ( $rows ?? array() ) as $row ) { + if ( ! is_array( $row ) ) { + continue; + } + $stage = (string) ( $row['stage'] ?? '' ); + $role_key = $stage . ':' . ( $row['agent_role'] ?? '' ); + $row['display_output'] = $this->extract_output( $row ); + $index[ $role_key ] = $row; + } + return $index; + } + + /** + * Extract the text output from a run_stages meta_json blob. + * Returns null when meta_json is NULL (pruned) or absent. + * + * @param array $row Stage row. + * @return string|null + */ + private function extract_output( array $row ): ?string { + if ( empty( $row['meta_json'] ) ) { + return null; + } + $meta = json_decode( (string) $row['meta_json'], true ); + return ( is_array( $meta ) && isset( $meta['output'] ) ) ? (string) $meta['output'] : null; + } + + /** + * Fetch all run_decisions rows, indexed by stage name. + * + * @param string $run_id Pipeline run UUID. + * @return array> Decision rows keyed by stage. + */ + private function get_decision_rows( string $run_id ): array { + global $wpdb; + if ( null === $wpdb ) { + return array(); + } + $table = $wpdb->prefix . 'prautoblogger_run_decisions'; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ), + ARRAY_A + ); + + $index = array(); + foreach ( ( $rows ?? array() ) as $row ) { + if ( ! is_array( $row ) ) { + continue; + } + $stage = (string) ( $row['stage'] ?? 'unknown' ); + $index[ $stage ] = $row; + } + return $index; + } +} diff --git a/includes/admin/class-dossier-gen-log-query.php b/includes/admin/class-dossier-gen-log-query.php new file mode 100644 index 0000000..ced0d63 --- /dev/null +++ b/includes/admin/class-dossier-gen-log-query.php @@ -0,0 +1,95 @@ +>> Rows keyed by stage. + */ + public function get_by_run( string $run_id ): array { + global $wpdb; + if ( null === $wpdb || '' === $run_id ) { + return array(); + } + + $table = $wpdb->prefix . 'prautoblogger_generation_log'; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT id, stage, model, agent_role, prompt_version, + prompt_tokens, completion_tokens, estimated_cost, + request_json, response_status, error_message, created_at + FROM {$table} + WHERE run_id = %s + ORDER BY id ASC", + $run_id + ), + ARRAY_A + ); + + $index = array(); + foreach ( ( $rows ?? array() ) as $row ) { + if ( ! is_array( $row ) ) { + continue; + } + $stage = (string) ( $row['stage'] ?? 'unknown' ); + if ( ! isset( $index[ $stage ] ) ) { + $index[ $stage ] = array(); + } + $index[ $stage ][] = $row; + } + return $index; + } + + /** + * Aggregate cost + token totals per stage for the sidebar receipt. + * + * @param array>> $log_index Output of get_by_run(). + * @return array + */ + public function aggregate_per_stage( array $log_index ): array { + $totals = array(); + foreach ( $log_index as $stage => $rows ) { + $cost = 0.0; + $prompt_tokens = 0; + $completion_tokens = 0; + foreach ( $rows as $row ) { + $cost += (float) ( $row['estimated_cost'] ?? 0 ); + $prompt_tokens += (int) ( $row['prompt_tokens'] ?? 0 ); + $completion_tokens += (int) ( $row['completion_tokens'] ?? 0 ); + } + $totals[ $stage ] = array( + 'cost' => $cost, + 'prompt_tokens' => $prompt_tokens, + 'completion_tokens' => $completion_tokens, + 'calls' => count( $rows ), + ); + } + return $totals; + } +} diff --git a/includes/admin/class-dossier-page.php b/includes/admin/class-dossier-page.php new file mode 100644 index 0000000..f2ae1e2 --- /dev/null +++ b/includes/admin/class-dossier-page.php @@ -0,0 +1,117 @@ + $item ) { + if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) { + unset( $submenu['prautoblogger-settings'][ $idx ] ); + break; + } + } + } + } + + /** + * Render the dossier page. + * + * @return void + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only dossier; no state changes. + $post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0; + $assembler = new PRAutoBlogger_Dossier_Data_Assembler(); + $dossier = $assembler->assemble( $post_id ); + include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-page.php'; + } + + /** + * Enqueue dossier-specific CSS + JS. + * + * @param string $hook_suffix Current admin page hook suffix. + * @return void + */ + public function on_enqueue_assets( string $hook_suffix ): void { + $dossier_hook = 'prautoblogger_page_' . self::PAGE_SLUG; + if ( $hook_suffix !== $dossier_hook ) { + return; + } + + wp_enqueue_style( + 'prautoblogger-dossier', + PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier.css', + array( 'prautoblogger-admin' ), + PRAUTOBLOGGER_VERSION + ); + + wp_enqueue_script( + 'prautoblogger-dossier', + PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier.js', + array(), + PRAUTOBLOGGER_VERSION, + true + ); + } + + /** + * Build the URL to the dossier for a given post. + * + * @param int $post_id WordPress post ID. + * @return string Admin URL to the dossier page. + */ + public static function url_for_post( int $post_id ): string { + return admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&post_id=' . $post_id ); + } +} diff --git a/includes/admin/class-post-metabox.php b/includes/admin/class-post-metabox.php index 63ad522..ff18823 100755 --- a/includes/admin/class-post-metabox.php +++ b/includes/admin/class-post-metabox.php @@ -4,21 +4,22 @@ declare(strict_types=1); /** * phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard * - * Adds a metabox to posts showing PRAutoBlogger generation metadata. + * Adds a metabox to PRAutoBlogger-generated posts. * - * Only appears on posts that were generated by PRAutoBlogger (checked via - * _prautoblogger_generated post meta). + * M2: Slimmed to a "View generation dossier" link. Full generation metadata + * lives on the Article Dossier page (PRAutoBlogger_Dossier_Page). * * Triggered by: PRAutoBlogger::register_admin_hooks() on `add_meta_boxes`. - * Dependencies: WordPress post meta API. + * Dependencies: PRAutoBlogger_Dossier_Page (for URL helper). * - * @see class-prautoblogger.php — Registers the hook. - * @see core/class-publisher.php — Writes the metadata this class displays. + * @see class-prautoblogger.php -- Registers the hook. + * @see admin/class-dossier-page.php -- Dossier target; provides url_for_post(). + * @see core/class-publisher.php -- Writes the run_id meta this class reads. */ class PRAutoBlogger_Post_Metabox { /** - * Register the metabox for the post editor. + * Register the metabox for PRAutoBlogger-generated posts only. * * @return void */ @@ -35,7 +36,7 @@ class PRAutoBlogger_Post_Metabox { add_meta_box( 'prautoblogger_generation_info', - __( 'PRAutoBlogger Generation Info', 'prautoblogger' ), + __( 'PRAutoBlogger', 'prautoblogger' ), array( $this, 'render_metabox' ), 'post', 'side', @@ -44,13 +45,12 @@ class PRAutoBlogger_Post_Metabox { } /** - * Render the generation metadata metabox. - * - * @param \WP_Post $post + * Render the dossier-link metabox. * + * @param \WP_Post $post Current post. * @return void */ public function render_metabox( \WP_Post $post ): void { - include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-generation-info.php'; + include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-dossier-link.php'; } } diff --git a/prautoblogger.php b/prautoblogger.php index ba4e649..912d2e0 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.19.1 + * Version: 0.19.2 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.19.1' ); +define( 'PRAUTOBLOGGER_VERSION', '0.19.2' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/templates/admin/dossier-page.php b/templates/admin/dossier-page.php new file mode 100644 index 0000000..7b15c24 --- /dev/null +++ b/templates/admin/dossier-page.php @@ -0,0 +1,224 @@ +aggregate_per_stage( $log_index ); + +$total_cost = 0.0; +foreach ( $stage_totals as $st ) { + $total_cost += $st['cost']; +} + +$verdict_labels = array( + 'approved' => __( 'Approved', 'prautoblogger' ), + 'revised' => __( 'Revised', 'prautoblogger' ), + 'rejected' => __( 'Rejected', 'prautoblogger' ), + 'halted' => __( 'Halted', 'prautoblogger' ), + 'pending' => __( 'Pending', 'prautoblogger' ), +); + +$back_url = admin_url( 'admin.php?page=prautoblogger-board' ); +?> +
+ + +

+ + ← + +

+ + + +
+
+

+ 0 && ! empty( $dossier['post_title'] ) ) : ?> +

+ +

+ +

+ +

+ +
+
+ + + + +
+ + +
+
+

+
+ + + + + + + + + + + +
+
+ 0 ) : ?> +
+ + + +
+ +
+ +
+ + +
+ + $stage_row ) { + if ( ( $stage_row['stage'] ?? '' ) !== $stage_name ) { + continue; + } + $rendered_stages[] = $role_key; + $log_rows = $log_index[ $stage_name ] ?? array(); + require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php'; + } + } + // Remaining unordered stages. + foreach ( $stages as $role_key => $stage_row ) { + if ( in_array( $role_key, $rendered_stages, true ) ) { + continue; + } + $stage_name = (string) ( $stage_row['stage'] ?? 'unknown' ); + $log_rows = $log_index[ $stage_name ] ?? array(); + require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php'; + } + + if ( empty( $stages ) ) : + ?> +
+

+
+ + + + +
+

+ $d ) : ?> +
+ + + + + +

+ +
+ +
+ + +
+ + +
+ +
+

+ +
+
+
+
+
+ +
+
+ +
+
+
+ +
+ +
+

+ + + $st ) : ?> + + + + + + + + + + + + + +
$
$
+
+ +
+ +
+ +
+ +
diff --git a/templates/admin/dossier-stage-section.php b/templates/admin/dossier-stage-section.php new file mode 100644 index 0000000..5b7113c --- /dev/null +++ b/templates/admin/dossier-stage-section.php @@ -0,0 +1,126 @@ + +
+ +
+

+ + + () + +

+
+ + + + 0 ) : ?> + $ + + + + pv + +
+
+ + +
+ +

+ +

+ +
+ +
+ +

+ +
+ + + +
+ + +
+ + + +
+ + + → + + +
+ + +
diff --git a/templates/admin/metabox-dossier-link.php b/templates/admin/metabox-dossier-link.php new file mode 100644 index 0000000..501277d --- /dev/null +++ b/templates/admin/metabox-dossier-link.php @@ -0,0 +1,41 @@ +ID, '_prautoblogger_run_id', true ); +$url = PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ); +?> +
+ +

+ + → + +

+

+ +

+ +

+ +
diff --git a/tests/unit/Admin/DossierDataAssemblerTest.php b/tests/unit/Admin/DossierDataAssemblerTest.php new file mode 100644 index 0000000..82355d4 --- /dev/null +++ b/tests/unit/Admin/DossierDataAssemblerTest.php @@ -0,0 +1,241 @@ +wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + + // Stub PRAutoBlogger_Run_Stage_State::is_available() to return true. + // Static method -- stubbed via wpdb table check returning the table name. + \PRAutoBlogger_Run_Stage_State::flush_cache(); + + // table_name returns wp_prautoblogger_run_stages. + $this->wpdb->prefix = 'wp_'; + + // is_available() calls $wpdb->get_var(SHOW TABLES LIKE ...) -- return the table. + $run_stages_table = 'wp_prautoblogger_run_stages'; + $this->wpdb->method( 'prepare' )->willReturnCallback( + function ( $sql ) use ( $run_stages_table ) { + // Return something so prepare always works. + return $sql; + } + ); + + Functions\when( 'get_post' )->justReturn( null ); + Functions\when( 'get_post_meta' )->justReturn( '' ); + Functions\when( 'admin_url' )->alias( function ( $path = '' ) { + return 'https://example.com/wp-admin/' . ltrim( $path, '/' ); + } ); + + $this->log_query = $this->getMockBuilder( \PRAutoBlogger_Dossier_Gen_Log_Query::class ) + ->onlyMethods( array( 'get_by_run' ) ) + ->getMock(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_Stage_State::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + // ───────────────────────────────────────────────────────────────────── + // Zero / invalid post ID + // ───────────────────────────────────────────────────────────────────── + + public function test_zero_post_id_returns_no_run(): void { + $assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query ); + $result = $assembler->assemble( 0 ); + + $this->assertFalse( $result['has_run'] ); + $this->assertEmpty( $result['stages'] ); + } + + public function test_nonexistent_post_returns_no_run(): void { + Functions\when( 'get_post' )->justReturn( null ); + + $assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query ); + $result = $assembler->assemble( 9999 ); + + $this->assertFalse( $result['has_run'] ); + } + + // ───────────────────────────────────────────────────────────────────── + // Legacy post (no run_id meta) + // ───────────────────────────────────────────────────────────────────── + + public function test_legacy_post_no_run_id_returns_no_run(): void { + $post = new \WP_Post(); + $post->ID = 100; + $post->post_title = 'Old Article'; + $post->post_status = 'publish'; + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->justReturn( '' ); + + $assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query ); + $result = $assembler->assemble( 100 ); + + $this->assertFalse( $result['has_run'], + 'Legacy post without run_id must return has_run=false' ); + $this->assertSame( 'Old Article', $result['post_title'] ); + } + + // ───────────────────────────────────────────────────────────────────── + // Full modern run + // ───────────────────────────────────────────────────────────────────── + + public function test_full_run_assembles_correctly(): void { + $post = new \WP_Post(); + $post->ID = 925; + $post->post_title = 'BPC-157 Research Overview'; + $post->post_status = 'publish'; + + $run_id = 'abc-123-def-456'; + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key, $single ) use ( $run_id ) { + if ( '_prautoblogger_run_id' === $key ) { return $run_id; } + if ( '_prautoblogger_editor_verdict' === $key ) { return 'approved'; } + if ( '_prautoblogger_article_type' === $key ) { return 'authority'; } + return ''; + } + ); + + // Mock runs table query. + $this->wpdb->method( 'get_row' )->willReturn( array( + 'run_id' => $run_id, + 'status' => 'done', + 'started_at' => '2026-06-12 03:00:00', + 'finished_at' => '2026-06-12 03:04:00', + 'settled_usd' => '0.012000', + ) ); + + // Mock run_stages query -- get_results returns stage rows. + $stage_rows = array( + array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'draft', 'agent_role' => 'writer', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.008', 'meta_json' => wp_json_encode( array( 'output' => 'Draft content here.' ) ), 'started_at' => '2026-06-12 03:01:00', 'finished_at' => '2026-06-12 03:02:00' ), + array( 'id' => 2, 'run_id' => $run_id, 'stage' => 'review', 'agent_role' => 'editor', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.004', 'meta_json' => wp_json_encode( array( 'output' => 'Looks good.' ) ), 'started_at' => '2026-06-12 03:02:00', 'finished_at' => '2026-06-12 03:03:00' ), + ); + + // is_available check: get_var returns the table name. + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' ); + $this->wpdb->method( 'get_results' )->willReturn( $stage_rows ); + + $this->log_query->method( 'get_by_run' )->willReturn( array( + 'draft' => array( array( 'model' => 'gpt-4', 'prompt_version' => '3', 'agent_role' => 'writer', 'prompt_tokens' => 500, 'completion_tokens' => 1000, 'estimated_cost' => '0.008', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:01:00' ) ), + 'review' => array( array( 'model' => 'gpt-4', 'prompt_version' => '2', 'agent_role' => 'editor', 'prompt_tokens' => 200, 'completion_tokens' => 50, 'estimated_cost' => '0.004', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:02:00' ) ), + ) ); + + $assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query ); + $result = $assembler->assemble( 925 ); + + $this->assertTrue( $result['has_run'], 'Modern run must have has_run=true' ); + $this->assertSame( $run_id, $result['run_id'] ); + $this->assertSame( 'approved', $result['verdict'] ); + $this->assertSame( 'authority', $result['tier'] ); + $this->assertNotEmpty( $result['stages'], 'Stage rows must be present' ); + $this->assertNotEmpty( $result['gen_log_index'], 'Gen_log index must be present' ); + + // Check draft stage has display_output set. + $draft_key = 'draft:writer'; + $this->assertArrayHasKey( $draft_key, $result['stages'] ); + $this->assertSame( 'Draft content here.', $result['stages'][ $draft_key ]['display_output'] ); + } + + // ───────────────────────────────────────────────────────────────────── + // Amortized research row (pv=null, role='') + // ───────────────────────────────────────────────────────────────────── + + public function test_amortized_research_row_renders_gracefully(): void { + $post = new \WP_Post(); + $post->ID = 930; + $post->post_title = 'TB-500 Article'; + $post->post_status = 'publish'; + + $run_id = 'llm-research-run-001'; + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + function ( $post_id, $key, $single ) use ( $run_id ) { + return '_prautoblogger_run_id' === $key ? $run_id : ''; + } + ); + + $this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done', 'started_at' => '2026-06-12 03:00:00' ) ); + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' ); + + // Amortized research row: pv=null, role=''. + $this->wpdb->method( 'get_results' )->willReturn( array( + array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'llm_research', 'agent_role' => '', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.001', 'meta_json' => null, 'started_at' => '2026-06-12 03:00:00', 'finished_at' => '2026-06-12 03:00:30' ), + ) ); + + $this->log_query->method( 'get_by_run' )->willReturn( array( + 'llm_research' => array( array( 'model' => 'gpt-4', 'prompt_version' => null, 'agent_role' => '', 'prompt_tokens' => 100, 'completion_tokens' => 50, 'estimated_cost' => '0.001', 'request_json' => null, 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:00:00' ) ), + ) ); + + $assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query ); + + // Must not throw or produce fatal. + $result = $assembler->assemble( 930 ); + + $this->assertTrue( $result['has_run'] ); + + // Stage is present; display_output is null (meta_json was null = pruned). + $llm_key = 'llm_research:'; + $this->assertArrayHasKey( $llm_key, $result['stages'] ); + $this->assertNull( $result['stages'][ $llm_key ]['display_output'], + 'Amortized research with null meta_json must have null display_output' ); + } + + // ───────────────────────────────────────────────────────────────────── + // Decisions present and absent + // ───────────────────────────────────────────────────────────────────── + + public function test_decisions_absent_returns_empty_array(): void { + $post = new \WP_Post(); + $post->ID = 901; $post->post_title = 'Test'; $post->post_status = 'publish'; + $run_id = 'no-decisions-run'; + + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( fn( $id, $key ) => '_prautoblogger_run_id' === $key ? $run_id : '' ); + $this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done' ) ); + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' ); + $this->wpdb->method( 'get_results' )->willReturn( array() ); // no stages, no decisions + $this->log_query->method( 'get_by_run' )->willReturn( array() ); + + $assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query ); + $result = $assembler->assemble( 901 ); + + $this->assertIsArray( $result['decisions'] ); + $this->assertEmpty( $result['decisions'] ); + } +} diff --git a/tests/unit/Admin/DossierMenuRegistrationTest.php b/tests/unit/Admin/DossierMenuRegistrationTest.php new file mode 100644 index 0000000..fceb9c8 --- /dev/null +++ b/tests/unit/Admin/DossierMenuRegistrationTest.php @@ -0,0 +1,149 @@ + Call sequence spy. */ + private array $call_sequence = []; + + protected function setUp(): void { + parent::setUp(); + $this->call_sequence = []; + + Functions\when( 'add_menu_page' )->alias( + function () { + $this->call_sequence[] = 'add_menu_page'; + return 'prautoblogger-settings'; + } + ); + Functions\when( 'add_submenu_page' )->alias( + function ( string $parent_slug, string $page_title, string $menu_title, string $capability, string $menu_slug ) { + $this->call_sequence[] = 'add_submenu_page:' . $menu_slug; + return 'prautoblogger_page_' . $menu_slug; + } + ); + + Functions\when( 'current_user_can' )->justReturn( true ); + Functions\when( 'get_option' )->justReturn( false ); + Functions\when( 'admin_url' )->alias( function ( $path = '' ) { + return 'https://example.com/wp-admin/' . ltrim( $path, '/' ); + } ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation. + $GLOBALS['submenu'] = array(); + } + + protected function tearDown(): void { + $this->call_sequence = []; + unset( $GLOBALS['submenu'] ); + parent::tearDown(); + } + + /** + * Dossier on_register_menu must be hooked at priority > board (11). + * Mirrors the bindings in register_admin_hooks(): + * parent at 10, board at 11, dossier at 12. + * + * FAILS if dossier drops to priority ≤ 11 (same as or before board). + * FAILS if dossier drops to priority ≤ 10 (same as or before parent). + */ + public function test_dossier_hook_registered_after_board(): void { + $admin_page = new \PRAutoBlogger_Admin_Page(); + $board_page = new \PRAutoBlogger_Board_Page(); + $dossier_page = new \PRAutoBlogger_Dossier_Page(); + + // Mirror class-prautoblogger.php register_admin_hooks() priorities. + add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 ); + add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 ); + add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 ); + + $storage = Monkey\Container::instance()->hookStorage(); + $ref = new \ReflectionClass( $storage ); + $prop = $ref->getProperty( 'storage' ); + $prop->setAccessible( true ); + $data = $prop->getValue( $storage ); + + $parent_priority = -1; + $board_priority = -1; + $dossier_priority = -1; + + $admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array(); + foreach ( $admin_menu_entries as $entry ) { + $cb = (string) ( $entry[0] ?? '' ); + $priority = (int) ( $entry[1] ?? -1 ); + if ( false !== strpos( $cb, 'PRAutoBlogger_Admin_Page' ) ) { $parent_priority = $priority; } + if ( false !== strpos( $cb, 'PRAutoBlogger_Board_Page' ) ) { $board_priority = $priority; } + if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; } + } + + $this->assertGreaterThan( $parent_priority, $board_priority, + 'Board priority must exceed parent priority.' ); + $this->assertGreaterThan( $board_priority, $dossier_priority, + 'Dossier priority must exceed board priority to ensure hookname resolves correctly.' ); + } + + /** + * Dossier submenu slug must be 'prautoblogger-dossier'. + */ + public function test_dossier_uses_correct_slug(): void { + $dossier_page = new \PRAutoBlogger_Dossier_Page(); + $dossier_page->on_register_menu(); + + $found_slug = null; + foreach ( $this->call_sequence as $entry ) { + if ( str_starts_with( $entry, 'add_submenu_page:' ) ) { + $found_slug = substr( $entry, strlen( 'add_submenu_page:' ) ); + break; + } + } + + $this->assertSame( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $found_slug, + 'Dossier must register under PAGE_SLUG.' ); + } + + /** + * Dossier submenu parent must be 'prautoblogger-settings'. + */ + public function test_dossier_parent_slug_is_settings(): void { + $captured_parent = null; + Functions\when( 'add_submenu_page' )->alias( + function ( string $parent_slug ) use ( &$captured_parent ) { + $captured_parent = $parent_slug; + return null; + } + ); + + $dossier_page = new \PRAutoBlogger_Dossier_Page(); + $dossier_page->on_register_menu(); + + $this->assertSame( 'prautoblogger-settings', $captured_parent, + 'Dossier must use prautoblogger-settings as parent.' ); + } + + /** + * url_for_post() returns a URL containing the dossier slug and post_id. + */ + public function test_url_for_post_contains_slug_and_post_id(): void { + $url = \PRAutoBlogger_Dossier_Page::url_for_post( 42 ); + $this->assertStringContainsString( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $url ); + $this->assertStringContainsString( '42', $url ); + } +}