Compare commits

..

4 Commits

Author SHA1 Message Date
peptiderepo
e029eae47e ci(review): drop panel to Haiku-only per QA A/B decision 2026-06-12
A/B analysis across 13 PRs (convo/cto/threads/2026-06-qa-ab-review/02-analyst-findings.md):
- DeepSeek: high false-positive rate (absint()="code exec", PLUGIN_DIR="dir traversal")
- Kimi: ~60% model failure rate, 1 unique-real finding in 13 PRs
- Neurometric: summarizer only, 0 unique-real findings, unreliable heuristic verdict label
- Haiku: 0 failures, lowest noise, equivalent real-signal catch vs 4-voice panel

Removes DeepSeek, Kimi, and Neurometric voices. Haiku is now sole reviewer.
Also removes NEUROMETRIC_API_KEY env var (no longer needed).
No plugin version bump -- CI-only change (precedent: prautoblogger #150).

Agent-Session: engineer-panel-haiku-20260612
2026-06-12 14:25:43 +00:00
peptiderepo
3b6a302f95 fix(maintenance): v0.19.4 — composer GD-rung tests, layout-key cleanup, carried P3s (#160)
QA: APPROVE-WITH-P2-NOTES @ c0929be (qa-reviews/prautoblogger/2026-06-12-c0929be.md)
CEO greenlight: 2026-06-12 in chat.

Agent-Session: cto-maint-v0194-merge-20260612
2026-06-12 21:21:52 +07:00
peptiderepo
5c3328dee2 v0.19.3 - hotfix: dossier 403 (hidden-page pattern -> options.php parent) (#159)
Dossier registers under options.php parent (stable admin_page_* hookname at
registration AND request time); hide-by-unset removed; enqueue gate fixed;
request-time-faithful regression tests (fail on v0.19.2 by construction);
hidden-admin-page convention codified in CONVENTIONS.md.

QA: REJECT-P1 (ae32876, test file 551 lines) -> split -> APPROVE (6689126).
Verdicts: qa-reviews/prautoblogger/2026-06-12-{ae32876,6689126}.md
Thread: pipeline-v2-phase2-admin seq 21-28

Agent-Session: cto-dossier-403-hotfix-merge-20260612
2026-06-12 18:41:46 +07:00
peptiderepo
9c8e500244 v0.19.2 - Phase 2 admin M2: Article Dossier + class-prautoblogger split (#158)
Article dossier (Proposal C): per-stage input/output sections w/ raw-trace
toggle, metadata sidebar + per-stage cost receipt, run_decisions rationale,
image section, metabox dossier link, board deep-links. DB-migration methods
extracted to class-db-migrations.php (byte-parity verified by QA).

QA: APPROVE-W-P2 qa-reviews/prautoblogger/2026-06-12-579c66e.md
P2s carried to M3: populate request_json (input trace), sidebar pv
consolidation, image stages in run_stages. Thread: pipeline-v2-phase2-admin seq 16-20

Agent-Session: cto-phase2-admin-m2-merge-20260612
2026-06-12 18:01:39 +07:00
16 changed files with 1103 additions and 351 deletions

View File

@@ -1,20 +1,17 @@
name: CTO Review
# Automated PR review powered by Claude + DeepSeek + Kimi (via OpenRouter)
# plus Neurometric PR-Summarizer (a Qwen3-4B task-specialist SLM).
# Automated PR review powered by Claude Haiku (via OpenRouter).
#
# During the A/B window (until further notice), every PR gets reviewed by FOUR models
# in parallel. The PR comment shows each model's verdict side-by-side so we can compare
# quality/cost on real PRs before picking a permanent default.
# A/B panel decision 2026-06-12: panel dropped to Haiku-only.
# Evidence: convo/cto/threads/2026-06-qa-ab-review/02-analyst-findings.md
# DeepSeek: high false-positive rate (absint()="code exec", PLUGIN_DIR="dir traversal").
# Kimi: ~60% model failure rate across 13 PRs sampled.
# Neurometric: summarizer only, zero unique-real findings, unreliable heuristic verdict.
# Haiku: 0 failures, lowest noise, equivalent real-signal catch rate vs 4-voice panel.
#
# Neurometric is a "prose voice": it does not support OpenAI tool-calling, so its
# contribution is raw markdown + a heuristic verdict. It is never the primary voice.
# Does NOT merge. Posts formal APPROVE/REQUEST_CHANGES/COMMENT reviews.
#
# Does NOT merge. Does NOT post formal APPROVE/REQUEST_CHANGES reviews — always COMMENT
# (GITHUB_TOKEN can't formally approve PRs without repo-setting toggle).
#
# Requires repo secrets: OPENROUTER_API_KEY, NEUROMETRIC_API_KEY (optional; the
# Neurometric voice self-skips with an inline error note if the key is missing).
# Requires repo secret: OPENROUTER_API_KEY
#
# Cost cap: tracked in Repository Variables (CTO_REVIEW_SPEND_CENTS, CTO_REVIEW_CAP_CENTS).
# Default cap is 1000 cents ($10/month). Auto-resets on the 1st of each month.
@@ -125,8 +122,8 @@ jobs:
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
# Write the diff to a file, not a step output / env var. Passing a
# ~400KB diff through the process env (execve argv+envp) exceeds the
# runner ARG_MAX and fails the next step with "Argument list too long"
# (issue #110). File transport also sidesteps the ~1MB step-output cap.
# runner ARG_MAX and fails the next step with "Argument list too long".
# File transport also sidesteps the ~1MB step-output cap.
git diff --no-color "$BASE_SHA" "$HEAD_SHA" | head -c 400000 > /tmp/cto-review-diff.txt
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
@@ -151,12 +148,11 @@ jobs:
echo "EOF_CONV"
} >> "$GITHUB_OUTPUT"
- name: Call 4 models in parallel (OpenRouter + Neurometric)
- name: Call Haiku via OpenRouter
if: steps.budget.outputs.skip != 'true'
id: review
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
NEUROMETRIC_API_KEY: ${{ secrets.NEUROMETRIC_API_KEY }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
@@ -184,24 +180,17 @@ jobs:
fi
python3 - <<'PY'
import json, os, sys, urllib.request, concurrent.futures
import json, os, sys, urllib.request
api_key = os.environ["OPENROUTER_API_KEY"]
# A/B harness: four models. Each gets the same prompt.
# Primary model's verdict drives the heading. Others are labeled.
# Edit this list to change the roster (or drop to 1 after A/B window closes).
#
# provider_type semantics:
# - "openrouter_tools": OpenAI tool-calling via OpenRouter; structured verdict.
# - "neurometric_prose": Neurometric marketplace SLM; prose output + heuristic verdict.
MODELS = [
# (slug, display name, input_per_M_USD, output_per_M_USD, is_primary, provider_type)
("anthropic/claude-haiku-4.5", "Claude Haiku 4.5", 1.00, 5.00, True, "openrouter_tools"),
("deepseek/deepseek-chat-v3.1", "DeepSeek V3.1", 0.15, 0.75, False, "openrouter_tools"),
("moonshotai/kimi-k2-thinking", "Kimi K2 Thinking", 0.60, 2.50, False, "openrouter_tools"),
("neurometric/pr-summarizer", "Neurometric PR-Summarizer (Qwen3-4B)", 0.00, 0.00, False, "neurometric_prose"),
]
# Haiku-only reviewer. A/B panel dropped 2026-06-12 per QA data:
# DeepSeek high false-positive rate, Kimi ~60% failure rate,
# Neurometric zero unique-real findings. Haiku retained as sole voice.
MODEL_SLUG = "anthropic/claude-haiku-4.5"
MODEL_DISPLAY = "Claude Haiku 4.5"
INPUT_PER_M_USD = 1.00
OUTPUT_PER_M_USD = 5.00
system = """You are the CTO and lead reviewer for the peptiderepo project. You are reviewing a pull request opened by an autonomous agent or human contributor.
@@ -264,218 +253,100 @@ jobs:
}
}]
PER_MODEL_TIMEOUT = 60 # seconds; one hung model should not block siblings
body = {
"model": MODEL_SLUG,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message}
],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"HTTP-Referer": "https://github.com/peptiderepo",
"X-Title": f"peptiderepo CTO reviewer ({MODEL_DISPLAY})"
},
method="POST"
)
# Heuristic verdict inference for prose-voice models that can't do tool-calling.
# Keyword order matters: block markers beat approve markers.
PROSE_BLOCK_MARKERS = [
"do not merge", "do not approve", "should not be merged", "reject this",
"critical security", "critical issue", "critical vulnerability",
"blocker", "blocking issue", "request_changes", "request changes",
"**critical**", "sql injection", "must be addressed before",
]
PROSE_APPROVE_MARKERS = [
"safe to merge", "ready to merge", "no blocking issues", "lgtm",
"approve this", "looks good to merge", "no concerns",
]
error = None
review = None
cost_cents = 0.0
ptok = 0
ctok = 0
def infer_verdict_from_prose(text):
t = (text or "").lower()
if any(m in t for m in PROSE_BLOCK_MARKERS):
return "REQUEST_CHANGES"
if any(m in t for m in PROSE_APPROVE_MARKERS):
return "APPROVE"
return "COMMENT"
def call_openrouter_tools(slug, display, in_rate, out_rate, is_primary):
body = {
"model": slug,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message}
],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "submit_review"}}
}
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {api_key}",
"content-type": "application/json",
"HTTP-Referer": "https://github.com/peptiderepo",
"X-Title": f"peptiderepo CTO reviewer ({display})"
},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
data = json.loads(resp.read())
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": str(e)[:300],
"cost_cents": 0,
}
try:
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read())
except Exception as e:
error = str(e)[:300]
data = {}
if not error:
choice = data.get("choices", [{}])[0].get("message", {})
tool_calls = choice.get("tool_calls", [])
if not tool_calls:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": f"No tool_call in response. Raw content: {str(choice)[:300]}",
"cost_cents": 0,
}
error = f"No tool_call in response. Raw content: {str(choice)[:300]}"
else:
try:
review = json.loads(tool_calls[0]["function"]["arguments"])
except Exception as e:
error = f"Malformed tool args: {e}"
try:
review = json.loads(tool_calls[0]["function"]["arguments"])
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"error": f"Malformed tool args: {e}",
"cost_cents": 0,
}
if not error:
usage = data.get("usage", {})
ptok = usage.get("prompt_tokens", 0)
ctok = usage.get("completion_tokens", 0)
cost_usd = (ptok * INPUT_PER_M_USD / 1_000_000) + (ctok * OUTPUT_PER_M_USD / 1_000_000)
cost_cents = cost_usd * 100
usage = data.get("usage", {})
ptok = usage.get("prompt_tokens", 0)
ctok = usage.get("completion_tokens", 0)
cost_usd = (ptok * in_rate / 1_000_000) + (ctok * out_rate / 1_000_000)
cost_cents = cost_usd * 100
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "openrouter_tools",
"review": review, "prompt_tokens": ptok, "completion_tokens": ctok,
"cost_cents": cost_cents,
}
def call_neurometric_prose(slug, display, is_primary):
nm_key = os.environ.get("NEUROMETRIC_API_KEY", "")
if not nm_key:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": "NEUROMETRIC_API_KEY secret not set on this repo",
"cost_cents": 0,
}
body = {
"model": slug,
"max_tokens": 2048,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user_message},
],
}
req = urllib.request.Request(
"https://api.neurometric.ai/v1/chat/completions",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {nm_key}",
"content-type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
data = json.loads(resp.read())
except Exception as e:
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"error": str(e)[:300],
"cost_cents": 0,
}
prose = data.get("choices", [{}])[0].get("message", {}).get("content") or ""
verdict = infer_verdict_from_prose(prose)
return {
"slug": slug, "display": display, "is_primary": is_primary,
"provider_type": "neurometric_prose",
"review": {"verdict": verdict, "prose": prose},
"prompt_tokens": 0, "completion_tokens": 0,
"cost_cents": 0,
}
def call_model(slug, display, in_rate, out_rate, is_primary, provider_type="openrouter_tools"):
if provider_type == "neurometric_prose":
return call_neurometric_prose(slug, display, is_primary)
return call_openrouter_tools(slug, display, in_rate, out_rate, is_primary)
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(MODELS)) as pool:
futures = [pool.submit(call_model, *m) for m in MODELS]
for f in concurrent.futures.as_completed(futures):
results.append(f.result())
# Order: primary first, then alphabetical for stable comparison
results.sort(key=lambda r: (not r.get("is_primary"), r["display"]))
# Build combined comment
primary = next((r for r in results if r.get("is_primary") and "review" in r), None)
heading_verdict = primary["review"]["verdict"] if primary else "MIXED"
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"_A/B harness active: {len(results)} models reviewing in parallel. Primary verdict drives heading; others are advisory._")
lines.append("")
total_cents = 0.0
for r in results:
lines.append(f"---")
primary_tag = " (primary)" if r.get("is_primary") else ""
lines.append(f"### {r['display']}{primary_tag}")
if "error" in r:
lines.append("")
lines.append(f"⚠️ Model failed: `{r['error']}`")
lines.append("")
continue
rev = r["review"]
# Build comment
if error:
heading_verdict = "ERROR"
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"Model failed: `{error}`")
lines.append("")
if r.get("provider_type") == "neurometric_prose":
lines.append(f"**Verdict:** {rev['verdict']} _(heuristic, inferred from prose)_")
lines.append("")
# Cap at 6KB to keep PR comments readable; 4B model output is rarely over this
prose = rev.get("prose", "")
if len(prose) > 6000:
prose = prose[:6000] + "\n\n_…output truncated for comment length…_"
lines.append(prose)
lines.append("")
lines.append("_free tier; per-call token usage not tracked_")
lines.append("")
continue
lines.append(f"**Verdict:** {rev.get('verdict', 'COMMENT')}")
lines.append(f"_reviewer: {MODEL_DISPLAY}_")
else:
heading_verdict = review.get("verdict", "COMMENT")
lines = [f"## CTO Review — {heading_verdict}", ""]
lines.append(f"**Verdict:** {heading_verdict}")
lines.append("")
lines.append(rev.get("summary") or "_(model returned no summary)_")
lines.append(review.get("summary") or "_(model returned no summary)_")
lines.append("")
if rev.get("concerns"):
if review.get("concerns"):
lines.append("**Concerns:**")
for c in rev["concerns"]:
for c in review["concerns"]:
sev = c.get("severity", "UNKNOWN").upper()
f = f"`{c['file']}`" if c.get("file") else ""
lines.append(f"- **{sev}** {f} — {c['issue']}")
lines.append("")
if rev.get("positives"):
lines.append("**Positives:** " + "; ".join(rev["positives"]))
if review.get("positives"):
lines.append("**Positives:** " + "; ".join(review["positives"]))
lines.append("")
lines.append(f"_tokens in/out: {r['prompt_tokens']}/{r['completion_tokens']} · cost: ${r['cost_cents']/100:.4f}_")
total_cents += r["cost_cents"]
lines.append(f"_tokens in/out: {ptok}/{ctok} · cost: ${cost_cents/100:.4f}_")
lines.append("")
lines.append("---")
lines.append(f"_Total review cost: **${total_cents/100:.4f}**. A/B roster configured in `.github/workflows/cto-review.yml`._")
lines.append(f"_Total review cost: **${cost_cents/100:.4f}**. Reviewer: {MODEL_DISPLAY}. Roster configured in `.github/workflows/cto-review.yml`._")
with open("/tmp/review-body.md", "w") as f:
f.write("\n".join(lines))
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"added_cents={int(round(total_cents))}\n")
f.write(f"added_cents={int(round(cost_cents))}\n")
f.write(f"skipped=0\n")
f.write(f"primary_verdict={heading_verdict}\n")
PY
- name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT)
# Uses primary model's verdict as the formal event. Falls back to COMMENT
# if the primary model failed or returned something unexpected.
# Uses Haiku's verdict as the formal event. Falls back to COMMENT
# if the model failed or returned something unexpected.
if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1'
uses: actions/github-script@v7
env:

View File

@@ -145,7 +145,7 @@ prautoblogger/
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (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-page.php # Article dossier admin page: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3)
│ │ ├── 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)
@@ -871,6 +871,8 @@ Tests use Brain\Monkey for WordPress function mocking (no database required). Th
### #22b: Kanban board as primary admin landing screen (v0.19.0 / v0.19.1)
**Hidden admin page convention (v0.19.3):** See CONVENTIONS.md §Hidden Admin Pages for the full rule. Summary: hidden admin pages (link-accessed only, not visible in nav) MUST use `options.php` as the `add_submenu_page()` parent. NEVER unset/remove registered pages from `$submenu` after registration -- doing so causes `get_admin_page_parent()` to fail at request time, which makes WP recompute the hookname in the `admin_page_*` orphan namespace, find no registered handler, and call `wp_die(403)`. This is the dossier 403 incident (v0.19.2→v0.19.3). Same hookname-mismatch class as the board 404 (v0.19.0→v0.19.1); the only difference is that the board bug was a priority-ordering issue at registration time while the dossier bug was a post-registration $submenu mutation.
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
@@ -904,14 +906,25 @@ a single post: generation run, stage I/O, cost receipts, raw-trace toggles, edit
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=<int>`. 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`.
**Page registration** (`class-dossier-page.php`): uses the canonical hidden-admin-page
pattern -- registered under parent **`options.php`** at `admin_menu` priority **12**. The
hookname is `admin_page_prautoblogger-dossier` at BOTH registration time and request time,
so there is no hookname mismatch and no need to manipulate `$submenu`. The page does not
appear in the WP admin sidebar (options.php-parent pages are hidden by construction).
URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>` (unchanged from v0.19.2 --
deep-link URLs are parent-agnostic). Secured by `manage_options` capability. Static
`url_for_post(int $post_id): string` provides the canonical deep-link.
v0.19.2 used `prautoblogger-settings` as parent with post-registration `unset($submenu[...])` to
hide the page. This caused `get_admin_page_parent()` to fail at request time -> orphan
`admin_page_*` hookname -> no handler registered -> `wp_die(403)`. Fixed in v0.19.3 by switching
to `options.php` parent and removing all `$submenu` mutation. See CONVENTIONS.md §Hidden Admin
Pages for the full rule and ARCHITECTURE.md §22b for both incident records.
Regression test: `tests/unit/Admin/DossierMenuRegistrationTest.php` -- the
`test_request_time_hookname_matches_registration_hookname` test replicates the request-time
resolution algorithm (simulate `get_admin_page_parent()`) so it FAILS on hide-by-unset and
PASSES on options.php-parent.
**5-query data assembly** (`class-dossier-data-assembler.php`):

View File

@@ -5,6 +5,72 @@ 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.4] - 2026-06-12
### Added
- **GD-rung unit tests.** New `ImageComposerEditorTest.php` (7 tests) covering
`PRAutoBlogger_Image_Composer_Editor`: `wp_get_image_editor` WP_Error, resize WP_Error,
upscale-guard size mismatch, save WP_Error, zero-width target geometry guard, missing-role
guard, and successful OG resize happy path. Orchestrator ladder test
`test_gd_rung_emits_passthrough_featured_and_single_warning` added to `ImageComposerTest.php`,
asserting the Imagick renderer is never called on the `gd` rung and pass-through featured is
always first.
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P2-1.)
### Fixed
- **Dead layout keys removed.** `caption_margin_right` (og) and `caption_side_padding` (square)
were defined in `Layout::defaults()` and exposed via the `prautoblogger_image_compose_layout`
filter but never consumed by the Imagick renderer. The og renderer positions text from
`logo_x + logo_w + caption_gap` (left-anchor); the square renderer uses centered `x` alignment.
Right-edge overflow is enforced via `caption_chars_per_line` on both variants. Keys removed;
filter users tuning these values had no effect and received no error.
(Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P3-3.)
- **Amortized research row now carries audit fields.** `Post_Assembler::amortize_research_costs()`
previously inserted per-article `llm_research` copies without `prompt_version` or `agent_role`,
leaving those columns NULL/empty while the original (correctly stamped) row was deleted.
SELECT now includes both columns; INSERT carries them forward from the original row.
(Source: phase-1 thread seq 49, seq 50 carried-list: "amortized llm_research rows pv=null/role
empty" — `convo/prautoblogger/threads/2026-06-pipeline-v2-phase1/`.)
- **`fetch_page()` error body included in exception.** Non-200 responses from the Runware
modelSearch API now include up to 200 chars of the response body in the `RuntimeException`
message, making diagnosis from logs actionable without re-reproducing the request.
(Source: phase-1 thread seq 50 carried-list: "`fetch_page()` error-body logging".)
### Notes
- cto-review.yml isinstance guard (handoff item 5): the fix (`c.get("severity", "UNKNOWN")`)
is already applied at HEAD (line 454 of the workflow). No changes made.
## [0.19.3] - 2026-06-12
### Fixed
- **Dossier page 403 (P0 hotfix).** `PRAutoBlogger_Dossier_Page::on_register_menu` registered the
dossier as a hidden submenu under `prautoblogger-settings` then called `unset($submenu[...])` to
hide it from the nav. This is the **hide-by-unset anti-pattern**: at registration time WP records
the render callback under hookname `prautoblogger_page_prautoblogger-dossier`; at request time
`get_admin_page_parent()` scans `$submenu`, finds the slug absent (it was unset), returns false,
and WP recomputes the hookname under the orphan `admin_page_*` namespace -- no handler registered
there -- `wp_die(403)`. Same hookname-mismatch class as the board 404 (v0.19.1), different vector.
**Fix:** register under parent `options.php` (canonical hidden-page pattern). Hookname is
`admin_page_prautoblogger-dossier` at both registration and request time. No `$submenu` mutation
required or permitted. Asset enqueue gate updated to match (`admin_page_*` prefix).
Deep-link URLs (`admin.php?page=prautoblogger-dossier&post_id=N`) are parent-agnostic and
unchanged. Board cards and the post metabox link continue to work without modification.
### Changed
- **Regression test upgraded to request-time faithful** (`DossierMenuRegistrationTest`).
New `test_request_time_hookname_matches_registration_hookname` simulates
`get_admin_page_parent()` against the post-registration `$submenu` state and asserts it
equals the registration-time hookname. FAILS on hide-by-unset (4 failures on v0.19.2 code,
captured in test evidence). PASSES on options.php-parent. New
`test_hide_by_unset_would_fail_request_time_check` documents the broken pattern inline.
- **CONVENTIONS.md §Hidden Admin Pages** added: options.php-parent is the only permitted
pattern for hidden admin pages; `$submenu` mutation after registration is explicitly
prohibited; full incident history (board 404 + dossier 403) and test methodology documented.
- **ARCHITECTURE.md §22b** updated with hidden-page convention cross-reference.
**§23** updated to reflect options.php-parent registration and the v0.19.2→v0.19.3 fix.
- **tests/bootstrap.php**: added `PRAUTOBLOGGER_PLUGIN_URL` and `PRAUTOBLOGGER_VERSION`
constants so admin enqueue tests resolve without "Undefined constant" errors.
## [0.19.2] - 2026-06-12
### Added

View File

@@ -502,3 +502,73 @@ public function my_operation( $input ) {
```
That's it. No config, no feature-flag checks, no try/catch needed.
---
## Hidden Admin Pages (v0.19.3 convention, codified after two incidents)
### Rule: use options.php as parent; NEVER unset/remove registered pages from $submenu
When a WordPress admin page must be accessible by URL but hidden from the nav menu, use the
**options.php-parent pattern** exclusively:
```php
add_submenu_page(
'options.php', // parent: built-in WP page; pages hidden by construction
__( 'My Page', 'prautoblogger' ), // page title
__( 'My Page', 'prautoblogger' ), // menu title (never shown; options.php is hidden)
'manage_options', // capability
'my-page-slug', // page slug
array( $this, 'render_page' ), // callback
);
// No $submenu manipulation. options.php-parent pages are hidden from nav by construction.
```
The resulting hookname is `admin_page_my-page-slug` at BOTH registration time and request time.
### Why the hide-by-unset pattern causes 403 (the incident class)
WordPress computes the page hookname at two distinct moments:
**Registration time** (inside `add_submenu_page`):
WP resolves the parent slug against `$admin_page_hooks`. If found, hookname = `{parent_key}_page_{slug}`. WP registers the render callback and `$_registered_pages` entry under that name.
**Request time** (inside `wp-admin/admin.php`):
WP calls `get_admin_page_parent()` which scans `$submenu` for the page slug. If found, it derives the hookname the same way as (1). If **not found** (e.g., because the entry was unset), it falls back to `admin_page_{slug}`.
When these two hooknames diverge, WP dispatches the request to a hookname with no registered callback → `wp_die(403)`.
**Why options.php avoids this:** `options.php` is a built-in page that WordPress always keeps intact in `$admin_page_hooks` and `$submenu`. For its children, `get_admin_page_parent()` returns `options.php` consistently at both moments, so both produce `admin_page_{slug}`. No mutation required.
### Incident history
| Version | Symptom | Root cause | Fix |
|---------|---------|------------|-----|
| v0.19.1 | Board page 404 | Board submenu registered at same priority as parent (both 10) — add_submenu_page fired before add_menu_page → wrong hookname at registration time | Board moved to priority 11, after parent at 10 |
| v0.19.3 | Dossier page 403 | Dossier registered under `prautoblogger-settings` parent (priority 12, after parent ✓) but post-registration `unset($submenu[...])` removed the slug → get_admin_page_parent() returned false at request time → orphan `admin_page_*` hookname → no handler → 403 | Switched to `options.php` parent; removed all $submenu mutation |
Both incidents are in the same **hookname-mismatch class**: registration-time hookname ≠ request-time hookname.
### Test pattern
The `DossierMenuRegistrationTest::test_request_time_hookname_matches_registration_hookname` test
replicates the request-time resolution algorithm (scan `$submenu` for the slug; if found derive
hookname; if absent return `admin_page_*`) and asserts it equals the registration-time hookname.
This test FAILS on hide-by-unset implementations and passes on the options.php-parent pattern.
Apply the same test strategy to any new hidden admin page.
### Asset enqueue gate
For `options.php`-parent pages, the hook suffix passed to `admin_enqueue_scripts` is
`admin_page_{slug}`. For plugin-parent pages it would be `{parent_key}_page_{slug}`. Always
match the asset enqueue gate to the actual hookname for the chosen parent pattern:
```php
// options.php parent → 'admin_page_{slug}'
public function on_enqueue_assets( string $hook_suffix ): void {
if ( $hook_suffix !== 'admin_page_' . self::PAGE_SLUG ) {
return;
}
// ... enqueue
}
```

View File

@@ -6,10 +6,25 @@ declare(strict_types=1);
*
* Registers and renders the Article Dossier admin page (M2).
*
* The dossier is link-accessed -- it is NOT a visible submenu item. It registers
* as a hidden submenu under 'prautoblogger-settings' at admin_menu priority 12
* (after board at 11) so WordPress resolves the hookname correctly. Board cards and
* post list columns deep-link here via ?page=prautoblogger-dossier&post_id=X.
* The dossier is link-accessed -- it is NOT a visible submenu item. It uses
* the canonical hidden-admin-page pattern: register under parent 'options.php'
* so WordPress computes the hookname as `admin_page_prautoblogger-dossier` at
* BOTH registration time AND request time. The page is hidden from nav by
* construction (options.php is a built-in WP page with no visible submenu block)
* so no $submenu mutation is needed or permitted.
*
* Why NOT the hide-by-unset pattern (the v0.19.2 bug):
* add_submenu_page('prautoblogger-settings', ...) → hookname 'prautoblogger_page_prautoblogger-dossier'
* post-registration unset($submenu['prautoblogger-settings'][$idx])
* → at request time get_admin_page_parent() no longer finds the slug in $submenu
* → WP recomputes hookname in the 'admin_page_*' orphan namespace
* → no callback registered there → wp_die(403).
* See ARCHITECTURE.md §22b + CONVENTIONS.md §Hidden Admin Pages for the full incident
* history (board 404 v0.19.1, dossier 403 v0.19.2).
*
* Board cards and post-metabox deep-link here via
* admin.php?page=prautoblogger-dossier&post_id=X. Deep-link URLs are parent-agnostic
* (they reference only the page slug) so they are unchanged by this fix.
*
* Design contract: Proposal C "Editorial Record" -- warm editorial dossier,
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
@@ -19,46 +34,44 @@ declare(strict_types=1);
*
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
* @see templates/admin/dossier-page.php -- HTML template.
* @see ARCHITECTURE.md -- §Dossier (M2).
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
* @see CONVENTIONS.md -- §Hidden Admin Pages.
*/
class PRAutoBlogger_Dossier_Page {
/** Admin page slug. */
public const PAGE_SLUG = 'prautoblogger-dossier';
/**
* Parent slug used at registration.
* 'options.php' is the canonical WP hidden-page parent: the hookname resolves
* to `admin_page_{slug}` at both registration time and request time, so there is
* no hookname mismatch and no need to manipulate $submenu.
*/
public const PARENT_SLUG = 'options.php';
/** Nonce action for any future stateful actions. */
public const NONCE_ACTION = 'prautoblogger_dossier';
/**
* Register the dossier as a hidden submenu page.
* Register the dossier as a hidden admin page.
*
* Hidden = not visible in the menu, but reachable by URL.
* Priority 12 ensures add_menu_page() (priority 10) has populated
* $admin_page_hooks before this add_submenu_page() call. See ARCHITECTURE.md §Board.
* Uses the canonical options.php-parent pattern so the hookname is stable at
* both registration time and request time. No $submenu manipulation required
* or permitted (see CONVENTIONS.md §Hidden Admin Pages).
*
* @return void
*/
public function on_register_menu(): void {
add_submenu_page(
'prautoblogger-settings',
self::PARENT_SLUG,
__( 'Article Dossier', 'prautoblogger' ),
__( 'Dossier', 'prautoblogger' ),
'manage_options',
self::PAGE_SLUG,
array( $this, 'render_page' )
);
// Remove from visible submenu so it doesn't clutter the nav.
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional hidden-submenu pattern.
global $submenu;
if ( isset( $submenu['prautoblogger-settings'] ) ) {
foreach ( $submenu['prautoblogger-settings'] as $idx => $item ) {
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
unset( $submenu['prautoblogger-settings'][ $idx ] );
break;
}
}
}
// No $submenu mutation. options.php-parent pages are hidden by construction.
}
/**
@@ -71,7 +84,7 @@ class PRAutoBlogger_Dossier_Page {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only dossier; no state changes.
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
$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';
@@ -80,11 +93,14 @@ class PRAutoBlogger_Dossier_Page {
/**
* Enqueue dossier-specific CSS + JS.
*
* The hook suffix for an options.php-parent page is `admin_page_{slug}`.
*
* @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;
// options.php-parent pages resolve to 'admin_page_{slug}' (not 'prautoblogger_page_*').
$dossier_hook = 'admin_page_' . self::PAGE_SLUG;
if ( $hook_suffix !== $dossier_hook ) {
return;
}
@@ -108,6 +124,9 @@ class PRAutoBlogger_Dossier_Page {
/**
* Build the URL to the dossier for a given post.
*
* Deep-link URLs use admin.php?page=<slug> which is parent-agnostic.
* This method is unchanged from v0.19.2 -- only the registration parent changed.
*
* @param int $post_id WordPress post ID.
* @return string Admin URL to the dossier page.
*/

View File

@@ -38,6 +38,10 @@ class PRAutoBlogger_Image_Composer_Layout {
* plugin; sizes are px. Values were tuned against rendered samples (see
* PR #thread 2026-06-image-composer assets).
*
* Note: 'caption_margin_right' (og) and 'caption_side_padding' (square) were removed
* in v0.19.4 — the Imagick renderer enforces right-edge geometry via caption_chars_per_line
* and centered x positioning respectively, so those keys had no consumer.
*
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
*/
public static function defaults(): array {
@@ -60,7 +64,6 @@ class PRAutoBlogger_Image_Composer_Layout {
'caption_size' => 32,
'caption_color' => '#FFFFFF',
'caption_gap' => 24,
'caption_margin_right' => 40,
'caption_max_lines' => 2,
'caption_chars_per_line' => 52,
'caption_line_height' => 1.3,
@@ -79,7 +82,6 @@ class PRAutoBlogger_Image_Composer_Layout {
'caption_color' => self::COLOR_INK,
'caption_max_lines' => 3,
'caption_chars_per_line' => 36,
'caption_side_padding' => 64,
'caption_line_height' => 1.3,
),
);

View File

@@ -91,7 +91,9 @@ class PRAutoBlogger_Post_Assembler {
* are generated. Its cost is logged with the run_id but no post_id. After all
* articles finish, this method divides the research cost evenly and inserts
* one 'llm_research' row per article so the cost breakdown popover includes
* the amortized research overhead.
* the amortized research overhead. The audit fields prompt_version and
* agent_role are carried from the original research row (v0.19.4 fix;
* prior builds left them NULL/empty — see phase-1 thread seq 49).
*
* Side effects: database SELECT, INSERT, DELETE on generation_log.
*
@@ -109,7 +111,7 @@ class PRAutoBlogger_Post_Assembler {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$research_row = $wpdb->get_row(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens, prompt_version, agent_role
FROM {$table}
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
LIMIT 1",
@@ -158,6 +160,8 @@ class PRAutoBlogger_Post_Assembler {
'estimated_cost' => $amortized_cost,
'response_status' => 'success',
'error_message' => '',
'prompt_version' => $research_row->prompt_version,
'agent_role' => $research_row->agent_role ?? '',
'created_at' => current_time( 'mysql' ),
)
);

View File

@@ -138,7 +138,8 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
* @param int $offset Pagination offset (0-based).
* @param int $limit Page size.
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
* @throws \RuntimeException On HTTP error or malformed response.
* @throws \RuntimeException On HTTP error or malformed response (non-200 exceptions
* include up to 200 chars of the response body for diagnosis).
*/
private function fetch_page( string $api_key, int $offset, int $limit ): array {
$payload = array(
@@ -171,8 +172,10 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) {
$raw_body = (string) wp_remote_retrieve_body( $response );
$body_excerpt = '' !== $raw_body ? ' — body: ' . substr( $raw_body, 0, 200 ) : '';
throw new \RuntimeException(
sprintf( 'Runware modelSearch returned HTTP %d', $status )
sprintf( 'Runware modelSearch returned HTTP %d%s', $status, $body_excerpt )
);
}

View File

@@ -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.2
* Version: 0.19.4
* 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.2' );
define( 'PRAUTOBLOGGER_VERSION', '0.19.4' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

View File

@@ -205,3 +205,16 @@ if ( ! class_exists( 'WP_Post' ) ) {
}
}
}
// Plugin URL and version constants used by admin enqueue methods.
// Defined here so all tests that load admin classes can stub WP enqueue calls
// without hitting "Undefined constant" errors.
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_URL' ) ) {
define( 'PRAUTOBLOGGER_PLUGIN_URL', 'https://test.example.com/wp-content/plugins/prautoblogger/' );
}
if ( ! defined( 'PRAUTOBLOGGER_VERSION' ) ) {
define( 'PRAUTOBLOGGER_VERSION', '0.19.3-test' );
}
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_DIR' ) ) {
define( 'PRAUTOBLOGGER_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
}

View File

@@ -0,0 +1,198 @@
<?php
/**
* Tests for Dossier admin page hookname faithfulness — request-time mechanism.
*
* ## Why the v0.19.2 hide-by-unset pattern caused a 403
*
* WordPress computes a page's hook suffix at TWO separate moments:
*
* 1. Registration time (inside add_submenu_page):
* WP resolves the parent slug against $admin_page_hooks. If found, the hookname
* becomes "{parent_key}_page_{slug}" (e.g. "prautoblogger_page_prautoblogger-dossier").
*
* 2. Request time (inside wp-admin/admin.php):
* WP calls get_admin_page_parent() which scans $submenu for the slug. If not found
* (because it was unset post-registration), it falls back to "admin_page_{slug}".
*
* When these two hooknames diverge, the render callback lives under name-A but WP
* dispatches to name-B -- no handler found -- wp_die(403).
*
* The fix: options.php-parent guarantees "admin_page_{slug}" at BOTH moments.
* No $submenu mutation is needed or permitted.
*
* ## Discriminator function: simulate_request_time_hookname
*
* Replicates get_admin_page_parent() + hookname derivation in wp-admin/admin.php.
* Returns different hooknames for hide-by-unset vs options.php-parent, making the
* 403 divergence mechanically verifiable in a unit test.
*
* Structural invariant tests (parent slug, URL shape, priority) live in
* DossierMenuRegistrationTest.
*
* @see DossierMenuRegistrationTest -- structural invariants + deep-link URLs + priority.
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
* @see CONVENTIONS.md -- §Hidden Admin Pages.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
class DossierHooknameMechanismTest extends DossierMenuRegistrationTestCase {
// =========================================================================
// § REQUEST-TIME HOOKNAME FAITHFULNESS
// The primary regression guard for the 403 incident class.
// =========================================================================
/**
* Simulate the request-time hookname resolution algorithm.
*
* Replicates get_admin_page_parent() + the hookname derivation in
* wp-admin/admin.php::wp_admin_canonical_url() / do_action("admin_{$page}"):
*
* scan $submenu for the slug
* if found at parent P → return hookname derived from P
* if not found → return "admin_page_{slug}"
*
* This is the discriminator function: it WILL return a different hookname than
* add_submenu_page returned when the slug has been unset from $submenu.
*
* @param array<string, array<int, array<int, string>>> $submenu_state $submenu after all registration + mutations.
* @param string $slug Page slug to resolve.
* @return string Request-time hookname.
*/
private function simulate_request_time_hookname( array $submenu_state, string $slug ): string {
foreach ( $submenu_state as $parent => $items ) {
foreach ( $items as $item ) {
if ( isset( $item[2] ) && $slug === $item[2] ) {
// Found the slug; derive hookname the same way as registration.
if ( 'options.php' === $parent ) {
return 'admin_page_' . $slug;
}
$parent_key = str_replace( '.php', '', $parent );
return $parent_key . '_page_' . $slug;
}
}
}
// Slug absent from $submenu -- WP falls back to the orphan namespace.
return 'admin_page_' . $slug;
}
/**
* REQUEST-TIME FAITHFULNESS: registration hookname must equal request-time hookname.
*
* This test directly discriminates the two patterns:
*
* HIDE-BY-UNSET (v0.19.2, BROKEN):
* - add_submenu_page('prautoblogger-settings', ...) → 'prautoblogger_page_prautoblogger-dossier'
* - unset($submenu['prautoblogger-settings'][$idx])
* - post-registration $submenu does NOT contain the slug
* - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier'
* - 'prautoblogger_page_...' ≠ 'admin_page_...' → FAIL ✓ (test correctly detects broken state)
*
* OPTIONS.PHP-PARENT (v0.19.3, FIXED):
* - add_submenu_page('options.php', ...) → 'admin_page_prautoblogger-dossier'
* - no $submenu mutation
* - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier'
* - MATCH → PASS ✓
*
* This test ALSO fails if any future change reintroduces $submenu mutation,
* or switches back to a plugin-owned parent without preserving the submenu entry.
*/
public function test_request_time_hookname_matches_registration_hookname(): void {
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
$this->assertNotNull(
$this->registration_hookname,
'add_submenu_page must be called during on_register_menu()'
);
// Capture $submenu state AFTER all mutations that on_register_menu() performs.
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read.
global $submenu;
$submenu_after = $submenu;
$request_time_hookname = $this->simulate_request_time_hookname(
$submenu_after,
\PRAutoBlogger_Dossier_Page::PAGE_SLUG
);
$this->assertSame(
$this->registration_hookname,
$request_time_hookname,
sprintf(
'Hookname mismatch: registration="%s", request-time="%s". '
. 'This is the 403 failure class. '
. 'The hide-by-unset pattern unsets the slug from $submenu after registration, '
. 'so get_admin_page_parent() fails at request time, WP recomputes under '
. 'the orphan "admin_page_*" namespace, finds no registered handler, '
. 'and calls wp_die(403). Fix: use options.php as parent -- hookname is '
. '"admin_page_{slug}" at both registration and request time, no mutation needed.',
$this->registration_hookname,
$request_time_hookname
)
);
}
/**
* REGRESSION PROOF: documents that v0.19.2 hide-by-unset would have failed the
* request-time test above.
*
* Directly simulates the broken implementation, applies simulate_request_time_hookname,
* and asserts the MISMATCH -- proving the discriminator is effective.
*
* This test documents the failure state and serves as evidence that the test methodology
* is correct. It passes by verifying the broken pattern produces the wrong hookname.
*/
public function test_hide_by_unset_would_fail_request_time_check(): void {
// --- SIMULATE V0.19.2 hide-by-unset IMPLEMENTATION ---
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation.
global $submenu;
// Step 1: Registration (add_submenu_page with plugin parent).
// Real add_submenu_page stub returns 'prautoblogger_page_prautoblogger-dossier'
// when parent is 'prautoblogger-settings'. Populate $submenu as WP does.
$broken_parent_slug = 'prautoblogger-settings';
$slug = 'prautoblogger-dossier';
$submenu[ $broken_parent_slug ] = $submenu[ $broken_parent_slug ] ?? array();
$submenu[ $broken_parent_slug ][] = array( 'Article Dossier', 'manage_options', $slug, 'Dossier' );
// The hookname WP computed at registration time with plugin-owned parent:
$broken_registration_hookname = 'prautoblogger_page_' . $slug;
// Step 2: The unset mutation (the bug).
// This is what v0.19.2's on_register_menu() did after add_submenu_page.
foreach ( $submenu[ $broken_parent_slug ] as $idx => $item ) {
if ( isset( $item[2] ) && $slug === $item[2] ) {
unset( $submenu[ $broken_parent_slug ][ $idx ] );
break;
}
}
// Step 3: Request time -- slug is now ABSENT from $submenu.
$request_time_hookname = $this->simulate_request_time_hookname( $submenu, $slug );
// THE MISMATCH that caused the 403:
$this->assertNotSame(
$broken_registration_hookname,
$request_time_hookname,
'Test methodology error: hide-by-unset must produce a hookname mismatch. '
. 'If this assertion fails the discriminator is broken.'
);
// Specifically: request-time falls back to admin_page_* orphan namespace.
$this->assertSame(
'admin_page_' . $slug,
$request_time_hookname,
'Request-time fallback must be admin_page_{slug} when slug absent from $submenu.'
);
$this->assertSame(
'prautoblogger_page_' . $slug,
$broken_registration_hookname,
'Registration-time hookname with prautoblogger-settings parent must be prautoblogger_page_{slug}.'
);
}
}

View File

@@ -1,76 +1,203 @@
<?php
/**
* Tests for Article Dossier admin page menu registration.
* Tests for Dossier admin page menu registration — structural invariants.
*
* Regression guard for the menu-ordering rule (v0.19.1 lesson):
* any submenu registered BEFORE add_menu_page fires will resolve under the
* fallback hookname `admin_page_*` instead of `prautoblogger_page_*`, causing
* a wp_die 404 at request time.
* Covers: parent slug (options.php), $submenu non-mutation, page slug constant,
* asset enqueue gate prefix, deep-link URL shape, and hook priority ordering.
*
* The dossier page uses a hidden-submenu pattern (removed from $submenu after
* registration) at priority 12 -- after board (11) and parent (10).
* Request-time hookname faithfulness tests live in DossierHooknameMechanismTest.
*
* @see DossierHooknameMechanismTest -- request-time discriminator + regression proof.
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
* @see CONVENTIONS.md -- §Hidden Admin Pages.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
class DossierMenuRegistrationTest extends BaseTestCase {
class DossierMenuRegistrationTest extends DossierMenuRegistrationTestCase {
/** @var array<int, string> Call sequence spy. */
private array $call_sequence = [];
// =========================================================================
// § STRUCTURAL INVARIANTS
// =========================================================================
protected function setUp(): void {
parent::setUp();
$this->call_sequence = [];
/**
* Parent slug must be PARENT_SLUG constant ('options.php').
*
* FAILS on v0.19.2 (parent was 'prautoblogger-settings').
* PASSES after v0.19.3 fix (parent is 'options.php').
*/
public function test_dossier_parent_slug_is_options_php(): void {
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
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;
}
$this->assertSame(
'options.php',
$this->captured_parent_slug,
'Dossier must use options.php as parent (canonical hidden-page pattern). '
. 'Never use a plugin-owned parent with post-registration $submenu unset -- '
. 'that is the v0.19.2 403 bug class. See CONVENTIONS.md §Hidden Admin Pages.'
);
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();
$this->assertSame(
\PRAutoBlogger_Dossier_Page::PARENT_SLUG,
$this->captured_parent_slug,
'Captured parent slug must match PARENT_SLUG constant.'
);
}
/**
* 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.
* No $submenu mutation after registration.
*
* FAILS if dossier drops to priority ≤ 11 (same as or before board).
* FAILS if dossier drops to priority ≤ 10 (same as or before parent).
* options.php-parent pages are hidden by construction; the slug must remain
* in $submenu so get_admin_page_parent() can resolve the parent at request time.
*
* FAILS on v0.19.2 (slug was unset from $submenu after registration).
* PASSES after v0.19.3 fix (no mutation).
*/
public function test_dossier_hook_registered_after_board(): void {
public function test_no_submenu_mutation_after_registration(): void {
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read.
global $submenu;
// The dossier slug must still be present somewhere in $submenu.
$slug_found = false;
foreach ( $submenu as $parent => $items ) {
foreach ( $items as $item ) {
if ( isset( $item[2] ) && \PRAutoBlogger_Dossier_Page::PAGE_SLUG === $item[2] ) {
$slug_found = true;
break 2;
}
}
}
$this->assertTrue(
$slug_found,
'Dossier slug must remain in $submenu after on_register_menu() completes. '
. 'Unsetting it causes get_admin_page_parent() to fail at request time and '
. 'produces the admin_page_* orphan hookname -- the v0.19.2 403 bug class. '
. 'options.php-parent pages are hidden by construction; no mutation needed.'
);
}
/**
* Page slug must be PAGE_SLUG constant ('prautoblogger-dossier').
*/
public function test_dossier_uses_correct_slug(): void {
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
$this->assertSame(
\PRAutoBlogger_Dossier_Page::PAGE_SLUG,
$this->captured_menu_slug,
'Dossier must register under PAGE_SLUG.'
);
}
/**
* Asset enqueue gate must use 'admin_page_' prefix (not 'prautoblogger_page_').
*
* options.php-parent pages always resolve to 'admin_page_{slug}'.
* FAILS on v0.19.2 (gate checked 'prautoblogger_page_prautoblogger-dossier').
* PASSES after v0.19.3 fix (gate checks 'admin_page_prautoblogger-dossier').
*/
public function test_asset_enqueue_gate_uses_admin_page_prefix(): void {
Functions\when( 'wp_enqueue_style' )->justReturn( null );
Functions\when( 'wp_enqueue_script' )->justReturn( null );
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$expected_hook = 'admin_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG;
// Wrong hook -- assets must NOT enqueue.
$enqueued = false;
Functions\when( 'wp_enqueue_style' )->alias( function () use ( &$enqueued ) {
$enqueued = true;
} );
$dossier_page->on_enqueue_assets( 'wrong_hook_prautoblogger_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG );
$this->assertFalse(
$enqueued,
'Assets must NOT enqueue when hook_suffix is the old prautoblogger_page_* hook.'
);
// Correct hook -- assets must enqueue.
$dossier_page->on_enqueue_assets( $expected_hook );
$this->assertTrue(
$enqueued,
sprintf(
'Assets must enqueue when hook_suffix is "%s" (admin_page_* from options.php parent).',
$expected_hook
)
);
}
// =========================================================================
// § DEEP-LINK URL INVARIANCE
// =========================================================================
/**
* url_for_post() must produce admin.php?page=prautoblogger-dossier&post_id=N.
*
* Deep-link URLs are parent-agnostic -- they reference only the page slug.
* The fix changes only the registration parent; the URL shape is unchanged.
*/
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,
'url_for_post() must contain the dossier page slug.'
);
$this->assertStringContainsString(
'42',
$url,
'url_for_post() must contain the post_id.'
);
$this->assertStringContainsString(
'admin.php',
$url,
'url_for_post() must use admin.php (parent-agnostic deep-link).'
);
}
/**
* url_for_post() URL shape is unchanged from v0.19.2.
*
* Confirms the exact URL format that board cards and the metabox link depend on.
*/
public function test_url_for_post_exact_shape(): void {
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 930 );
// Must contain admin.php?page=prautoblogger-dossier&post_id=930
// (or equivalent query string order -- admin_url may vary order).
$this->assertStringContainsString( 'page=prautoblogger-dossier', $url );
$this->assertStringContainsString( 'post_id=930', $url );
}
// =========================================================================
// § HOOK REGISTRATION ORDERING (priority guard)
// =========================================================================
/**
* Dossier on_register_menu must remain at priority 12 (after board 11, parent 10).
*
* The options.php-parent fix removes the dependency on ordering relative to the
* plugin's own parent menu (since we no longer add a submenu under it), but we
* keep the priority assertion to prevent accidental priority regression.
*/
public function test_dossier_hook_registered_at_priority_12(): 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 );
@@ -94,56 +221,15 @@ class DossierMenuRegistrationTest extends BaseTestCase {
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;
}
$this->assertGreaterThan(
$parent_priority,
$board_priority,
'Board priority must exceed parent priority.'
);
$this->assertGreaterThan(
$board_priority,
$dossier_priority,
'Dossier priority must exceed board priority.'
);
$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 );
}
}

View File

@@ -0,0 +1,120 @@
<?php
/**
* Shared test harness for Dossier menu registration test suites.
*
* Provides the Brain\Monkey spy stubs (add_menu_page, add_submenu_page) and the
* $submenu isolation that both DossierMenuRegistrationTest and
* DossierHooknameMechanismTest depend on.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
abstract class DossierMenuRegistrationTestCase extends BaseTestCase {
/** @var array<int, string> Call sequence spy. */
protected array $call_sequence = [];
/**
* Captured parent slug from the most recent add_submenu_page call.
*
* @var string|null
*/
protected ?string $captured_parent_slug = null;
/**
* Captured menu slug from the most recent add_submenu_page call.
*
* @var string|null
*/
protected ?string $captured_menu_slug = null;
/**
* Hookname returned by the most recent add_submenu_page call.
*
* In real WordPress this is `{parent_key}_page_{slug}` when parent is known,
* or `admin_page_{slug}` when parent is not in $admin_page_hooks. For options.php
* children WordPress always emits `admin_page_{slug}`.
*
* @var string|null
*/
protected ?string $registration_hookname = null;
protected function setUp(): void {
parent::setUp();
$this->call_sequence = [];
$this->captured_parent_slug = null;
$this->captured_menu_slug = null;
$this->registration_hookname = null;
// Spy stub: tracks call order, captures slugs, returns a hookname that
// reflects how WordPress actually resolves it for known parents.
Functions\when( 'add_menu_page' )->alias(
function () {
$this->call_sequence[] = 'add_menu_page';
return 'prautoblogger-settings';
}
);
// Simulate WordPress hookname computation:
// - options.php parent → 'admin_page_{slug}'
// - known plugin parent → '{parent_key}_page_{slug}'
// - unknown parent → 'admin_page_{slug}' (orphan fallback)
Functions\when( 'add_submenu_page' )->alias(
function (
string $parent_slug,
string $page_title,
string $menu_title,
string $capability,
string $menu_slug,
$callback = ''
) {
$this->call_sequence[] = 'add_submenu_page:' . $parent_slug . ':' . $menu_slug;
$this->captured_parent_slug = $parent_slug;
$this->captured_menu_slug = $menu_slug;
if ( 'options.php' === $parent_slug ) {
$hookname = 'admin_page_' . $menu_slug;
} else {
$parent_key = str_replace( '.php', '', $parent_slug );
$hookname = $parent_key . '_page_' . $menu_slug;
}
$this->registration_hookname = $hookname;
// Populate $submenu as WordPress does at registration time.
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation.
global $submenu;
$submenu[ $parent_slug ] = $submenu[ $parent_slug ] ?? array();
$submenu[ $parent_slug ][] = array( $page_title, $capability, $menu_slug, $menu_title );
return $hookname;
}
);
Functions\when( 'current_user_can' )->justReturn( true );
Functions\when( 'get_option' )->justReturn( false );
Functions\when( 'admin_url' )->alias(
function ( string $path = '' ) {
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
}
);
Functions\when( '__' )->returnArg( 1 );
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
$GLOBALS['submenu'] = array();
}
protected function tearDown(): void {
$this->call_sequence = [];
$this->captured_parent_slug = null;
$this->captured_menu_slug = null;
$this->registration_hookname = null;
unset( $GLOBALS['submenu'] );
parent::tearDown();
}
}

View File

@@ -0,0 +1,246 @@
<?php
/**
* Tests for PRAutoBlogger_Image_Composer_Editor (GD/WP-editor rung).
*
* Validates the three main branches of cover_crop():
* - wp_get_image_editor() returns WP_Error → null returned.
* - Resize returns WP_Error → null returned.
* - Exact size not reachable (would upscale) → null returned.
* - Happy path: successful resize/save returns the expected shape.
* And compose_variants() iteration:
* - Roles with bad geometry (0-width target) are skipped.
* - Null cover_crop result for a role skips that role with a debug log.
*
* Brain Monkey stubs wp_get_image_editor, get_temp_dir so no filesystem or
* WP runtime is needed.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ImageComposerEditorTest extends BaseTestCase {
/**
* Stub get_option so PRAutoBlogger_Logger can construct without Brain Monkey
* throwing on the prautoblogger_log_level option lookup. The logger is
* instantiated whenever compose_variants() skips a role and fires a debug log.
*/
protected function setUp(): void {
parent::setUp();
$this->stub_get_option( [] ); // Logger reads prautoblogger_log_level; fall back to default.
}
/** Minimal layout geometry mirroring Layout::defaults() relevant keys. */
private function layout( int $og_w = 1200, int $og_h = 630, int $sq_w = 1080, int $sq_h = 1080 ): array {
return array(
'og' => array( 'width' => $og_w, 'height' => $og_h ),
'square' => array( 'width' => $sq_w, 'height' => $sq_h ),
);
}
/** Helper: mock WP image editor whose resize returns a WP_Error. */
private function mock_editor_resize_error(): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( new \WP_Error( 'resize_fail', 'Mock resize failure' ) );
return $editor;
}
/** Helper: mock WP image editor whose save returns a WP_Error. */
private function mock_editor_save_error( int $w, int $h ): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
$editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) );
$editor->method( 'save' )->willReturn( new \WP_Error( 'save_fail', 'Mock save failure' ) );
return $editor;
}
/** Helper: mock WP image editor that succeeds at the requested size. */
private function mock_editor_success( int $w, int $h ): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
$editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) );
$editor->method( 'save' )->willReturn( array( 'path' => '/tmp/prab_ok.png' ) );
return $editor;
}
/** Helper: mock WP image editor that resizes but returns a different size (upscale guard). */
private function mock_editor_wrong_size( int $requested_w, int $requested_h, int $actual_w, int $actual_h ): object {
$editor = $this->getMockBuilder( \stdClass::class )
->addMethods( array( 'resize', 'get_size', 'save' ) )
->getMock();
$editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success.
$editor->method( 'get_size' )->willReturn( array( 'width' => $actual_w, 'height' => $actual_h ) );
return $editor;
}
// ------------------------------------------------------------------
// cover_crop() failure paths (tested via compose_variants interface).
// ------------------------------------------------------------------
/**
* wp_get_image_editor() returns WP_Error → role skipped,
* no variants emitted.
*/
public function test_editor_wp_error_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn(
new \WP_Error( 'no_editor', 'No image editor available' )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
$this->assertSame( array(), $variants, 'wp_get_image_editor WP_Error must yield no variants' );
}
/**
* Resize step returns WP_Error → role skipped.
*/
public function test_resize_wp_error_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn( $this->mock_editor_resize_error() );
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
$this->assertSame( array(), $variants, 'Resize WP_Error must yield no variants' );
}
/**
* Exact size not reachable (WP editor silently gives a different size
* instead of upscaling) → role skipped via the size-mismatch guard.
*/
public function test_size_mismatch_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
// Editor resizes to 900×473 when 1080×1080 square was requested (would upscale).
Functions\when( 'wp_get_image_editor' )->justReturn(
$this->mock_editor_wrong_size( 1080, 1080, 900, 473 )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'square' ), $this->layout() );
$this->assertSame( array(), $variants, 'Size mismatch (upscale guard) must yield no variants' );
}
/**
* Save step returns WP_Error → role skipped.
*/
public function test_save_wp_error_skips_role(): void {
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn(
$this->mock_editor_save_error( 1200, 630 )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() );
$this->assertSame( array(), $variants, 'Save WP_Error must yield no variants' );
}
// ------------------------------------------------------------------
// Happy path — OG resizes successfully.
// ------------------------------------------------------------------
/**
* Successful cover_crop for 'og': variant shape matches expectations.
* file_put_contents and file_get_contents are exercised via temp file
* in the test runner's writable temp dir; the editor mock does not
* actually modify the file, but the class reads it back and returns its
* bytes.
*/
public function test_successful_og_resize_returns_correct_shape(): void {
$temp_dir = sys_get_temp_dir() . '/';
Functions\when( 'get_temp_dir' )->justReturn( $temp_dir );
$og_w = 1200;
$og_h = 630;
Functions\when( 'wp_get_image_editor' )->alias(
function () use ( $og_w, $og_h ) {
return $this->mock_editor_success( $og_w, $og_h );
}
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_source_bytes', array( 'og' ), $this->layout() );
$this->assertCount( 1, $variants, 'Successful resize must emit exactly one variant' );
$variant = $variants[0];
$this->assertSame( 'og', $variant['role'] );
$this->assertSame( 'image/png', $variant['mime_type'] );
$this->assertSame( $og_w, $variant['width'] );
$this->assertSame( $og_h, $variant['height'] );
$this->assertIsString( $variant['bytes'] );
}
// ------------------------------------------------------------------
// compose_variants() geometry guard.
// ------------------------------------------------------------------
/**
* A role whose layout target has width=0 is skipped before any I/O —
* no editor call, no variant emitted.
*/
public function test_zero_width_target_skips_role_before_editor(): void {
// Make the 'og' slot have zero width so the guard fires before any I/O.
$layout = array(
'og' => array( 'width' => 0, 'height' => 630 ),
);
// wp_get_image_editor must never be called.
Functions\expect( 'wp_get_image_editor' )->never();
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $layout );
$this->assertSame( array(), $variants );
}
/**
* Role not present in layout skips cleanly.
*/
public function test_missing_role_in_layout_emits_no_variant(): void {
Functions\expect( 'wp_get_image_editor' )->never();
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$editor = new \PRAutoBlogger_Image_Composer_Editor();
$variants = $editor->compose_variants( 'fake_bytes', array( 'nonexistent_role' ), array() );
$this->assertSame( array(), $variants );
}
}

View File

@@ -322,4 +322,43 @@ class ImageComposerTest extends BaseTestCase {
$this->assertStringStartsWith( 'prautoblogger_', $key );
}
}
/**
* Ladder rung 2 forced via the override filter ('gd'): compose() emits the
* pass-through featured variant as first element, and fires exactly one WARNING.
* The Imagick renderer must never be called on this rung.
*
* Source: QA verdict 2026-06-11-6f76df1.md P2-1 and CTO handoff
* convo/prautoblogger/threads/2026-06-maintenance-v0194/01-cto-maintenance-handoff.md item 1.
*/
public function test_gd_rung_emits_passthrough_featured_and_single_warning(): void {
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'gd' );
// Imagick renderer must not be invoked on the gd rung.
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
$renderer->expects( $this->never() )->method( 'compose_featured' );
$renderer->expects( $this->never() )->method( 'compose_og' );
$renderer->expects( $this->never() )->method( 'compose_square' );
// Stub the WP functions that cover_crop() calls inside the GD rung.
// get_temp_dir() is called first; wp_get_image_editor() returning WP_Error
// makes cover_crop() return null for every role → compose_variants() returns []
// → the GD rung emits only the pass-through featured (array_merge($passthrough, [])).
// This is the real degradation-to-passthrough path the test is designed to assert.
Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' );
Functions\when( 'wp_get_image_editor' )->justReturn(
new \WP_Error( 'no_editor', 'Mock: no GD editor in test environment' )
);
Functions\when( 'is_wp_error' )->alias( static function ( $val ) {
return $val instanceof \WP_Error;
} );
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
$variants = $composer->compose( $this->image_data, $this->context() );
// Pass-through featured variant is always first.
$this->assertNotEmpty( $variants );
$this->assertSame( 'featured', $variants[0]['role'] );
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
}
}

View File

@@ -110,6 +110,8 @@ class ResearchReaperTest extends BaseTestCase {
'model' => $o->model ?? 'gemini-flash',
'prompt_tokens' => $o->prompt_tokens ?? 1000,
'completion_tokens' => $o->completion_tokens ?? 500,
'prompt_version' => $o->prompt_version ?? null,
'agent_role' => $o->agent_role ?? '',
];
}
}