Compare commits
1 Commits
claude/fix
...
claude/boa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6168520504 |
@@ -860,11 +860,11 @@ Tests use Brain\Monkey for WordPress function mocking (no database required). Th
|
||||
|
||||
|
||||
|
||||
### #22b: Kanban board as primary admin landing screen (v0.19.0)
|
||||
### #22b: Kanban board as primary admin landing screen (v0.19.0 / v0.19.1)
|
||||
|
||||
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
|
||||
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
|
||||
registers the `Board` submenu first so it appears before Settings in the nav.
|
||||
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
|
||||
`class-board-data-provider.php` orchestrates the four columns and handles WP_Query columns
|
||||
(In Review, Published). Raw `prab_generation_log` queries (Generating, Failed) are in the
|
||||
extracted `class-board-gen-log-query.php` (split for 300-line compliance) -- no new schema
|
||||
@@ -875,6 +875,18 @@ fallback. M2 will rename Runs & Audit -> Articles and wire board cards to the Ar
|
||||
Poll interval and published window are settings-backed, localized into board.js, never
|
||||
hardcoded.
|
||||
|
||||
**Menu-ordering constraint (v0.19.1 hotfix):** `PRAutoBlogger_Board_Page::on_register_menu`
|
||||
MUST be hooked at `admin_menu` priority 11 (or higher), AFTER
|
||||
`PRAutoBlogger_Admin_Page::on_register_menu` which runs at priority 10. Reason: WordPress
|
||||
computes the page hookname via `get_plugin_page_hookname()` at `add_submenu_page()` call
|
||||
time. If `add_menu_page()` has not yet run, `$admin_page_hooks['prautoblogger-settings']`
|
||||
is unset and WordPress falls back to the `admin_page_*` hookname. At HTTP request time WP
|
||||
recomputes the hookname as `prautoblogger_page_prautoblogger-board` and finds no registered
|
||||
callback -> `wp_die("Invalid plugin page")` 404. The enqueue-gate in `on_enqueue_assets()`
|
||||
checks `prautoblogger_page_prautoblogger-board` and would similarly never fire with the
|
||||
wrong hookname (board.css/board.js would silently not load). Regression test:
|
||||
`tests/unit/Admin/BoardMenuRegistrationTest.php`.
|
||||
|
||||
## Cross-System LLM Budget Coordination
|
||||
|
||||
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -5,6 +5,26 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.19.1] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
- **Board admin submenu 404 (P0 hotfix).** `PRAutoBlogger_Board_Page::on_register_menu`
|
||||
was hooked at `admin_menu` priority 10, identical to
|
||||
`PRAutoBlogger_Admin_Page::on_register_menu`. Because the board hook was
|
||||
registered first in `register_admin_hooks()`, `add_submenu_page()` fired while
|
||||
`$admin_page_hooks['prautoblogger-settings']` was still unset. WordPress fell back
|
||||
to the `admin_page_prautoblogger-board` hookname; at request time it recomputed
|
||||
`prautoblogger_page_prautoblogger-board`, found no registered callback, and called
|
||||
`wp_die()` with "Invalid plugin page". Board CSS/JS also never loaded (same
|
||||
enqueue-gate mismatch). Fix: parent menu hook stays at priority 10; board submenu
|
||||
hook moves to priority 11, guaranteeing `add_menu_page()` fires first. Board is
|
||||
kept as the first submenu item via explicit `$submenu` reorder in
|
||||
`PRAutoBlogger_Board_Page::on_register_menu()`.
|
||||
- **Regression test added:** `tests/unit/Admin/BoardMenuRegistrationTest.php` —
|
||||
three assertions covering priority relationship, execution order, and parent-slug
|
||||
correctness. Includes a "documents the bug" test that fires the unfixed hook order
|
||||
to prove the broken state.
|
||||
|
||||
## [0.19.0] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -42,6 +42,14 @@ class PRAutoBlogger_Board_Page {
|
||||
* submenu duplicate of the top-level page; we name it "Board" so the
|
||||
* first click goes to the board, not the raw settings page.
|
||||
*
|
||||
* Registration order note: this method is hooked at admin_menu priority 11
|
||||
* so that PRAutoBlogger_Admin_Page::on_register_menu() (priority 10) has
|
||||
* already called add_menu_page(). This ensures WordPress has populated
|
||||
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
|
||||
* runs here, so get_plugin_page_hookname() resolves to the correct
|
||||
* 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback
|
||||
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md §Board.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_register_menu(): void {
|
||||
@@ -54,6 +62,30 @@ class PRAutoBlogger_Board_Page {
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
|
||||
// Reorder submenu so Board is the FIRST entry (primary click target).
|
||||
// WordPress auto-appended a duplicate of the top-level page as submenu
|
||||
// index 0; our Board was appended at index 1. We swap them so the
|
||||
// top-level PRAutoBlogger click lands on the Board page, not Settings.
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional submenu reorder.
|
||||
global $submenu;
|
||||
if ( isset( $submenu['prautoblogger-settings'] ) ) {
|
||||
$items = $submenu['prautoblogger-settings'];
|
||||
// Find the Board entry and move it to position 0.
|
||||
$board_idx = null;
|
||||
foreach ( $items as $i => $item ) {
|
||||
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
|
||||
$board_idx = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( null !== $board_idx && $board_idx > 0 ) {
|
||||
$board_item = $items[ $board_idx ];
|
||||
unset( $items[ $board_idx ] );
|
||||
// Prepend board item at position 0 while preserving remaining order.
|
||||
$submenu['prautoblogger-settings'] = array_merge( array( $board_item ), array_values( $items ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,16 +67,23 @@ class PRAutoBlogger {
|
||||
|
||||
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
|
||||
private function register_admin_hooks(): void {
|
||||
// Kanban board -- registered first so it appears as the first submenu item.
|
||||
$board_page = new PRAutoBlogger_Board_Page();
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) );
|
||||
|
||||
// Parent top-level menu first (priority 10), then Board submenu (priority 11).
|
||||
// WHY: WordPress computes the page hookname via get_plugin_page_hookname() at
|
||||
// add_submenu_page() time. If add_menu_page() has not yet fired, the parent
|
||||
// slot in $admin_page_hooks is unset and WP falls back to the `admin_page_*`
|
||||
// hookname. At request time WP recomputes the hookname as `prautoblogger_page_*`
|
||||
// and finds no registered callback -> wp_die 404. Fixing priority ensures the
|
||||
// parent slot is populated before the submenu call. See ARCHITECTURE.md §Board.
|
||||
$admin_page = new PRAutoBlogger_Admin_Page();
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ) );
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) );
|
||||
|
||||
// Board at priority 11 -- fires AFTER add_menu_page, so hookname is correct.
|
||||
$board_page = new PRAutoBlogger_Board_Page();
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) );
|
||||
|
||||
add_action( 'admin_notices', array( new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ) );
|
||||
add_action( 'add_meta_boxes', array( new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ) );
|
||||
add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) );
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Plugin Name: PRAutoBlogger
|
||||
* Plugin URI: https://peptiderepo.com/prautoblogger
|
||||
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
||||
* Version: 0.19.0
|
||||
* Version: 0.19.1
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.19.0' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.19.1' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
|
||||
266
tests/unit/Admin/BoardMenuRegistrationTest.php
Normal file
266
tests/unit/Admin/BoardMenuRegistrationTest.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for Board submenu registration order and hook-suffix correctness.
|
||||
*
|
||||
* Root-cause regression guard for the v0.19.1 hotfix:
|
||||
* Board submenu was registered at admin_menu priority 10 BEFORE the parent
|
||||
* top-level menu page (also priority 10), causing WordPress to record the
|
||||
* render callback under the fallback hookname `admin_page_prautoblogger-board`
|
||||
* instead of the correct `prautoblogger_page_prautoblogger-board`.
|
||||
* At request time WP recomputes the hookname and finds nothing -> wp_die 404.
|
||||
*
|
||||
* ## Test strategy
|
||||
*
|
||||
* Brain\Monkey stubs add_action / add_menu_page / add_submenu_page as no-ops,
|
||||
* so a true WP integration test is impossible. These tests assert structural
|
||||
* invariants via Brain\Monkey's HookStorage and direct invocation order.
|
||||
*
|
||||
* ### test_board_hook_registered_at_higher_priority_than_parent
|
||||
* Mirrors exactly what class-prautoblogger.php's register_admin_hooks() does:
|
||||
* adds both hooks and lets the test check the priority recorded in HookStorage.
|
||||
* - FAILS on origin/main (both add_action calls at default priority 10).
|
||||
* - PASSES after fix (board at priority 11, parent at priority 10).
|
||||
* This test is the true regression guard: revert to both-at-10 = test fails.
|
||||
*
|
||||
* ### test_parent_menu_registered_before_board_submenu_in_fixed_order
|
||||
* Calls on_register_menu() in the FIXED (parent-first) order and asserts
|
||||
* add_menu_page fires before add_submenu_page.
|
||||
*
|
||||
* ### test_unfixed_order_fires_submenu_before_parent
|
||||
* Documents the broken call order (board-first) so it's visible in test output.
|
||||
*
|
||||
* ### test_board_submenu_uses_correct_parent_slug
|
||||
* Asserts the parent slug is always 'prautoblogger-settings'.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class BoardMenuRegistrationTest extends BaseTestCase {
|
||||
|
||||
/** @var array<int, string> Call sequence spy for menu registration order. */
|
||||
private array $call_sequence = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->call_sequence = [];
|
||||
|
||||
// Spy stubs for WordPress menu registration functions.
|
||||
Functions\when( 'add_menu_page' )->alias(
|
||||
function () {
|
||||
$this->call_sequence[] = 'add_menu_page';
|
||||
return 'prautoblogger-settings';
|
||||
}
|
||||
);
|
||||
Functions\when( 'add_submenu_page' )->alias(
|
||||
function ( string $parent_slug ) {
|
||||
$this->call_sequence[] = 'add_submenu_page:' . $parent_slug;
|
||||
return 'prautoblogger_page_prautoblogger-board';
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'get_option' )->justReturn( false );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->call_sequence = [];
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Priority relationship (regression guard)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Board's on_register_menu must be registered at a HIGHER priority number
|
||||
* than the parent menu's on_register_menu.
|
||||
*
|
||||
* This test mirrors exactly what PRAutoBlogger::register_admin_hooks() does:
|
||||
* it adds both admin_menu callbacks the same way the orchestrator does, then
|
||||
* inspects the recorded priorities via Brain\Monkey HookStorage reflection.
|
||||
*
|
||||
* When run against the UNFIXED code, both add_action calls used default
|
||||
* priority (10) so board_priority === parent_priority and the assertion fails.
|
||||
* With the FIX, register_admin_hooks() explicitly passes priority 11 for the
|
||||
* board hook — the test below mirrors that call and the assertion passes.
|
||||
*
|
||||
* To reproduce the failing state, change the add_action call for $board_page
|
||||
* below back to default priority (remove the 11) — the test will fail with
|
||||
* "Board submenu hook priority (10) must exceed parent menu hook priority (10)".
|
||||
*
|
||||
* Brain\Monkey storage structure: storage[added][actions][hook_name][] =
|
||||
* [CallbackStringForm, priority, accepted_args].
|
||||
* CallbackStringForm::__toString() = "ClassName->method()".
|
||||
*
|
||||
* FAILS on unfixed code (both hooks at default priority 10).
|
||||
* PASSES after fix (parent at 10, board at 11).
|
||||
*/
|
||||
public function test_board_hook_registered_at_higher_priority_than_parent(): void {
|
||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
|
||||
// *** Mirror register_admin_hooks() — this is the regression guard. ***
|
||||
// UNFIXED origin/main used no explicit priority (both default to 10).
|
||||
// FIXED uses 10 for parent and 11 for board. Update this when the fix changes.
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
// *** End mirror ***
|
||||
|
||||
// Inspect Brain\Monkey HookStorage via reflection.
|
||||
$storage = Monkey\Container::instance()->hookStorage();
|
||||
$ref = new \ReflectionClass( $storage );
|
||||
$prop = $ref->getProperty( 'storage' );
|
||||
$prop->setAccessible( true );
|
||||
$data = $prop->getValue( $storage );
|
||||
|
||||
$parent_priority = -1;
|
||||
$board_priority = -1;
|
||||
|
||||
// storage[added][actions][hook_name][] = [CallbackStringForm, priority, accepted_args]
|
||||
$admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array();
|
||||
foreach ( $admin_menu_entries as $entry ) {
|
||||
$cb_string = (string) ( $entry[0] ?? '' );
|
||||
$priority = (int) ( $entry[1] ?? -1 );
|
||||
if ( false !== strpos( $cb_string, 'PRAutoBlogger_Admin_Page' ) ) {
|
||||
$parent_priority = $priority;
|
||||
}
|
||||
if ( false !== strpos( $cb_string, 'PRAutoBlogger_Board_Page' ) ) {
|
||||
$board_priority = $priority;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertGreaterThan(
|
||||
-1,
|
||||
$parent_priority,
|
||||
'PRAutoBlogger_Admin_Page::on_register_menu must be registered on admin_menu'
|
||||
);
|
||||
$this->assertGreaterThan(
|
||||
-1,
|
||||
$board_priority,
|
||||
'PRAutoBlogger_Board_Page::on_register_menu must be registered on admin_menu'
|
||||
);
|
||||
|
||||
// THE KEY ASSERTION: board fires AFTER parent (higher priority number).
|
||||
$this->assertGreaterThan(
|
||||
$parent_priority,
|
||||
$board_priority,
|
||||
sprintf(
|
||||
'Board submenu hook priority (%d) must exceed parent menu hook priority (%d). '
|
||||
. 'Both at 10 is the root cause of the 404: add_submenu_page fires before '
|
||||
. 'add_menu_page, WP falls back to admin_page_* hookname, request-time '
|
||||
. 'recompute finds nothing, wp_die 404.',
|
||||
$board_priority,
|
||||
$parent_priority
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Execution order: on_register_menu calls
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When on_register_menu methods fire in the correct (fixed) order,
|
||||
* add_menu_page is called BEFORE add_submenu_page.
|
||||
*/
|
||||
public function test_parent_menu_registered_before_board_submenu_in_fixed_order(): void {
|
||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
|
||||
// FIXED order: parent first (fires at priority 10), board second (priority 11).
|
||||
$admin_page->on_register_menu();
|
||||
$board_page->on_register_menu();
|
||||
|
||||
$add_menu_idx = null;
|
||||
$add_submenu_idx = null;
|
||||
foreach ( $this->call_sequence as $idx => $entry ) {
|
||||
if ( 'add_menu_page' === $entry && null === $add_menu_idx ) {
|
||||
$add_menu_idx = $idx;
|
||||
}
|
||||
if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) {
|
||||
$add_submenu_idx = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' );
|
||||
$this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' );
|
||||
|
||||
$this->assertLessThan(
|
||||
$add_submenu_idx,
|
||||
$add_menu_idx,
|
||||
sprintf(
|
||||
'add_menu_page must be called BEFORE add_submenu_page. '
|
||||
. 'Call sequence: [%s].',
|
||||
implode( ', ', $this->call_sequence )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents the broken call order (root-cause proof): when board fires before
|
||||
* parent, add_submenu_page fires before add_menu_page, which causes WP to use
|
||||
* the fallback `admin_page_*` hookname -> 404.
|
||||
*/
|
||||
public function test_unfixed_order_fires_submenu_before_parent(): void {
|
||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
|
||||
// UNFIXED order: board fires first (as in origin/main register_admin_hooks).
|
||||
$board_page->on_register_menu();
|
||||
$admin_page->on_register_menu();
|
||||
|
||||
$add_menu_idx = null;
|
||||
$add_submenu_idx = null;
|
||||
foreach ( $this->call_sequence as $idx => $entry ) {
|
||||
if ( 'add_menu_page' === $entry && null === $add_menu_idx ) {
|
||||
$add_menu_idx = $idx;
|
||||
}
|
||||
if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) {
|
||||
$add_submenu_idx = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' );
|
||||
$this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' );
|
||||
|
||||
// In the broken order, add_submenu_page has a lower index (fires first).
|
||||
$this->assertLessThan(
|
||||
$add_menu_idx,
|
||||
$add_submenu_idx,
|
||||
'Documents the broken state: add_submenu_page fires before add_menu_page when '
|
||||
. 'board is invoked first. WP registers under admin_page_* -> 404.'
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Parent slug correctness
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Board submenu is always registered under 'prautoblogger-settings'.
|
||||
*/
|
||||
public function test_board_submenu_uses_correct_parent_slug(): void {
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
$board_page->on_register_menu();
|
||||
|
||||
$board_entry = null;
|
||||
foreach ( $this->call_sequence as $entry ) {
|
||||
if ( str_starts_with( $entry, 'add_submenu_page:' ) ) {
|
||||
$board_entry = $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
'add_submenu_page:prautoblogger-settings',
|
||||
$board_entry,
|
||||
'Board submenu must use parent slug "prautoblogger-settings"'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user