Compare commits
4 Commits
claude/qa-
...
claude/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579c66ecab | ||
|
|
91dea0501a | ||
|
|
72bfd232a4 | ||
|
|
682c98e981 |
243
.github/workflows/cto-review.yml
vendored
243
.github/workflows/cto-review.yml
vendored
@@ -1,17 +1,20 @@
|
|||||||
name: CTO Review
|
name: CTO Review
|
||||||
|
|
||||||
# Automated PR review powered by Claude Haiku (via OpenRouter).
|
# Automated PR review powered by Claude + DeepSeek + Kimi (via OpenRouter)
|
||||||
|
# plus Neurometric PR-Summarizer (a Qwen3-4B task-specialist SLM).
|
||||||
#
|
#
|
||||||
# A/B panel decision 2026-06-12: panel dropped to Haiku-only.
|
# During the A/B window (until further notice), every PR gets reviewed by FOUR models
|
||||||
# Evidence: convo/cto/threads/2026-06-qa-ab-review/02-analyst-findings.md
|
# in parallel. The PR comment shows each model's verdict side-by-side so we can compare
|
||||||
# DeepSeek: high false-positive rate (absint()="code exec", PLUGIN_DIR="dir traversal").
|
# quality/cost on real PRs before picking a permanent default.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Does NOT merge. Posts formal APPROVE/REQUEST_CHANGES/COMMENT reviews.
|
# 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.
|
||||||
#
|
#
|
||||||
# Requires repo secret: OPENROUTER_API_KEY
|
# 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).
|
||||||
#
|
#
|
||||||
# Cost cap: tracked in Repository Variables (CTO_REVIEW_SPEND_CENTS, CTO_REVIEW_CAP_CENTS).
|
# 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.
|
# Default cap is 1000 cents ($10/month). Auto-resets on the 1st of each month.
|
||||||
@@ -122,8 +125,8 @@ jobs:
|
|||||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||||
# Write the diff to a file, not a step output / env var. Passing a
|
# 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
|
# ~400KB diff through the process env (execve argv+envp) exceeds the
|
||||||
# runner ARG_MAX and fails the next step with "Argument list too long".
|
# runner ARG_MAX and fails the next step with "Argument list too long"
|
||||||
# File transport also sidesteps the ~1MB step-output cap.
|
# (issue #110). 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
|
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")
|
FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
|
||||||
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
|
SIZE=$(wc -c < /tmp/cto-review-diff.txt)
|
||||||
@@ -148,11 +151,12 @@ jobs:
|
|||||||
echo "EOF_CONV"
|
echo "EOF_CONV"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Call Haiku via OpenRouter
|
- name: Call 4 models in parallel (OpenRouter + Neurometric)
|
||||||
if: steps.budget.outputs.skip != 'true'
|
if: steps.budget.outputs.skip != 'true'
|
||||||
id: review
|
id: review
|
||||||
env:
|
env:
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
|
NEUROMETRIC_API_KEY: ${{ secrets.NEUROMETRIC_API_KEY }}
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
PR_BODY: ${{ github.event.pull_request.body }}
|
PR_BODY: ${{ github.event.pull_request.body }}
|
||||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
@@ -180,17 +184,24 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json, os, sys, urllib.request
|
import json, os, sys, urllib.request, concurrent.futures
|
||||||
|
|
||||||
api_key = os.environ["OPENROUTER_API_KEY"]
|
api_key = os.environ["OPENROUTER_API_KEY"]
|
||||||
|
|
||||||
# Haiku-only reviewer. A/B panel dropped 2026-06-12 per QA data:
|
# A/B harness: four models. Each gets the same prompt.
|
||||||
# DeepSeek high false-positive rate, Kimi ~60% failure rate,
|
# Primary model's verdict drives the heading. Others are labeled.
|
||||||
# Neurometric zero unique-real findings. Haiku retained as sole voice.
|
# Edit this list to change the roster (or drop to 1 after A/B window closes).
|
||||||
MODEL_SLUG = "anthropic/claude-haiku-4.5"
|
#
|
||||||
MODEL_DISPLAY = "Claude Haiku 4.5"
|
# provider_type semantics:
|
||||||
INPUT_PER_M_USD = 1.00
|
# - "openrouter_tools": OpenAI tool-calling via OpenRouter; structured verdict.
|
||||||
OUTPUT_PER_M_USD = 5.00
|
# - "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"),
|
||||||
|
]
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -253,8 +264,32 @@ jobs:
|
|||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
PER_MODEL_TIMEOUT = 60 # seconds; one hung model should not block siblings
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
]
|
||||||
|
|
||||||
|
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 = {
|
body = {
|
||||||
"model": MODEL_SLUG,
|
"model": slug,
|
||||||
"max_tokens": 2048,
|
"max_tokens": 2048,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
@@ -270,83 +305,177 @@ jobs:
|
|||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"HTTP-Referer": "https://github.com/peptiderepo",
|
"HTTP-Referer": "https://github.com/peptiderepo",
|
||||||
"X-Title": f"peptiderepo CTO reviewer ({MODEL_DISPLAY})"
|
"X-Title": f"peptiderepo CTO reviewer ({display})"
|
||||||
},
|
},
|
||||||
method="POST"
|
method="POST"
|
||||||
)
|
)
|
||||||
|
|
||||||
error = None
|
|
||||||
review = None
|
|
||||||
cost_cents = 0.0
|
|
||||||
ptok = 0
|
|
||||||
ctok = 0
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=PER_MODEL_TIMEOUT) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)[:300]
|
return {
|
||||||
data = {}
|
"slug": slug, "display": display, "is_primary": is_primary,
|
||||||
|
"provider_type": "openrouter_tools",
|
||||||
|
"error": str(e)[:300],
|
||||||
|
"cost_cents": 0,
|
||||||
|
}
|
||||||
|
|
||||||
if not error:
|
|
||||||
choice = data.get("choices", [{}])[0].get("message", {})
|
choice = data.get("choices", [{}])[0].get("message", {})
|
||||||
tool_calls = choice.get("tool_calls", [])
|
tool_calls = choice.get("tool_calls", [])
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
error = f"No tool_call in response. Raw content: {str(choice)[:300]}"
|
return {
|
||||||
else:
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
review = json.loads(tool_calls[0]["function"]["arguments"])
|
review = json.loads(tool_calls[0]["function"]["arguments"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = f"Malformed tool args: {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", {})
|
usage = data.get("usage", {})
|
||||||
ptok = usage.get("prompt_tokens", 0)
|
ptok = usage.get("prompt_tokens", 0)
|
||||||
ctok = usage.get("completion_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_usd = (ptok * in_rate / 1_000_000) + (ctok * out_rate / 1_000_000)
|
||||||
cost_cents = cost_usd * 100
|
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"
|
||||||
|
|
||||||
# Build comment
|
|
||||||
if error:
|
|
||||||
heading_verdict = "ERROR"
|
|
||||||
lines = [f"## CTO Review — {heading_verdict}", ""]
|
lines = [f"## CTO Review — {heading_verdict}", ""]
|
||||||
lines.append(f"Model failed: `{error}`")
|
lines.append(f"_A/B harness active: {len(results)} models reviewing in parallel. Primary verdict drives heading; others are advisory._")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"_reviewer: {MODEL_DISPLAY}_")
|
|
||||||
else:
|
total_cents = 0.0
|
||||||
heading_verdict = review.get("verdict", "COMMENT")
|
for r in results:
|
||||||
lines = [f"## CTO Review — {heading_verdict}", ""]
|
lines.append(f"---")
|
||||||
lines.append(f"**Verdict:** {heading_verdict}")
|
primary_tag = " (primary)" if r.get("is_primary") else ""
|
||||||
|
lines.append(f"### {r['display']}{primary_tag}")
|
||||||
|
if "error" in r:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(review.get("summary") or "_(model returned no summary)_")
|
lines.append(f"⚠️ Model failed: `{r['error']}`")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if review.get("concerns"):
|
continue
|
||||||
|
rev = r["review"]
|
||||||
|
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("")
|
||||||
|
lines.append(rev.get("summary") or "_(model returned no summary)_")
|
||||||
|
lines.append("")
|
||||||
|
if rev.get("concerns"):
|
||||||
lines.append("**Concerns:**")
|
lines.append("**Concerns:**")
|
||||||
for c in review["concerns"]:
|
for c in rev["concerns"]:
|
||||||
sev = c.get("severity", "UNKNOWN").upper()
|
sev = c.get("severity", "UNKNOWN").upper()
|
||||||
f = f"`{c['file']}`" if c.get("file") else ""
|
f = f"`{c['file']}`" if c.get("file") else ""
|
||||||
lines.append(f"- **{sev}** {f} — {c['issue']}")
|
lines.append(f"- **{sev}** {f} — {c['issue']}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if review.get("positives"):
|
if rev.get("positives"):
|
||||||
lines.append("**Positives:** " + "; ".join(review["positives"]))
|
lines.append("**Positives:** " + "; ".join(rev["positives"]))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"_tokens in/out: {ptok}/{ctok} · cost: ${cost_cents/100:.4f}_")
|
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("")
|
lines.append("")
|
||||||
|
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
lines.append(f"_Total review cost: **${cost_cents/100:.4f}**. Reviewer: {MODEL_DISPLAY}. Roster configured in `.github/workflows/cto-review.yml`._")
|
lines.append(f"_Total review cost: **${total_cents/100:.4f}**. A/B roster configured in `.github/workflows/cto-review.yml`._")
|
||||||
|
|
||||||
with open("/tmp/review-body.md", "w") as f:
|
with open("/tmp/review-body.md", "w") as f:
|
||||||
f.write("\n".join(lines))
|
f.write("\n".join(lines))
|
||||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||||
f.write(f"added_cents={int(round(cost_cents))}\n")
|
f.write(f"added_cents={int(round(total_cents))}\n")
|
||||||
f.write(f"skipped=0\n")
|
f.write(f"skipped=0\n")
|
||||||
f.write(f"primary_verdict={heading_verdict}\n")
|
f.write(f"primary_verdict={heading_verdict}\n")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
- name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT)
|
- name: Post PR review (formal APPROVE / REQUEST_CHANGES / COMMENT)
|
||||||
# Uses Haiku's verdict as the formal event. Falls back to COMMENT
|
# Uses primary model's verdict as the formal event. Falls back to COMMENT
|
||||||
# if the model failed or returned something unexpected.
|
# if the primary model failed or returned something unexpected.
|
||||||
if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1'
|
if: steps.budget.outputs.skip != 'true' && steps.review.outputs.skipped != '1'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ prautoblogger/
|
|||||||
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
|
│ │ ├── 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-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-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: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3)
|
│ │ ├── 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-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-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-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
||||||
@@ -871,8 +871,6 @@ 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)
|
### #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
|
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`
|
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.
|
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
|
||||||
@@ -906,25 +904,14 @@ 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
|
decisions, and image A/B pairs. It is link-accessed only (hidden submenu) — no navigation
|
||||||
entry appears in the WP sidebar.
|
entry appears in the WP sidebar.
|
||||||
|
|
||||||
**Page registration** (`class-dossier-page.php`): uses the canonical hidden-admin-page
|
**Page registration** (`class-dossier-page.php`): registered as a hidden submenu under
|
||||||
pattern -- registered under parent **`options.php`** at `admin_menu` priority **12**. The
|
`prautoblogger-settings` at `admin_menu` priority **12**, one step after the board (priority
|
||||||
hookname is `admin_page_prautoblogger-dossier` at BOTH registration time and request time,
|
11), which is itself after the parent menu (priority 10). The page is immediately removed
|
||||||
so there is no hookname mismatch and no need to manipulate `$submenu`. The page does not
|
from `$submenu` so it does not appear in the WP admin sidebar, but remains reachable by
|
||||||
appear in the WP admin sidebar (options.php-parent pages are hidden by construction).
|
direct URL. URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>`. Secured by
|
||||||
URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>` (unchanged from v0.19.2 --
|
`manage_options` capability + nonce (`prautoblogger_dossier`). Static `url_for_post(int
|
||||||
deep-link URLs are parent-agnostic). Secured by `manage_options` capability. Static
|
$post_id): string` provides the canonical deep-link. Regression test:
|
||||||
`url_for_post(int $post_id): string` provides the canonical deep-link.
|
`tests/unit/Admin/DossierMenuRegistrationTest.php`.
|
||||||
|
|
||||||
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`):
|
**5-query data assembly** (`class-dossier-data-assembler.php`):
|
||||||
|
|
||||||
|
|||||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -5,72 +5,6 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
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
|
## [0.19.2] - 2026-06-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -502,73 +502,3 @@ public function my_operation( $input ) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
That's it. No config, no feature-flag checks, no try/catch needed.
|
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -6,25 +6,10 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* Registers and renders the Article Dossier admin page (M2).
|
* Registers and renders the Article Dossier admin page (M2).
|
||||||
*
|
*
|
||||||
* The dossier is link-accessed -- it is NOT a visible submenu item. It uses
|
* The dossier is link-accessed -- it is NOT a visible submenu item. It registers
|
||||||
* the canonical hidden-admin-page pattern: register under parent 'options.php'
|
* as a hidden submenu under 'prautoblogger-settings' at admin_menu priority 12
|
||||||
* so WordPress computes the hookname as `admin_page_prautoblogger-dossier` at
|
* (after board at 11) so WordPress resolves the hookname correctly. Board cards and
|
||||||
* BOTH registration time AND request time. The page is hidden from nav by
|
* post list columns deep-link here via ?page=prautoblogger-dossier&post_id=X.
|
||||||
* 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,
|
* Design contract: Proposal C "Editorial Record" -- warm editorial dossier,
|
||||||
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
|
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
|
||||||
@@ -34,44 +19,46 @@ declare(strict_types=1);
|
|||||||
*
|
*
|
||||||
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
|
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
|
||||||
* @see templates/admin/dossier-page.php -- HTML template.
|
* @see templates/admin/dossier-page.php -- HTML template.
|
||||||
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
* @see ARCHITECTURE.md -- §Dossier (M2).
|
||||||
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Dossier_Page {
|
class PRAutoBlogger_Dossier_Page {
|
||||||
|
|
||||||
/** Admin page slug. */
|
/** Admin page slug. */
|
||||||
public const PAGE_SLUG = 'prautoblogger-dossier';
|
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. */
|
/** Nonce action for any future stateful actions. */
|
||||||
public const NONCE_ACTION = 'prautoblogger_dossier';
|
public const NONCE_ACTION = 'prautoblogger_dossier';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the dossier as a hidden admin page.
|
* Register the dossier as a hidden submenu page.
|
||||||
*
|
*
|
||||||
* Uses the canonical options.php-parent pattern so the hookname is stable at
|
* Hidden = not visible in the menu, but reachable by URL.
|
||||||
* both registration time and request time. No $submenu manipulation required
|
* Priority 12 ensures add_menu_page() (priority 10) has populated
|
||||||
* or permitted (see CONVENTIONS.md §Hidden Admin Pages).
|
* $admin_page_hooks before this add_submenu_page() call. See ARCHITECTURE.md §Board.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function on_register_menu(): void {
|
public function on_register_menu(): void {
|
||||||
add_submenu_page(
|
add_submenu_page(
|
||||||
self::PARENT_SLUG,
|
'prautoblogger-settings',
|
||||||
__( 'Article Dossier', 'prautoblogger' ),
|
__( 'Article Dossier', 'prautoblogger' ),
|
||||||
__( 'Dossier', 'prautoblogger' ),
|
__( 'Dossier', 'prautoblogger' ),
|
||||||
'manage_options',
|
'manage_options',
|
||||||
self::PAGE_SLUG,
|
self::PAGE_SLUG,
|
||||||
array( $this, 'render_page' )
|
array( $this, 'render_page' )
|
||||||
);
|
);
|
||||||
// No $submenu mutation. options.php-parent pages are hidden by construction.
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,14 +80,11 @@ class PRAutoBlogger_Dossier_Page {
|
|||||||
/**
|
/**
|
||||||
* Enqueue dossier-specific CSS + JS.
|
* 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.
|
* @param string $hook_suffix Current admin page hook suffix.
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
// options.php-parent pages resolve to 'admin_page_{slug}' (not 'prautoblogger_page_*').
|
$dossier_hook = 'prautoblogger_page_' . self::PAGE_SLUG;
|
||||||
$dossier_hook = 'admin_page_' . self::PAGE_SLUG;
|
|
||||||
if ( $hook_suffix !== $dossier_hook ) {
|
if ( $hook_suffix !== $dossier_hook ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -124,9 +108,6 @@ class PRAutoBlogger_Dossier_Page {
|
|||||||
/**
|
/**
|
||||||
* Build the URL to the dossier for a given post.
|
* 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.
|
* @param int $post_id WordPress post ID.
|
||||||
* @return string Admin URL to the dossier page.
|
* @return string Admin URL to the dossier page.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ class PRAutoBlogger_Image_Composer_Layout {
|
|||||||
* plugin; sizes are px. Values were tuned against rendered samples (see
|
* plugin; sizes are px. Values were tuned against rendered samples (see
|
||||||
* PR #thread 2026-06-image-composer assets).
|
* 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.
|
* @return array<string, array<string, mixed>> Keyed by role: featured, og, square.
|
||||||
*/
|
*/
|
||||||
public static function defaults(): array {
|
public static function defaults(): array {
|
||||||
@@ -64,6 +60,7 @@ class PRAutoBlogger_Image_Composer_Layout {
|
|||||||
'caption_size' => 32,
|
'caption_size' => 32,
|
||||||
'caption_color' => '#FFFFFF',
|
'caption_color' => '#FFFFFF',
|
||||||
'caption_gap' => 24,
|
'caption_gap' => 24,
|
||||||
|
'caption_margin_right' => 40,
|
||||||
'caption_max_lines' => 2,
|
'caption_max_lines' => 2,
|
||||||
'caption_chars_per_line' => 52,
|
'caption_chars_per_line' => 52,
|
||||||
'caption_line_height' => 1.3,
|
'caption_line_height' => 1.3,
|
||||||
@@ -82,6 +79,7 @@ class PRAutoBlogger_Image_Composer_Layout {
|
|||||||
'caption_color' => self::COLOR_INK,
|
'caption_color' => self::COLOR_INK,
|
||||||
'caption_max_lines' => 3,
|
'caption_max_lines' => 3,
|
||||||
'caption_chars_per_line' => 36,
|
'caption_chars_per_line' => 36,
|
||||||
|
'caption_side_padding' => 64,
|
||||||
'caption_line_height' => 1.3,
|
'caption_line_height' => 1.3,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -91,9 +91,7 @@ class PRAutoBlogger_Post_Assembler {
|
|||||||
* are generated. Its cost is logged with the run_id but no post_id. After all
|
* 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
|
* articles finish, this method divides the research cost evenly and inserts
|
||||||
* one 'llm_research' row per article so the cost breakdown popover includes
|
* one 'llm_research' row per article so the cost breakdown popover includes
|
||||||
* the amortized research overhead. The audit fields prompt_version and
|
* the amortized research overhead.
|
||||||
* 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.
|
* Side effects: database SELECT, INSERT, DELETE on generation_log.
|
||||||
*
|
*
|
||||||
@@ -111,7 +109,7 @@ class PRAutoBlogger_Post_Assembler {
|
|||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
$research_row = $wpdb->get_row(
|
$research_row = $wpdb->get_row(
|
||||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens, prompt_version, agent_role
|
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
|
||||||
FROM {$table}
|
FROM {$table}
|
||||||
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
|
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
@@ -160,8 +158,6 @@ class PRAutoBlogger_Post_Assembler {
|
|||||||
'estimated_cost' => $amortized_cost,
|
'estimated_cost' => $amortized_cost,
|
||||||
'response_status' => 'success',
|
'response_status' => 'success',
|
||||||
'error_message' => '',
|
'error_message' => '',
|
||||||
'prompt_version' => $research_row->prompt_version,
|
|
||||||
'agent_role' => $research_row->agent_role ?? '',
|
|
||||||
'created_at' => current_time( 'mysql' ),
|
'created_at' => current_time( 'mysql' ),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -138,8 +138,7 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
|
|||||||
* @param int $offset Pagination offset (0-based).
|
* @param int $offset Pagination offset (0-based).
|
||||||
* @param int $limit Page size.
|
* @param int $limit Page size.
|
||||||
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
|
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
|
||||||
* @throws \RuntimeException On HTTP error or malformed response (non-200 exceptions
|
* @throws \RuntimeException On HTTP error or malformed response.
|
||||||
* include up to 200 chars of the response body for diagnosis).
|
|
||||||
*/
|
*/
|
||||||
private function fetch_page( string $api_key, int $offset, int $limit ): array {
|
private function fetch_page( string $api_key, int $offset, int $limit ): array {
|
||||||
$payload = array(
|
$payload = array(
|
||||||
@@ -172,10 +171,8 @@ class PRAutoBlogger_Runware_Catalog_Fetcher {
|
|||||||
|
|
||||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||||
if ( 200 !== $status ) {
|
if ( 200 !== $status ) {
|
||||||
$raw_body = (string) wp_remote_retrieve_body( $response );
|
|
||||||
$body_excerpt = '' !== $raw_body ? ' — body: ' . substr( $raw_body, 0, 200 ) : '';
|
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
sprintf( 'Runware modelSearch returned HTTP %d%s', $status, $body_excerpt )
|
sprintf( 'Runware modelSearch returned HTTP %d', $status )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* Plugin Name: PRAutoBlogger
|
* Plugin Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/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.
|
* 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.4
|
* Version: 0.19.2
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| Defined here so every file in the plugin can reference paths, versions,
|
| Defined here so every file in the plugin can reference paths, versions,
|
||||||
| and limits without magic strings.
|
| and limits without magic strings.
|
||||||
*/
|
*/
|
||||||
define( 'PRAUTOBLOGGER_VERSION', '0.19.4' );
|
define( 'PRAUTOBLOGGER_VERSION', '0.19.2' );
|
||||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
|
|||||||
@@ -205,16 +205,3 @@ 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__ ) . '/' );
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
<?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}.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +1,76 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Tests for Dossier admin page menu registration — structural invariants.
|
* Tests for Article Dossier admin page menu registration.
|
||||||
*
|
*
|
||||||
* Covers: parent slug (options.php), $submenu non-mutation, page slug constant,
|
* Regression guard for the menu-ordering rule (v0.19.1 lesson):
|
||||||
* asset enqueue gate prefix, deep-link URL shape, and hook priority ordering.
|
* 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.
|
||||||
*
|
*
|
||||||
* Request-time hookname faithfulness tests live in DossierHooknameMechanismTest.
|
* The dossier page uses a hidden-submenu pattern (removed from $submenu after
|
||||||
*
|
* registration) at priority 12 -- after board (11) and parent (10).
|
||||||
* @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
|
* @package PRAutoBlogger\Tests\Admin
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace PRAutoBlogger\Tests\Admin;
|
namespace PRAutoBlogger\Tests\Admin;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
use Brain\Monkey;
|
use Brain\Monkey;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
class DossierMenuRegistrationTest extends DossierMenuRegistrationTestCase {
|
class DossierMenuRegistrationTest extends BaseTestCase {
|
||||||
|
|
||||||
// =========================================================================
|
/** @var array<int, string> Call sequence spy. */
|
||||||
// § STRUCTURAL INVARIANTS
|
private array $call_sequence = [];
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
protected function setUp(): void {
|
||||||
* Parent slug must be PARENT_SLUG constant ('options.php').
|
parent::setUp();
|
||||||
*
|
$this->call_sequence = [];
|
||||||
* 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();
|
|
||||||
|
|
||||||
$this->assertSame(
|
Functions\when( 'add_menu_page' )->alias(
|
||||||
'options.php',
|
function () {
|
||||||
$this->captured_parent_slug,
|
$this->call_sequence[] = 'add_menu_page';
|
||||||
'Dossier must use options.php as parent (canonical hidden-page pattern). '
|
return 'prautoblogger-settings';
|
||||||
. '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( '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(
|
Functions\when( 'current_user_can' )->justReturn( true );
|
||||||
\PRAutoBlogger_Dossier_Page::PARENT_SLUG,
|
Functions\when( 'get_option' )->justReturn( false );
|
||||||
$this->captured_parent_slug,
|
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
|
||||||
'Captured parent slug must match PARENT_SLUG constant.'
|
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No $submenu mutation after registration.
|
|
||||||
*
|
|
||||||
* 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_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 );
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
|
||||||
$this->assertFalse(
|
$GLOBALS['submenu'] = array();
|
||||||
$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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
protected function tearDown(): void {
|
||||||
// § DEEP-LINK URL INVARIANCE
|
$this->call_sequence = [];
|
||||||
// =========================================================================
|
unset( $GLOBALS['submenu'] );
|
||||||
|
parent::tearDown();
|
||||||
/**
|
|
||||||
* 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.
|
* 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.
|
||||||
*
|
*
|
||||||
* Confirms the exact URL format that board cards and the metabox link depend on.
|
* 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_url_for_post_exact_shape(): void {
|
public function test_dossier_hook_registered_after_board(): 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();
|
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||||
$board_page = new \PRAutoBlogger_Board_Page();
|
$board_page = new \PRAutoBlogger_Board_Page();
|
||||||
$dossier_page = new \PRAutoBlogger_Dossier_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( $admin_page, 'on_register_menu' ), 10 );
|
||||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||||
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||||
@@ -221,15 +94,56 @@ class DossierMenuRegistrationTest extends DossierMenuRegistrationTestCase {
|
|||||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; }
|
if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; }
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->assertGreaterThan(
|
$this->assertGreaterThan( $parent_priority, $board_priority,
|
||||||
$parent_priority,
|
'Board priority must exceed parent priority.' );
|
||||||
$board_priority,
|
$this->assertGreaterThan( $board_priority, $dossier_priority,
|
||||||
'Board priority must exceed parent priority.'
|
'Dossier priority must exceed board priority to ensure hookname resolves correctly.' );
|
||||||
);
|
}
|
||||||
$this->assertGreaterThan(
|
|
||||||
$board_priority,
|
/**
|
||||||
$dossier_priority,
|
* Dossier submenu slug must be 'prautoblogger-dossier'.
|
||||||
'Dossier priority must exceed board priority.'
|
*/
|
||||||
);
|
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 );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
<?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 );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -322,43 +322,4 @@ class ImageComposerTest extends BaseTestCase {
|
|||||||
$this->assertStringStartsWith( 'prautoblogger_', $key );
|
$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'] );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,8 +110,6 @@ class ResearchReaperTest extends BaseTestCase {
|
|||||||
'model' => $o->model ?? 'gemini-flash',
|
'model' => $o->model ?? 'gemini-flash',
|
||||||
'prompt_tokens' => $o->prompt_tokens ?? 1000,
|
'prompt_tokens' => $o->prompt_tokens ?? 1000,
|
||||||
'completion_tokens' => $o->completion_tokens ?? 500,
|
'completion_tokens' => $o->completion_tokens ?? 500,
|
||||||
'prompt_version' => $o->prompt_version ?? null,
|
|
||||||
'agent_role' => $o->agent_role ?? '',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user