chore(repo): track full tests/ and tools/ suites, drop per-file whitelist
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Admin contract map
|
||||
|
||||
Generated by process, not a migration guide. Source of truth for menu ↔ schema wiring.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Every `schemaPage` must have matching PHP section under the same domain.
|
||||
2. Enum field values must be identical in PHP schema and JS schema.
|
||||
3. REST `'callback' => [$this, 'method']` methods must exist (class or trait).
|
||||
4. Deploy `assets/admin/build/` as one atomic unit after `npm run build`.
|
||||
5. No compat aliases, dual-read, or silent frontend catch for missing APIs.
|
||||
|
||||
## Audit
|
||||
|
||||
```bash
|
||||
php tools/audit-admin-contracts.php
|
||||
```
|
||||
|
||||
Exit code `1` means hard contract errors.
|
||||
|
||||
## Domain builders
|
||||
|
||||
| Domain | PHP builder map key | Menu constants file |
|
||||
|--------|---------------------|---------------------|
|
||||
| global | `AKSRT_Admin_Schema_Global` | `assets/admin/src/constants/sections/global.js` |
|
||||
| page_display | `AKSRT_Admin_Schema_Page_Display` | `page-display.js` |
|
||||
| post_list | `AKSRT_Admin_Schema_Post_List` | `post-list.js` |
|
||||
| permissions | `AKSRT_Admin_Schema_Permissions` | `permissions.js` |
|
||||
| users | `AKSRT_Admin_Schema_Users` | `users.js` |
|
||||
| payment | `AKSRT_Admin_Schema_Payment` | `payment.js` |
|
||||
| extensions | `AKSRT_Admin_Schema_Extensions` | `extensions.js` |
|
||||
| tools | `AKSRT_Admin_Schema_Tools` | `tools.js` |
|
||||
| shop | `AKSRT_Admin_Schema_Shop` | `shop.js` |
|
||||
|
||||
## Known custom pages (not schema REST)
|
||||
|
||||
These use dedicated Vue pages + dedicated REST modules:
|
||||
|
||||
- Homepage: `HomepageSettings` + `/settings/homepage`
|
||||
- Footer: `FooterSettings` + footer settings API
|
||||
- Permissions capabilities matrix: `PermissionsCapabilities` + `/permissions/capabilities`
|
||||
- Shop products/orders and other operational tables
|
||||
|
||||
## Deploy checklist
|
||||
|
||||
1. `cd assets/admin && npm ci && npm run build`
|
||||
2. Confirm `build/index.js` and every dynamically imported `./Chunk.hash.js` exist
|
||||
3. Replace the entire remote `assets/admin/build/` directory
|
||||
4. Hard-refresh wp-admin (or purge CDN)
|
||||
5. Re-run `php tools/audit-admin-contracts.php`
|
||||
@@ -0,0 +1,271 @@
|
||||
{
|
||||
"generated_at": "2026-09-06T05:00:37+00:00",
|
||||
"errors": [],
|
||||
"warnings": [
|
||||
"customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",
|
||||
"customPage users-wechat-template section 'users/wechat-template' not in PHP schema (ok if pure operational page)",
|
||||
"enum incomplete parse for 'alipay_payment_type' (php subset of js): php=[page] js=[page,wap]",
|
||||
"enum incomplete parse for 'avatar_external' (php subset of js): php=[] js=[,gravatar,weavatar,cravatar,geekzu,loli,sep,v2ex]",
|
||||
"enum incomplete parse for 'backup_frequency' (php subset of js): php=[daily] js=[daily,weekly,monthly]",
|
||||
"enum incomplete parse for 'bind_reminder' (php subset of js): php=[email] js=[email,phone,email_phone,close]",
|
||||
"enum incomplete parse for 'comment_city_type' (php subset of js): php=[province_city] js=[province_city,province,country_province]",
|
||||
"enum incomplete parse for 'comment_order' (php subset of js): php=[asc] js=[asc,desc]",
|
||||
"enum incomplete parse for 'comment_pagination_type' (php subset of js): php=[numbered] js=[numbered,load_more,infinite]",
|
||||
"enum incomplete parse for 'content_audit_on_failure' (php subset of js): php=[allow] js=[allow,block]",
|
||||
"enum incomplete parse for 'content_audit_provider' (php subset of js): php=[baidu] js=[baidu,null]",
|
||||
"enum incomplete parse for 'epay_device' (php subset of js): php=[auto] js=[auto,pc,mobile]",
|
||||
"enum incomplete parse for 'font_family' (php subset of js): php=[system] js=[system,noto-sans-sc,noto-serif-sc,custom]",
|
||||
"enum incomplete parse for 'front_submit_editor_mode' (php subset of js): php=[rich] js=[rich,markdown,plain]",
|
||||
"enum incomplete parse for 'front_submit_review_mode' (php subset of js): php=[pending] js=[pending,publish,draft]",
|
||||
"enum incomplete parse for 'i18n_default_lang' (php subset of js): php=[zh-CN] js=[zh-CN,en,zh-TW,ja,ko,fr,de,es,ru]",
|
||||
"enum incomplete parse for 'i18n_switcher_position' (php subset of js): php=[header] js=[header,footer,both,none]",
|
||||
"enum incomplete parse for 'i18n_url_strategy' (php subset of js): php=[query] js=[query,path,subdomain]",
|
||||
"enum incomplete parse for 'img_position' (php subset of js): php=[left] js=[left,right]",
|
||||
"enum incomplete parse for 'invit_code_s' (php subset of js): php=[close] js=[close,open,must]",
|
||||
"enum incomplete parse for 'ip_addr_sdk' (php subset of js): php=[polling] js=[polling,qq,amap,null]",
|
||||
"enum incomplete parse for 'ip_geo_format' (php subset of js): php=[province_city] js=[province_city,province,country_province]",
|
||||
"enum incomplete parse for 'lazy_animation' (php subset of js): php=[fade] js=[blur,fade,scale]",
|
||||
"enum incomplete parse for 'list_meta_show' (php subset of js): php=[view] js=[view,like,comm]",
|
||||
"enum incomplete parse for 'list_orderby' (php subset of js): php=[date] js=[date,modified,default]",
|
||||
"enum incomplete parse for 'list_show_type' (php subset of js): php=[separate] js=[separate,no_margin]",
|
||||
"enum incomplete parse for 'list_thumb_fit' (php subset of js): php=[] js=[,contain]",
|
||||
"enum incomplete parse for 'list_type' (php subset of js): php=[text] js=[text,thumb,thumb_if_has,card]",
|
||||
"enum incomplete parse for 'mandatory_bind' (php subset of js): php=[email] js=[email,phone,email_phone,close]",
|
||||
"enum incomplete parse for 'message_paginate_type' (php subset of js): php=[ajax_lists] js=[ajax_lists,default]",
|
||||
"enum incomplete parse for 'oauth_bind_type' (php subset of js): php=[auto] js=[auto,manual]",
|
||||
"enum incomplete parse for 'oauth_proxy_mode' (php subset of js): php=[close] js=[close,server,client]",
|
||||
"enum incomplete parse for 'paging_ajax_s' (php subset of js): php=[1] js=[1,0]",
|
||||
"enum incomplete parse for 'pay_box_position' (php subset of js): php=[box_top] js=[box_top,top,bottom,box_bottom]",
|
||||
"enum incomplete parse for 'post_author_card_orderby' (php subset of js): php=[views] js=[views,date,modified,comment_count]",
|
||||
"enum incomplete parse for 'post_author_card_style' (php subset of js): php=[card] js=[card,list]",
|
||||
"enum incomplete parse for 'post_pay_box_position' (php subset of js): php=[content] js=[content,mask]",
|
||||
"enum incomplete parse for 'post_related_orderby' (php subset of js): php=[modified] js=[modified,date,views,like,comment_count,favorite,rand]",
|
||||
"enum incomplete parse for 'post_related_type' (php subset of js): php=[img] js=[img,list,text]",
|
||||
"enum incomplete parse for 'post_time_source' (php subset of js): php=[date] js=[date,modified]",
|
||||
"enum incomplete parse for 'search_type_in' (php subset of js): php=[post] js=[post,user]",
|
||||
"enum incomplete parse for 'sign_card_position' (php subset of js): php=[left] js=[left,center,right]",
|
||||
"enum incomplete parse for 'sign_theme_mode' (php subset of js): php=[no] js=[no,white-theme,dark-theme]",
|
||||
"enum incomplete parse for 'style' (php subset of js): php=[null] js=[null,style2]",
|
||||
"enum incomplete parse for 'system_notice_btn1_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
|
||||
"enum incomplete parse for 'system_notice_btn2_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
|
||||
"enum incomplete parse for 'system_notice_btn3_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
|
||||
"enum incomplete parse for 'system_notice_btn4_color' (php subset of js): php=[primary] js=[primary,success,warning,danger,info,purple,dark]",
|
||||
"enum incomplete parse for 'system_notice_policy' (php subset of js): php=[] js=[,signin,vip,free,auth]",
|
||||
"enum incomplete parse for 'system_notice_size' (php subset of js): php=[] js=[,modal-mini,modal-sm,modal-lg]",
|
||||
"enum incomplete parse for 'theme_mode' (php subset of js): php=[white-theme] js=[white-theme,dark-theme,time-auto]",
|
||||
"enum incomplete parse for 'user_email_limit_type' (php subset of js): php=[not] js=[not,allow]",
|
||||
"enum incomplete parse for 'user_sign_type' (php subset of js): php=[modal] js=[modal,page]",
|
||||
"enum incomplete parse for 'user_signin_nopas_active' (php subset of js): php=[nopas] js=[nopas,pas,only_nopas]",
|
||||
"enum incomplete parse for 'wechat_payment_type' (php subset of js): php=[native] js=[native,h5]",
|
||||
"enum incomplete parse for 'weixingzh_gzh_type' (php subset of js): php=[service] js=[service,subscribe]"
|
||||
],
|
||||
"info": [
|
||||
"PHP schema domains: global, page_display, post_list, permissions, users, payment, extensions, tools, ExtensionsAudit, ExtensionsIP, ExtensionsPush, ToolsBackup, ToolsHealth, ToolsImportExport",
|
||||
"Menu pages discovered: 61",
|
||||
"admin build ok; chunks=32"
|
||||
],
|
||||
"domains": {
|
||||
"global": {
|
||||
"sections": [
|
||||
"code-highlight",
|
||||
"common",
|
||||
"custom-code",
|
||||
"email",
|
||||
"i18n",
|
||||
"layout",
|
||||
"logo",
|
||||
"search",
|
||||
"seo",
|
||||
"system-notice"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/GlobalSchema.php"
|
||||
]
|
||||
},
|
||||
"page_display": {
|
||||
"sections": [
|
||||
"category",
|
||||
"footer",
|
||||
"header-components",
|
||||
"header-nav",
|
||||
"homepage",
|
||||
"other",
|
||||
"tag",
|
||||
"user-home"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaPageDisplay.php"
|
||||
]
|
||||
},
|
||||
"post_list": {
|
||||
"sections": [
|
||||
"comment",
|
||||
"feature",
|
||||
"filter",
|
||||
"list",
|
||||
"page",
|
||||
"pay",
|
||||
"share",
|
||||
"submit",
|
||||
"thumb"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaPostList.php",
|
||||
"src/Admin/Schema/PostList/SchemaPostListList.php"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"sections": [
|
||||
"attachment",
|
||||
"capabilities",
|
||||
"download",
|
||||
"login",
|
||||
"moderation"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaPermissions.php"
|
||||
]
|
||||
},
|
||||
"users": {
|
||||
"sections": [
|
||||
"auth",
|
||||
"badge",
|
||||
"credits",
|
||||
"features",
|
||||
"level",
|
||||
"notifications",
|
||||
"oauth",
|
||||
"report",
|
||||
"sms",
|
||||
"verify",
|
||||
"wechat"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaUsers.php"
|
||||
]
|
||||
},
|
||||
"payment": {
|
||||
"sections": [
|
||||
"affiliate",
|
||||
"card-pass",
|
||||
"gateway",
|
||||
"order",
|
||||
"recharge",
|
||||
"revenue",
|
||||
"vip",
|
||||
"withdrawal"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaPayment.php"
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"sections": [
|
||||
"audit",
|
||||
"ip",
|
||||
"push"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaExtensions.php"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"sections": [
|
||||
"backup",
|
||||
"health",
|
||||
"import-export"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaTools.php"
|
||||
]
|
||||
},
|
||||
"ExtensionsAudit": {
|
||||
"sections": [
|
||||
"audit"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaExtensionsAudit.php"
|
||||
]
|
||||
},
|
||||
"ExtensionsIP": {
|
||||
"sections": [
|
||||
"ip"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaExtensionsIP.php"
|
||||
]
|
||||
},
|
||||
"ExtensionsPush": {
|
||||
"sections": [
|
||||
"push"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaExtensionsPush.php"
|
||||
]
|
||||
},
|
||||
"ToolsBackup": {
|
||||
"sections": [
|
||||
"backup"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaToolsBackup.php"
|
||||
]
|
||||
},
|
||||
"ToolsHealth": {
|
||||
"sections": [
|
||||
"health"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaToolsHealth.php"
|
||||
]
|
||||
},
|
||||
"ToolsImportExport": {
|
||||
"sections": [
|
||||
"import-export"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaToolsImportExport.php"
|
||||
]
|
||||
}
|
||||
},
|
||||
"page_count": 61,
|
||||
"build": {
|
||||
"present": true,
|
||||
"entry": true,
|
||||
"missing_chunks": [],
|
||||
"chunks": [
|
||||
"SchemaPage.CcW0AW7T.js",
|
||||
"vendor-vue.BxZIRU0_.js",
|
||||
"shared-api.BUPhZW2Y.js",
|
||||
"FaIcon.NZ6TDkNu.js",
|
||||
"shared-api.DnsQ5cxS.js",
|
||||
"vendor-misc.bELTaXdf.js",
|
||||
"level.E3ANSDAX.js",
|
||||
"badge.CKTsmH1z.js",
|
||||
"vendor-naive.CKYCZmXD.js",
|
||||
"DeferredPage.DpQpYrho.js",
|
||||
"NativeOpsRedirect.CHwEgYbu.js",
|
||||
"FloatingButtonsSettings.JM4lmjpr.js",
|
||||
"vendor-draggable.BooRVfGb.js",
|
||||
"PageHeader.CtT29f8Y.js",
|
||||
"FooterTabbarSettings.C6AggjrQ.js",
|
||||
"PostPageSettings.uzxR41nA.js",
|
||||
"CommentSettings.sqwcq8XD.js",
|
||||
"PermissionsCapabilities.CjA3yZya.js",
|
||||
"AttachmentPermissions.Dzsg-DfQ.js",
|
||||
"CmsCredits.BYsXdX89.js",
|
||||
"ThemeInfoPage.jxAxkM2b.js",
|
||||
"ThemeAuthorizationPage.DMbNRHuq.js",
|
||||
"DocsPage.mFfiXsYT.js",
|
||||
"UsersVerify.ryf_AQd6.js",
|
||||
"UsersLevel.BpnbcbVe.js",
|
||||
"UsersBadge.DCbaumfe.js",
|
||||
"UsersWechatTemplate.Dn21sVHP.js",
|
||||
"PaymentGateway.DuX_0Di_.js",
|
||||
"ToolsImportExport.BxEsl-fY.js",
|
||||
"ToolsHealth.B1bVNkRU.js",
|
||||
"ToolsUpdate.DZedmIdt.js",
|
||||
"ExtensionAI.BmvoDo9B.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# ADR: Domain folder placement + PSR-4 namespaces for theme types
|
||||
|
||||
## Status
|
||||
Accepted (2026-07-17); **updated 2026-08-02** for Composer PSR-4 root flatten to `src/`.
|
||||
|
||||
## Context
|
||||
Domain folders were frozen after the root→domain move. Theme types then migrated from global `AKSRT_*` + manual `require_once` to real PHP namespaces under `src/` with Composer PSR-4.
|
||||
|
||||
## Decision
|
||||
|
||||
### Domain ownership (unchanged)
|
||||
|
||||
| Concern | Domain | Why |
|
||||
|---------|--------|-----|
|
||||
| Theme shell (setup, scripts, templates, SEO, meta boxes, DB migrator, front-submit, docs presenter, home modules, stats, redirects, update checker, comment actions) | `Core` | Shared CMS plumbing |
|
||||
| SPA shell + controllers + schema + native ops | `Admin` | Dual-surface IA |
|
||||
| Settings facade + runtime delegates | `Settings` | Single public settings API |
|
||||
| Auth, OAuth, captcha, moderation, email rollback | `Auth` | Identity & access |
|
||||
| User center/home/badges/level/metrics/notifications/follow | `User` | Account surface |
|
||||
| Credits, balance wallet, signin, invite, points-free | `Credits` | Points + cash wallet |
|
||||
| Download tokens/state, content pay, VIP, visibility | `Content` | Post-centric paid content |
|
||||
| Orders, gateways, coupon, charge-card, revenue, creator-revenue, withdrawal, rebate, discount, after-sale, shipping, wechat-notify | `Payment` | Commerce + settlement |
|
||||
| Shop services/meta/reviews | `Shop` | Product catalog / cart |
|
||||
| SMS stack | `Sms` | Messaging channel |
|
||||
| AI runtime (admin AI controller stays in `Admin`) | `Ai` | Generation helpers |
|
||||
| Message, private message, site notice, report center | `Messages` | In-site messaging |
|
||||
| MeiliSearch, search-push, ip-geo, content-audit, api-audit | `Extensions` | Optional integrations |
|
||||
| Widgets + registry | `Widgets` | WP widgets |
|
||||
| Inline style / custom code / loading overlay | `Frontend` | Asset render helpers |
|
||||
|
||||
### PSR-4 layout
|
||||
|
||||
- Composer: `"AKSRT\\": "src/"`
|
||||
- Namespace `AKSRT\{Domain}\…` ↔ path `src/{Domain}/…`
|
||||
- One type per file; StudlyCaps filename = short class/trait/interface name
|
||||
- Nested segments: `Admin\Schema`, `Admin\Native`, `Payment\Gateways`, `Admin\Schema\PostList`
|
||||
- OAuth stays under `Auth` (`AKSRT\Auth\OAuth`) — no separate `oauth/` tree
|
||||
- Do not move `TopicPay` / `PlateFollowPay` into `Content` without updating this ADR
|
||||
- `CreatorRevenue` and `WeChatNotify` stay under `Payment`
|
||||
|
||||
### Loader
|
||||
|
||||
1. `functions.php` loads `vendor/autoload.php` (Composer: third-party + theme PSR-4)
|
||||
2. Immediately after, canary `class_exists(\AKSRT\Core\DomainModel::class)` — if missing, `wp_die` with `composer install` / `composer dump-autoload -o` (stale `vendor/` after PSR-4 cutover is the common failure)
|
||||
3. `inc/bootstrap.php` loads helpers + procedural modules (`inc/modules/*`) + core-hooks only
|
||||
4. Singleton `init()` remains in `functions.php` using FQCNs
|
||||
|
||||
Deploy note: `vendor/` is gitignored. After pull/upload, run `composer install --no-dev --optimize-autoloader` (or at least `composer dump-autoload -o`) in the theme root so `autoload_psr4` includes `"AKSRT\\": ["…/src"]`.
|
||||
|
||||
### Procedural (not PSR-4)
|
||||
|
||||
- `inc/helpers/*` function libraries
|
||||
- `inc/modules/*` (captcha, user-hub/parts, user-notifications, widgets-register, …)
|
||||
- `inc/core-hooks.php` (+ parts)
|
||||
|
||||
The one-shot `tools/build-psr4-map.php` migrator (and `tools/psr4-map/` artifacts) were removed when `inc/classes` was deleted — they scanned the old tree and cannot run anymore.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Dual-read `class_alias` shims for old `AKSRT_*` global names (rewrite references instead)
|
||||
- Converting all helpers to classes
|
||||
@@ -0,0 +1,529 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin contract audit — menu ↔ PHP schema ↔ REST $this callbacks ↔ build integrity.
|
||||
*
|
||||
* Usage (from theme root):
|
||||
* php tools/audit-admin-contracts.php
|
||||
*
|
||||
* Exit 1 on hard errors.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$themeRoot = dirname(__DIR__);
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
$info = [];
|
||||
|
||||
function rel(string $path, string $root): string {
|
||||
return str_replace('\\', '/', ltrim(str_replace($root, '', $path), '\\/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the source of the first balanced [ ... ] array assigned to $needle,
|
||||
* brackets included; null when absent or unbalanced.
|
||||
*/
|
||||
function extract_balanced_block(string $src, string $needle): ?string {
|
||||
$pos = strpos($src, $needle);
|
||||
if ($pos === false) {
|
||||
return null;
|
||||
}
|
||||
$open = strpos($src, '[', $pos);
|
||||
if ($open === false) {
|
||||
return null;
|
||||
}
|
||||
$depth = 0;
|
||||
$len = strlen($src);
|
||||
for ($i = $open; $i < $len; $i++) {
|
||||
$ch = $src[$i];
|
||||
if ($ch === "'" || $ch === '"') {
|
||||
$quote = $ch;
|
||||
$i++;
|
||||
while ($i < $len) {
|
||||
if ($src[$i] === '\\') {
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
if ($src[$i] === $quote) {
|
||||
break;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($ch === '[') {
|
||||
$depth++;
|
||||
} elseif ($ch === ']') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
return substr($src, $open, $i - $open + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* String keys written directly inside an outer [ ... ] block (bracket depth 1),
|
||||
* regardless of whether their value is an inline array, Class::build() or
|
||||
* self::helper(). Nested 'k => v' pairs deeper down are ignored.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
function top_level_array_keys(string $block): array {
|
||||
$keys = [];
|
||||
$depth = 0;
|
||||
$len = strlen($block);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$ch = $block[$i];
|
||||
if ($ch === "'" || $ch === '"') {
|
||||
$start = $i;
|
||||
$quote = $ch;
|
||||
$i++;
|
||||
while ($i < $len) {
|
||||
if ($block[$i] === '\\') {
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
if ($block[$i] === $quote) {
|
||||
break;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
$j = $i + 1;
|
||||
while ($j < $len && ctype_space($block[$j])) {
|
||||
$j++;
|
||||
}
|
||||
if ($depth === 1 && substr($block, $j, 2) === '=>') {
|
||||
$keys[] = substr($block, $start + 1, $i - $start - 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($ch === '[') {
|
||||
$depth++;
|
||||
} elseif ($ch === ']') {
|
||||
$depth--;
|
||||
}
|
||||
}
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract section keys from composite schema builders (including delegated leaf builders).
|
||||
*
|
||||
* @return array<string, array{sections: string[], files: string[]}>
|
||||
*/
|
||||
function collect_php_schema_sections(string $themeRoot): array {
|
||||
$map = [];
|
||||
|
||||
$register = static function (string $domain, string $section, string $file) use (&$map, $themeRoot): void {
|
||||
if ($domain === '' || $section === '') {
|
||||
return;
|
||||
}
|
||||
if (!isset($map[$domain])) {
|
||||
$map[$domain] = ['sections' => [], 'files' => []];
|
||||
}
|
||||
$map[$domain]['sections'][$section] = true;
|
||||
$fileRel = rel($file, $themeRoot);
|
||||
if (!in_array($fileRel, $map[$domain]['files'], true)) {
|
||||
$map[$domain]['files'][] = $fileRel;
|
||||
}
|
||||
};
|
||||
|
||||
// Primary domain builders.
|
||||
$primary = [
|
||||
'global' => $themeRoot . '/src/Admin/Schema/GlobalSchema.php',
|
||||
'page_display' => $themeRoot . '/src/Admin/Schema/SchemaPageDisplay.php',
|
||||
'post_list' => $themeRoot . '/src/Admin/Schema/SchemaPostList.php',
|
||||
'permissions' => $themeRoot . '/src/Admin/Schema/SchemaPermissions.php',
|
||||
'users' => $themeRoot . '/src/Admin/Schema/SchemaUsers.php',
|
||||
'payment' => $themeRoot . '/src/Admin/Schema/SchemaPayment.php',
|
||||
'extensions' => $themeRoot . '/src/Admin/Schema/SchemaExtensions.php',
|
||||
'tools' => $themeRoot . '/src/Admin/Schema/SchemaTools.php',
|
||||
'shop' => $themeRoot . '/src/Admin/Schema/SchemaShop.php',
|
||||
];
|
||||
|
||||
foreach ($primary as $domain => $file) {
|
||||
if (!is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
$src = file_get_contents($file);
|
||||
if ($src === false) {
|
||||
continue;
|
||||
}
|
||||
// Only keys at the first level of the 'sections' block are sections;
|
||||
// whole-file regexes also match nested field/group arrays ('all' => [,
|
||||
// 'fields' => [, ...) and miss self::helper() delegates.
|
||||
$block = extract_balanced_block($src, "'sections'");
|
||||
if ($block === null) {
|
||||
continue;
|
||||
}
|
||||
foreach (top_level_array_keys($block) as $section) {
|
||||
if (in_array($section, ['domain', 'title', 'version', 'option_key', 'key', 'sections', 'groups', 'fields'], true)) {
|
||||
continue;
|
||||
}
|
||||
$register($domain, $section, $file);
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf builders that return a single section with 'key' => 'section-id'
|
||||
$leafFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
|
||||
foreach ($leafFiles as $file) {
|
||||
$src = file_get_contents($file);
|
||||
if ($src === false) {
|
||||
continue;
|
||||
}
|
||||
// Skip pure composites that only delegate.
|
||||
$isComposite = (bool) preg_match("/'sections'\\s*=>\\s*\\[/", $src);
|
||||
if ($isComposite) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match("/'key'\\s*=>\\s*'([a-z0-9_-]+)'/", $src, $km)) {
|
||||
continue;
|
||||
}
|
||||
$section = $km[1];
|
||||
// Infer domain from filename: class-aksrt-admin-schema-{domain}-{section}.php
|
||||
$base = basename($file, '.php');
|
||||
$base = preg_replace('/^(class-aksrt-admin-schema-|Schema)/', '', $base);
|
||||
$parts = explode('-', $base);
|
||||
// domain may be multi-token (page_display is page-display in menus but page_display in PHP).
|
||||
// Prefer matching known domains by prefix.
|
||||
$domain = null;
|
||||
foreach (array_keys($primary) as $candidate) {
|
||||
$candidateDash = str_replace('_', '-', $candidate);
|
||||
if ($base === $candidateDash || str_starts_with($base, $candidateDash . '-')) {
|
||||
$domain = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($domain === null) {
|
||||
// Fallback: first token / PSR-4 Schema* short names.
|
||||
if (str_contains($base, 'PostList')) {
|
||||
$domain = 'post_list';
|
||||
} else {
|
||||
$domain = str_replace('-', '_', $parts[0] ?? '');
|
||||
}
|
||||
}
|
||||
// Leaf under Schema/PostList/ with key list → post_list/list
|
||||
if ($domain === 'post_list' && $section === 'list') {
|
||||
$register($domain, $section, $file);
|
||||
continue;
|
||||
}
|
||||
$register($domain, $section, $file);
|
||||
}
|
||||
|
||||
foreach ($map as $domain => $payload) {
|
||||
$map[$domain]['sections'] = array_keys($payload['sections']);
|
||||
sort($map[$domain]['sections']);
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
function collect_menu_pages(string $themeRoot): array {
|
||||
$dir = $themeRoot . '/assets/admin/src/constants/sections';
|
||||
$pages = [];
|
||||
foreach (glob($dir . '/*.js') ?: [] as $file) {
|
||||
$base = basename($file);
|
||||
if (in_array($base, ['helpers.js', 'index.js'], true)) {
|
||||
continue;
|
||||
}
|
||||
$src = file_get_contents($file);
|
||||
if ($src === false) {
|
||||
continue;
|
||||
}
|
||||
foreach (['schemaPage' => 'schemaPage', 'customPage' => 'customPage', 'deferredPage' => 'deferredPage'] as $fn => $kind) {
|
||||
if (!preg_match_all('/' . $fn . '\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)/', $src, $blocks)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($blocks[1] as $block) {
|
||||
$page = ['file' => rel($file, $themeRoot), 'kind' => $kind];
|
||||
foreach (['key', 'canonicalDomain', 'section', 'route', 'component', 'name'] as $prop) {
|
||||
if (preg_match('/' . $prop . ":\\s*'([^']*)'/", $block, $m)) {
|
||||
$page[$prop === 'canonicalDomain' ? 'domain' : $prop] = $m[1];
|
||||
}
|
||||
}
|
||||
$pages[] = $page;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect enum options for fields with balanced-enough heuristics:
|
||||
* only consider field objects that include both type radio|select and an options array
|
||||
* on nearby lines, keyed by the field key property.
|
||||
*
|
||||
* @return array{php: array<string, string[]>, js: array<string, string[]>}
|
||||
*/
|
||||
function collect_enum_maps(string $themeRoot): array {
|
||||
$php = [];
|
||||
$js = [];
|
||||
|
||||
$phpFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
|
||||
foreach ($phpFiles as $file) {
|
||||
$src = file_get_contents($file);
|
||||
if ($src === false) {
|
||||
continue;
|
||||
}
|
||||
// Split roughly by field array starts: ['key' => '...'
|
||||
if (!preg_match_all("/\\['key'\\s*=>\\s*'([^']+)'([\\s\\S]*?)(?=\\['key'\\s*=>|\\]\\s*,\\s*\\[\\s*'key'|\\]\\s*\\]\\s*,\\s*\\[\\s*'key'|$)/", $src, $chunks, PREG_SET_ORDER)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($chunks as $chunk) {
|
||||
$key = $chunk[1];
|
||||
$body = $chunk[2];
|
||||
if (!preg_match("/'type'\\s*=>\\s*'(radio|select)'/", $body)) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match("/'options'\\s*=>\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
|
||||
continue;
|
||||
}
|
||||
$values = [];
|
||||
if (preg_match_all("/'value'\\s*=>\\s*'([^']*)'/", $om[1], $vm)) {
|
||||
$values = $vm[1];
|
||||
}
|
||||
// Keep first definition; warn later if duplicates differ.
|
||||
if (!isset($php[$key])) {
|
||||
$php[$key] = $values;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$jsRoot = $themeRoot . '/assets/admin/src/schema/sections';
|
||||
if (is_dir($jsRoot)) {
|
||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($jsRoot, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'js') {
|
||||
continue;
|
||||
}
|
||||
$src = file_get_contents($file->getPathname());
|
||||
if ($src === false) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match_all("/\\{\\s*key:\\s*'([^']+)'([\\s\\S]*?)(?=\\{\\s*key:\\s*'|$)/", $src, $chunks, PREG_SET_ORDER)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($chunks as $chunk) {
|
||||
$key = $chunk[1];
|
||||
$body = $chunk[2];
|
||||
// Stop body at next field-ish boundary if options not closed well.
|
||||
if (!preg_match("/type:\\s*'(radio|select)'/", $body)) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match("/options:\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
|
||||
continue;
|
||||
}
|
||||
$values = [];
|
||||
if (preg_match_all("/value:\\s*'([^']*)'/", $om[1], $vm)) {
|
||||
$values = $vm[1];
|
||||
}
|
||||
if (!isset($js[$key])) {
|
||||
$js[$key] = $values;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['php' => $php, 'js' => $js];
|
||||
}
|
||||
|
||||
function collect_missing_this_callbacks(string $themeRoot): array {
|
||||
$issues = [];
|
||||
$adminRoot = $themeRoot . '/src/Admin';
|
||||
$methodIndex = [];
|
||||
|
||||
if (!is_dir($adminRoot)) {
|
||||
return $issues;
|
||||
}
|
||||
|
||||
$allPhp = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($adminRoot, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($allPhp as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
$src = file_get_contents($file->getPathname());
|
||||
if ($src === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match_all('/function\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(/', $src, $mm)) {
|
||||
foreach ($mm[1] as $method) {
|
||||
$methodIndex[$method] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($allPhp as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
$path = $file->getPathname();
|
||||
$src = file_get_contents($path);
|
||||
if ($src === false) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match_all("/'callback'\\s*=>\\s*\\[\\s*\\\$this\\s*,\\s*'([^']+)'\\s*\\]/", $src, $matches)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($matches[1] as $method) {
|
||||
if (empty($methodIndex[$method])) {
|
||||
$issues[] = [
|
||||
'file' => rel($path, $themeRoot),
|
||||
'method' => $method,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
function audit_build_manifest(string $themeRoot): array {
|
||||
$buildDir = $themeRoot . '/assets/admin/build';
|
||||
$result = [
|
||||
'present' => is_dir($buildDir),
|
||||
'entry' => false,
|
||||
'missing_chunks' => [],
|
||||
'chunks' => [],
|
||||
];
|
||||
if (!$result['present']) {
|
||||
return $result;
|
||||
}
|
||||
$entry = $buildDir . '/index.js';
|
||||
$result['entry'] = is_file($entry);
|
||||
if (!$result['entry']) {
|
||||
return $result;
|
||||
}
|
||||
$src = file_get_contents($entry);
|
||||
if ($src === false) {
|
||||
return $result;
|
||||
}
|
||||
if (preg_match_all('/\\.\\/([A-Za-z0-9_.-]+\\.js)/', $src, $m)) {
|
||||
$result['chunks'] = array_values(array_unique($m[1]));
|
||||
foreach ($result['chunks'] as $chunk) {
|
||||
if (!is_file($buildDir . '/' . $chunk)) {
|
||||
$result['missing_chunks'][] = $chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
$phpSchemas = collect_php_schema_sections($themeRoot);
|
||||
$menuPages = collect_menu_pages($themeRoot);
|
||||
$enums = collect_enum_maps($themeRoot);
|
||||
$restIssues = collect_missing_this_callbacks($themeRoot);
|
||||
$build = audit_build_manifest($themeRoot);
|
||||
|
||||
$info[] = 'PHP schema domains: ' . implode(', ', array_keys($phpSchemas));
|
||||
$info[] = 'Menu pages discovered: ' . count($menuPages);
|
||||
|
||||
foreach ($menuPages as $page) {
|
||||
if (($page['kind'] ?? '') !== 'schemaPage') {
|
||||
continue;
|
||||
}
|
||||
$domain = $page['domain'] ?? '';
|
||||
$section = $page['section'] ?? '';
|
||||
$key = $page['key'] ?? '(unknown)';
|
||||
if ($domain === '' || $section === '') {
|
||||
$errors[] = "schemaPage {$key} missing canonicalDomain/section in {$page['file']}";
|
||||
continue;
|
||||
}
|
||||
if (!isset($phpSchemas[$domain])) {
|
||||
$errors[] = "schemaPage {$key} domain '{$domain}' has no PHP schema builder";
|
||||
continue;
|
||||
}
|
||||
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
|
||||
$errors[] = "schemaPage {$key} section '{$domain}/{$section}' missing in PHP schema; known=[" . implode(',', $phpSchemas[$domain]['sections']) . '] files=' . implode(',', $phpSchemas[$domain]['files']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($menuPages as $page) {
|
||||
if (($page['kind'] ?? '') !== 'customPage') {
|
||||
continue;
|
||||
}
|
||||
$domain = $page['domain'] ?? '';
|
||||
$section = $page['section'] ?? '';
|
||||
if ($domain === '' || $section === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($phpSchemas[$domain])) {
|
||||
$warnings[] = "customPage {$page['key']} references domain '{$domain}' without PHP schema map entry";
|
||||
continue;
|
||||
}
|
||||
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
|
||||
$warnings[] = "customPage {$page['key']} section '{$domain}/{$section}' not in PHP schema (ok if pure operational page)";
|
||||
}
|
||||
}
|
||||
|
||||
// Enum mismatches only for shared keys with non-empty option sets on both sides.
|
||||
// Heuristic parsers often capture only the default option from dense PHP arrays;
|
||||
// treat "PHP is a non-empty proper subset of JS" as a parser warning, not a hard error.
|
||||
// Hard error when sets are disjoint or PHP introduces values JS does not have.
|
||||
$shared = array_intersect(array_keys($enums['php']), array_keys($enums['js']));
|
||||
sort($shared);
|
||||
foreach ($shared as $key) {
|
||||
$phpValues = $enums['php'][$key];
|
||||
$jsValues = $enums['js'][$key];
|
||||
if ($phpValues === [] && $jsValues === []) {
|
||||
continue;
|
||||
}
|
||||
$phpSorted = $phpValues;
|
||||
$jsSorted = $jsValues;
|
||||
sort($phpSorted);
|
||||
sort($jsSorted);
|
||||
if ($phpSorted === $jsSorted) {
|
||||
continue;
|
||||
}
|
||||
$phpOnly = array_values(array_diff($phpValues, $jsValues));
|
||||
$jsOnly = array_values(array_diff($jsValues, $phpValues));
|
||||
if ($phpOnly === [] && $jsOnly !== [] && $phpValues !== []) {
|
||||
$warnings[] = "enum incomplete parse for '{$key}' (php subset of js): php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . ']';
|
||||
continue;
|
||||
}
|
||||
$errors[] = "enum mismatch for '{$key}': php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . '] php_only=[' . implode(',', $phpOnly) . '] js_only=[' . implode(',', $jsOnly) . ']';
|
||||
}
|
||||
|
||||
foreach ($restIssues as $issue) {
|
||||
$errors[] = "REST \$this callback missing: {$issue['method']} in {$issue['file']}";
|
||||
}
|
||||
|
||||
if (!$build['present']) {
|
||||
$warnings[] = 'assets/admin/build/ missing — run: cd assets/admin && npm run build';
|
||||
} elseif (!$build['entry']) {
|
||||
$errors[] = 'assets/admin/build/index.js missing';
|
||||
} elseif ($build['missing_chunks']) {
|
||||
$errors[] = 'admin build missing chunks: ' . implode(', ', $build['missing_chunks']);
|
||||
} else {
|
||||
$info[] = 'admin build ok; chunks=' . count($build['chunks']);
|
||||
}
|
||||
|
||||
echo "AKSRT admin contract audit\n";
|
||||
echo str_repeat('=', 48) . "\n";
|
||||
foreach ($info as $line) {
|
||||
echo "[info] {$line}\n";
|
||||
}
|
||||
foreach ($warnings as $line) {
|
||||
echo "[warn] {$line}\n";
|
||||
}
|
||||
foreach ($errors as $line) {
|
||||
echo "[error] {$line}\n";
|
||||
}
|
||||
echo str_repeat('-', 48) . "\n";
|
||||
echo 'errors=' . count($errors) . ' warnings=' . count($warnings) . "\n";
|
||||
|
||||
$summaryPath = $themeRoot . '/tools/admin-contract-report.json';
|
||||
file_put_contents($summaryPath, json_encode([
|
||||
'generated_at' => gmdate('c'),
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
'info' => $info,
|
||||
'domains' => $phpSchemas,
|
||||
'page_count' => count($menuPages),
|
||||
'build' => $build,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
echo "wrote tools/admin-contract-report.json\n";
|
||||
|
||||
// Ignore report in git by default? Keep it for CI artifact; do not commit noise if huge.
|
||||
exit(count($errors) > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
// Minimal WordPress-compatible stubs keep the schema audit runnable without a site checkout.
|
||||
if (!function_exists('sanitize_text_field')) {
|
||||
function sanitize_text_field($value): string {
|
||||
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
|
||||
}
|
||||
}
|
||||
if (!function_exists('__')) {
|
||||
function __($text): string {
|
||||
return (string) $text;
|
||||
}
|
||||
}
|
||||
if (!function_exists('sanitize_textarea_field')) {
|
||||
function sanitize_textarea_field($value): string {
|
||||
return trim(strip_tags((string) $value));
|
||||
}
|
||||
}
|
||||
if (!function_exists('wp_kses_post')) {
|
||||
function wp_kses_post($value): string {
|
||||
return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>');
|
||||
}
|
||||
}
|
||||
if (!function_exists('esc_url_raw')) {
|
||||
function esc_url_raw($value): string {
|
||||
$value = trim((string) $value);
|
||||
return filter_var($value, FILTER_VALIDATE_URL) ? $value : '';
|
||||
}
|
||||
}
|
||||
if (!function_exists('absint')) {
|
||||
function absint($value): int {
|
||||
return abs((int) $value);
|
||||
}
|
||||
}
|
||||
if (!function_exists('sanitize_hex_color')) {
|
||||
function sanitize_hex_color($value): string {
|
||||
$value = (string) $value;
|
||||
return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', $value) ? strtolower($value) : '';
|
||||
}
|
||||
}
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use AKSRT\WidgetSystem\FieldSchema;
|
||||
use AKSRT\WidgetSystem\PostQuery;
|
||||
use AKSRT\WidgetSystem\RendererRegistry;
|
||||
use AKSRT\WidgetSystem\WidgetAreas;
|
||||
use AKSRT\WidgetSystem\WidgetRegistry;
|
||||
use AKSRT\WidgetSystem\WidgetTransfer;
|
||||
|
||||
$errors = [];
|
||||
$expected = [
|
||||
'search', 'posts_toc', 'quote', 'icon_cards', 'video', 'embed', 'media_cards',
|
||||
'icon_cover_cards', 'partners', 'combo_info', 'text_title', 'buttons', 'layout_gap',
|
||||
'tag_cloud', 'recent_comments', 'links', 'notice', 'term_cards', 'term_aggregation',
|
||||
'hot_posts', 'post_pay', 'posts', 'tab_posts', 'oneline_posts', 'pricing_plans',
|
||||
'reviews', 'faq', 'stats', 'highlights', 'feature_intro', 'tilt_cases', 'slider',
|
||||
'slider_media_combo', 'user_card', 'author_card', 'user_ranking', 'user_list',
|
||||
];
|
||||
|
||||
$registry = WidgetRegistry::all();
|
||||
if (count($registry) !== 37) {
|
||||
$errors[] = 'Registry must contain exactly 37 widgets; got ' . count($registry) . '.';
|
||||
}
|
||||
if (array_keys($registry) !== $expected) {
|
||||
$errors[] = 'Registry slugs or ordering differ from the frozen contract.';
|
||||
}
|
||||
|
||||
$expected_areas = [
|
||||
'all_top_fluid', 'all_bottom_fluid', 'all_sidebar_top', 'all_sidebar_bottom',
|
||||
'home_top_fluid', 'home_top_content', 'home_bottom_content', 'home_bottom_fluid', 'home_sidebar',
|
||||
'single_top_fluid', 'single_top_content', 'single_bottom_content', 'single_bottom_fluid', 'single_sidebar',
|
||||
'cat_top_fluid', 'cat_top_content', 'cat_bottom_content', 'cat_bottom_fluid', 'cat_sidebar',
|
||||
'tag_top_fluid', 'tag_top_content', 'tag_bottom_content', 'tag_bottom_fluid', 'tag_sidebar',
|
||||
'search_top_fluid', 'search_top_content', 'search_bottom_content', 'search_bottom_fluid', 'search_sidebar',
|
||||
'author_top_fluid', 'author_bottom_fluid', 'msg_top_fluid', 'msg_bottom_fluid',
|
||||
'user_top_fluid', 'user_top_content', 'user_bottom_fluid',
|
||||
'newposts_sidebar_top', 'newposts_sidebar_bottom', 'all_footer', 'mobile_nav_fluid',
|
||||
];
|
||||
if (WidgetAreas::static_ids() !== $expected_areas || count(WidgetAreas::static_ids()) !== 40) {
|
||||
$errors[] = 'Widget area registry must contain the exact 40 static areas.';
|
||||
}
|
||||
|
||||
$templates = WidgetTransfer::templates();
|
||||
$expected_templates = ['article_home', 'article_list', 'links_navigation', 'article_sidebar', 'knowledge_landing'];
|
||||
if (array_keys($templates) !== $expected_templates || count($templates) !== 5) {
|
||||
$errors[] = 'Import/export must expose the exact five built-in templates.';
|
||||
}
|
||||
foreach ($templates as $template => $contract) {
|
||||
foreach ((array) ($contract['sidebars'] ?? []) as $items) {
|
||||
foreach ((array) $items as $item) {
|
||||
if (!isset($registry[(string) ($item['widget'] ?? '')])) {
|
||||
$errors[] = $template . ': template contains an unknown widget.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$supported = FieldSchema::supported_types();
|
||||
$walk = static function (array $fields, string $owner) use (&$walk, &$errors, $supported): void {
|
||||
$ids = [];
|
||||
foreach ($fields as $field) {
|
||||
$id = (string) ($field['id'] ?? '');
|
||||
$type = (string) ($field['type'] ?? 'text');
|
||||
if (!in_array($type, $supported, true)) {
|
||||
$errors[] = $owner . ': unsupported field type ' . $type . '.';
|
||||
}
|
||||
if ($id !== '') {
|
||||
if (isset($ids[$id])) {
|
||||
$errors[] = $owner . ': duplicate field id ' . $id . '.';
|
||||
}
|
||||
$ids[$id] = true;
|
||||
}
|
||||
if (!empty($field['fields']) && is_array($field['fields'])) {
|
||||
$walk($field['fields'], $owner . '.' . ($id ?: $type));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
foreach ($registry as $slug => $definition) {
|
||||
if ($definition->id_base() !== 'aksrt_widget_' . $slug) {
|
||||
$errors[] = $slug . ': invalid id_base.';
|
||||
}
|
||||
if (preg_match('/(?:bbs|forum|shop|cart|inventory|shipping)/i', $slug . ' ' . $definition->renderer())) {
|
||||
$errors[] = $slug . ': forbidden forum/shop contract.';
|
||||
}
|
||||
$fields = $definition->fields();
|
||||
$walk($fields, $slug);
|
||||
$field_ids = array_column($fields, 'id');
|
||||
$required_common = ['show_type', 'show_id_type', 'show_ids', 'layout_pin_pc', 'layout_bg'];
|
||||
if (($definition->common()['animation'] ?? true)) {
|
||||
$required_common[] = 'animation_in';
|
||||
}
|
||||
foreach ($required_common as $required) {
|
||||
if (!in_array($required, $field_ids, true)) {
|
||||
$errors[] = $slug . ': missing common field ' . $required . '.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$renderers = RendererRegistry::all();
|
||||
if (count($renderers) !== 37 || array_keys($renderers) !== $expected) {
|
||||
$errors[] = 'Renderer registry must map the exact 37 frozen slugs.';
|
||||
}
|
||||
foreach ($renderers as $slug => $renderer) {
|
||||
if (!is_callable([$renderer, 'render'])) {
|
||||
$errors[] = $slug . ': renderer is not callable.';
|
||||
}
|
||||
}
|
||||
|
||||
$dynamic = ['posts', 'tab_posts', 'oneline_posts'];
|
||||
foreach ($expected as $slug) {
|
||||
if (RendererRegistry::supports_items($slug) !== in_array($slug, $dynamic, true)) {
|
||||
$errors[] = $slug . ': invalid dynamic items capability.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!defined('DAY_IN_SECONDS')) {
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
}
|
||||
$query_args = PostQuery::arguments([
|
||||
'count' => 7,
|
||||
'category_ids' => '2,5',
|
||||
'tag_ids' => '8,13',
|
||||
'limit_day' => 30,
|
||||
'orderby' => 'views',
|
||||
'order' => 'ASC',
|
||||
'pay_type' => 'video',
|
||||
], 3);
|
||||
if (($query_args['posts_per_page'] ?? 0) !== 7 || ($query_args['paged'] ?? 0) !== 3) {
|
||||
$errors[] = 'Post query pagination contract failed.';
|
||||
}
|
||||
if (($query_args['category__in'] ?? []) !== [2, 5] || ($query_args['tag__in'] ?? []) !== [8, 13]) {
|
||||
$errors[] = 'Post query taxonomy contract failed.';
|
||||
}
|
||||
if (($query_args['orderby'] ?? '') !== 'meta_value_num' || ($query_args['order'] ?? '') !== 'ASC') {
|
||||
$errors[] = 'Post query ordering contract failed.';
|
||||
}
|
||||
$pay_query = json_encode($query_args['meta_query'] ?? []);
|
||||
if (!is_string($pay_query) || !str_contains($pay_query, 'video')) {
|
||||
$errors[] = 'Post query paid-content contract failed.';
|
||||
}
|
||||
|
||||
$rest_source = (string) file_get_contents(dirname(__DIR__) . '/src/WidgetSystem/RestController.php');
|
||||
foreach ([
|
||||
"'permission_callback' => '__return_true'",
|
||||
"'instance' => ['type' => 'string', 'required' => true",
|
||||
"'page' => ['type' => 'integer'",
|
||||
"'segment' => ['type' => 'string'",
|
||||
'wp_get_sidebars_widgets()',
|
||||
'$widget->get_settings()',
|
||||
] as $contract) {
|
||||
if (!str_contains($rest_source, $contract)) {
|
||||
$errors[] = 'REST controller missing contract: ' . $contract;
|
||||
}
|
||||
}
|
||||
if (preg_match('/\$_(?:GET|POST|REQUEST)/', $rest_source)) {
|
||||
$errors[] = 'REST controller must use WP_REST_Request only.';
|
||||
}
|
||||
|
||||
$frontend_files = [
|
||||
dirname(__DIR__) . '/assets/widgets/frontend.css',
|
||||
dirname(__DIR__) . '/assets/widgets/frontend.js',
|
||||
];
|
||||
foreach ($frontend_files as $frontend_file) {
|
||||
if (!is_file($frontend_file) || filesize($frontend_file) === 0) {
|
||||
$errors[] = 'Missing frontend widget asset: ' . basename($frontend_file);
|
||||
}
|
||||
}
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$legacy_paths = [
|
||||
'/src/Core/WidgetAjax.php',
|
||||
'/src/Core/SetupPart1.php',
|
||||
'/src/Widgets',
|
||||
'/inc/modules/widgets-register.php',
|
||||
'/inc/modules/widget-common.php',
|
||||
'/inc/modules/widget-areas.php',
|
||||
'/assets/css/frontend/widgets.css',
|
||||
'/assets/css/frontend/widgets',
|
||||
'/assets/css/frontend/widget-batch5.css',
|
||||
'/assets/js/widget-set.js',
|
||||
];
|
||||
foreach ($legacy_paths as $legacy_path) {
|
||||
if (file_exists($root . $legacy_path)) {
|
||||
$errors[] = 'Legacy widget runtime remains: ' . $legacy_path;
|
||||
}
|
||||
}
|
||||
|
||||
$runtime_source = (string) file_get_contents($root . '/src/WidgetSystem/Runtime.php');
|
||||
foreach (['WidgetAreas::class', 'WidgetRegistry::all()', 'register_widget(new SchemaWidget', 'unregister_core_widgets'] as $contract) {
|
||||
if (!str_contains($runtime_source, $contract)) {
|
||||
$errors[] = 'Widget runtime missing contract: ' . $contract;
|
||||
}
|
||||
}
|
||||
$asset_source = (string) file_get_contents($root . '/src/WidgetSystem/AssetManager.php');
|
||||
if (str_contains($asset_source, 'cdn.jsdelivr.net')) {
|
||||
$errors[] = 'Widget assets must not use CDN dependencies.';
|
||||
}
|
||||
$admin_import_sources = (string) file_get_contents($root . '/src/Admin/ImportExportController.php')
|
||||
. (string) file_get_contents($root . '/src/Admin/ImportExportControllerPart1.php');
|
||||
foreach (['/widgets/export', '/widgets/import', '/widgets/templates', 'aksrt_widget_templates'] as $legacy_contract) {
|
||||
if (str_contains($admin_import_sources, $legacy_contract)) {
|
||||
$errors[] = 'Legacy widget import/export contract remains: ' . $legacy_contract;
|
||||
}
|
||||
}
|
||||
|
||||
$pinned_assets = [
|
||||
'DPlayer.min.js' => '361689807fc1e856f868df91036e41195d2c255619bd1c5526849c5ad7491241',
|
||||
'hls.min.js' => 'e876282a26116e323c8ed3e86bed3838a80dbbe3a2faa09f834bee86963ff760',
|
||||
'flv.min.js' => '0668d36757cdc17804d5c0bd6fe8956d76370e73a733bf5bbc0eddbfbca2c6cc',
|
||||
'dash.all.min.js' => '3e753969c16a2a78f420a25d24869f14721eb1d284418f9f22d35a31c3693cd9',
|
||||
'vanilla-tilt.min.js' => 'de6fa8d3f40dbae2726a72f7d1ae46bb0588267af05355bef1d128f737f4a282',
|
||||
];
|
||||
foreach ($pinned_assets as $asset => $hash) {
|
||||
$path = $root . '/assets/widgets/third-party/' . $asset;
|
||||
if (!is_file($path)) {
|
||||
$errors[] = 'Missing pinned widget dependency: ' . $asset;
|
||||
} else {
|
||||
$contents = (string) file_get_contents($path);
|
||||
$raw_hash = hash('sha256', $contents);
|
||||
$lf_hash = hash('sha256', str_replace("\r\n", "\n", $contents));
|
||||
if ($raw_hash !== $hash && $lf_hash !== $hash) {
|
||||
$errors[] = 'Pinned widget dependency changed: ' . $asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!is_file($root . '/assets/widgets/third-party/THIRD_PARTY.md')) {
|
||||
$errors[] = 'Missing widget dependency license manifest.';
|
||||
}
|
||||
|
||||
$posts = $registry['posts'];
|
||||
$normalized = $posts->normalize([
|
||||
'title' => '<script>alert(1)</script> 最新课程',
|
||||
'show_type' => 'invalid',
|
||||
'show_id_type' => 'show',
|
||||
'show_ids' => '3, 3,9 bad -4',
|
||||
'count' => 999,
|
||||
'style' => 'broken',
|
||||
'cat' => '4,-7',
|
||||
]);
|
||||
if ($normalized['count'] !== 20 || $normalized['style'] !== 'list' || $normalized['show_type'] !== 'all') {
|
||||
$errors[] = 'Schema clamping or enum fallback failed.';
|
||||
}
|
||||
if ($normalized['show_ids'] !== '3,9,4' || $normalized['cat'] !== '4,-7') {
|
||||
$errors[] = 'ID list normalization failed: ' . $normalized['show_ids'];
|
||||
}
|
||||
if (str_contains($normalized['title'], '<script>')) {
|
||||
$errors[] = 'Text sanitization failed.';
|
||||
}
|
||||
|
||||
$baseline_file = dirname(__DIR__) . '/.specs/baselines/widget-reference.json';
|
||||
$baseline = json_decode((string) file_get_contents($baseline_file), true);
|
||||
if (!is_array($baseline) || ($baseline['source_policy']['modern_widget_count'] ?? null) !== 37) {
|
||||
$errors[] = 'Reference baseline is missing or invalid.';
|
||||
} elseif (is_dir((string) ($baseline['source_root'] ?? ''))) {
|
||||
foreach ((array) ($baseline['files'] ?? []) as $file) {
|
||||
$path = rtrim((string) $baseline['source_root'], '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $file['path']);
|
||||
if (!is_file($path)) {
|
||||
$errors[] = 'Reference file missing: ' . $path;
|
||||
continue;
|
||||
}
|
||||
if (hash_file('sha256', $path) !== ($file['sha256'] ?? '')) {
|
||||
$errors[] = 'Reference file changed: ' . $file['path'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
fwrite(STDERR, "Widget system audit failed:\n- " . implode("\n- ", $errors) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Widget system audit passed: 37 widgets, 40 areas, 5 templates, single runtime and pinned assets.\n";
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
|
||||
|
||||
use AKSRT\WidgetSystem\FieldSchema;
|
||||
use AKSRT\WidgetSystem\WidgetRegistry;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$baseline_path = $root . '/.specs/baselines/widget-reference.json';
|
||||
$baseline = json_decode((string) file_get_contents($baseline_path), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (($baseline['schema'] ?? '') !== 'aksrt.widget-reference.v1') {
|
||||
throw new RuntimeException('Frozen widget reference baseline is invalid.');
|
||||
}
|
||||
|
||||
$reference_root = rtrim((string) ($baseline['source_root'] ?? ''), '\\/');
|
||||
$reference_hashes = [];
|
||||
foreach ((array) ($baseline['files'] ?? []) as $file) {
|
||||
$relative = (string) ($file['path'] ?? '');
|
||||
$expected = (string) ($file['sha256'] ?? '');
|
||||
$path = $reference_root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
||||
if (!is_file($path) || hash_file('sha256', $path) !== $expected) {
|
||||
throw new RuntimeException('Frozen reference changed or is missing: ' . $relative);
|
||||
}
|
||||
$reference_hashes[$relative] = $expected;
|
||||
}
|
||||
|
||||
$group_sources = [
|
||||
'general' => 'inc/widgets/widget-more.php',
|
||||
'content' => 'inc/widgets/widget-posts.php',
|
||||
'knowledge_product' => 'inc/widgets/widget-produck.php',
|
||||
'slider' => 'inc/widgets/widget-slider.php',
|
||||
'user' => 'inc/widgets/widget-user.php',
|
||||
];
|
||||
$viewports = [
|
||||
['name' => 'desktop', 'width' => 1440, 'height' => 1100],
|
||||
['name' => 'tablet', 'width' => 1024, 'height' => 1000],
|
||||
['name' => 'mobile', 'width' => 390, 'height' => 844],
|
||||
];
|
||||
$modes = ['light', 'dark'];
|
||||
|
||||
$images = [
|
||||
'https://fixtures.aksrt.test/media/course-cover-01.jpg',
|
||||
'https://fixtures.aksrt.test/media/course-cover-02.jpg',
|
||||
'https://fixtures.aksrt.test/media/course-cover-03.jpg',
|
||||
];
|
||||
|
||||
function fixture_text(string $id, string $slug, int $index): string {
|
||||
$labels = [
|
||||
'question' => '购买后可以长期学习吗?',
|
||||
'answer' => '可以,已购买内容会永久保存在你的学习中心。',
|
||||
'name' => 'AKSRT 学员 ' . ($index + 1),
|
||||
'author' => 'AKSRT 学员 ' . ($index + 1),
|
||||
'role' => '知识产品体验官',
|
||||
'price' => '¥99',
|
||||
'value' => (string) (($index + 1) * 1280) . '+',
|
||||
'label' => '累计学习人数',
|
||||
'heading' => '从入门到精通的系统学习路径',
|
||||
'description' => '固定内容用于组件 DOM、响应式和视觉差异验收。',
|
||||
'content' => '结构清晰、案例完整,并持续更新配套资料。',
|
||||
'title' => 'AKSRT 固定示例 ' . ($index + 1),
|
||||
'text' => '保持好奇,持续创造。',
|
||||
];
|
||||
if (isset($labels[$id])) {
|
||||
return $labels[$id];
|
||||
}
|
||||
if (str_contains($id, 'video')) {
|
||||
return 'https://fixtures.aksrt.test/media/lesson.m3u8';
|
||||
}
|
||||
if (str_contains($id, 'url')) {
|
||||
return 'https://fixtures.aksrt.test/' . $slug . '/' . ($index + 1);
|
||||
}
|
||||
if (str_contains($id, 'ids')) {
|
||||
return str_contains($id, 'term') || str_contains($id, 'category') ? '101,102,103' : '';
|
||||
}
|
||||
return 'AKSRT ' . str_replace('_', ' ', $id) . ' ' . ($index + 1);
|
||||
}
|
||||
|
||||
function fixture_value(array $field, string $slug, int $index, array $images) {
|
||||
$type = (string) ($field['type'] ?? 'text');
|
||||
$id = (string) ($field['id'] ?? '');
|
||||
$default = $field['default'] ?? null;
|
||||
if ($id === 'title') return 'AKSRT · ' . ucfirst(str_replace('_', ' ', $slug));
|
||||
if ($id === 'subtitle') return '固定内容与交互状态视觉验收';
|
||||
if ($id === 'title_highlight') return 'AKSRT';
|
||||
if ($id === 'show_type') return 'all';
|
||||
if ($id === 'show_id_type' || $id === 'show_ids') return '';
|
||||
if ($id === 'layout_bg') {
|
||||
return [
|
||||
'light' => ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
|
||||
'dark' => ['color' => '#1d1e24', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
|
||||
];
|
||||
}
|
||||
if ($type === 'group') {
|
||||
$rows = [];
|
||||
$count = min(3, max(1, (int) ($field['max_items'] ?? 3)));
|
||||
for ($row = 0; $row < $count; $row++) {
|
||||
$item = [];
|
||||
foreach ((array) ($field['fields'] ?? []) as $child) {
|
||||
$child_id = (string) ($child['id'] ?? '');
|
||||
if ($child_id !== '') $item[$child_id] = fixture_value($child, $slug, $row, $images);
|
||||
}
|
||||
$rows[] = $item;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
if (in_array($type, ['fieldset', 'accordion'], true)) {
|
||||
$value = [];
|
||||
foreach ((array) ($field['fields'] ?? []) as $child) {
|
||||
$child_id = (string) ($child['id'] ?? '');
|
||||
if ($child_id !== '') $value[$child_id] = fixture_value($child, $slug, $index, $images);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
if ($type === 'link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看详情', 'target' => ''];
|
||||
if ($type === 'upload') return $images[$index % count($images)];
|
||||
if ($type === 'icon') return 'fa fas fa-graduation-cap';
|
||||
if ($type === 'color') return '#667eea';
|
||||
if ($type === 'spacing') return ['top' => 0, 'right' => 0, 'bottom' => 20, 'left' => 0];
|
||||
if ($type === 'background') return ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'];
|
||||
if ($type === 'switcher') return $default === null ? true : (bool) $default;
|
||||
if (in_array($type, ['number', 'spinner', 'slider'], true)) return $default ?? ($field['min'] ?? 1);
|
||||
if (in_array($type, ['radio', 'button_set', 'select', 'palette'], true)) return $default ?? (array_key_first((array) ($field['options'] ?? [])) ?? '');
|
||||
if ($type === 'checkbox') return $default ?? [];
|
||||
if ($id === 'title_link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看更多', 'target' => ''];
|
||||
if ($default !== null && $default !== '') return $default;
|
||||
return fixture_text($id, $slug, $index);
|
||||
}
|
||||
|
||||
$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'];
|
||||
$expandable = ['posts_toc', 'faq'];
|
||||
$tabbed = ['tab_posts', 'feature_intro'];
|
||||
$carousel = ['partners', 'notice', 'oneline_posts', 'reviews', 'slider', 'slider_media_combo'];
|
||||
$components = [];
|
||||
$matrix = [];
|
||||
foreach (WidgetRegistry::all() as $slug => $definition) {
|
||||
$settings = [];
|
||||
foreach ($definition->fields() as $field) {
|
||||
$id = (string) ($field['id'] ?? '');
|
||||
$type = (string) ($field['type'] ?? 'text');
|
||||
if ($id === '' || in_array($type, ['content', 'subheading', 'submessage'], true)) continue;
|
||||
$settings[$id] = fixture_value($field, $slug, 0, $images);
|
||||
}
|
||||
$settings = $definition->normalize($settings);
|
||||
$states = ['default', 'hover', 'focus'];
|
||||
if (in_array($slug, $expandable, true)) $states[] = 'expanded';
|
||||
if (in_array($slug, $tabbed, true)) $states[] = 'alternate-tab';
|
||||
if (in_array($slug, $carousel, true)) $states[] = 'next-slide';
|
||||
if (in_array($slug, $dynamic, true)) $states = array_merge($states, ['loading', 'empty', 'error']);
|
||||
if ($slug === 'post_pay') $states = array_merge($states, ['guest-locked', 'member-locked', 'authorized', 'unconfigured']);
|
||||
if ($slug === 'user_card') $states = array_merge($states, ['guest', 'signed-in']);
|
||||
$source = $group_sources[$definition->group()] ?? 'inc/widgets/widget-index.php';
|
||||
$components[$slug] = [
|
||||
'id_base' => $definition->id_base(),
|
||||
'name' => $definition->name(),
|
||||
'group' => $definition->group(),
|
||||
'size' => $definition->size(),
|
||||
'reference' => ['file' => $source, 'sha256' => $reference_hashes[$source] ?? ''],
|
||||
'field_ids' => array_values(array_filter(array_map(static fn(array $field): string => (string) ($field['id'] ?? ''), $definition->fields()))),
|
||||
'settings' => $settings,
|
||||
'root_selector' => '[data-aksrt-widget="' . $slug . '"]',
|
||||
'states' => array_values(array_unique($states)),
|
||||
];
|
||||
foreach ($viewports as $viewport) {
|
||||
foreach ($modes as $mode) {
|
||||
$id = $slug . ':' . $viewport['name'] . ':' . $mode;
|
||||
$matrix[$id] = [
|
||||
'widget' => $slug,
|
||||
'viewport' => $viewport,
|
||||
'mode' => $mode,
|
||||
'path' => 'tests/visual-results/' . $slug . '/' . $viewport['name'] . '-' . $mode . '.png',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fixture = [
|
||||
'schema' => 'aksrt.widget-golden.v1',
|
||||
'captured_at' => (string) ($baseline['captured_at'] ?? ''),
|
||||
'reference' => [
|
||||
'source_root' => $reference_root,
|
||||
'fingerprint' => hash('sha256', implode('', $reference_hashes)),
|
||||
'files' => $reference_hashes,
|
||||
'policy' => $baseline['source_policy'] ?? [],
|
||||
],
|
||||
'acceptance' => [
|
||||
'dimension_delta_px_max' => 2,
|
||||
'pixel_difference_percent_max' => 1.5,
|
||||
'console_errors_max' => 0,
|
||||
'unexpected_network_failures_max' => 0,
|
||||
'a11y_critical_max' => 0,
|
||||
'a11y_serious_max' => 0,
|
||||
'reduced_motion_required' => true,
|
||||
],
|
||||
'dataset' => [
|
||||
'site' => ['name' => 'AKSRT 知识库', 'url' => 'https://fixtures.aksrt.test'],
|
||||
'terms' => [
|
||||
['id' => 101, 'taxonomy' => 'category', 'name' => '产品设计', 'count' => 24],
|
||||
['id' => 102, 'taxonomy' => 'category', 'name' => '前端开发', 'count' => 36],
|
||||
['id' => 103, 'taxonomy' => 'post_tag', 'name' => '系统课程', 'count' => 18],
|
||||
],
|
||||
'users' => [
|
||||
['id' => 201, 'name' => '林知夏', 'role' => '讲师', 'points' => 9680, 'followers' => 3280],
|
||||
['id' => 202, 'name' => '周屿', 'role' => '作者', 'points' => 7420, 'followers' => 2160],
|
||||
['id' => 203, 'name' => '陈墨', 'role' => '学员', 'points' => 5360, 'followers' => 980],
|
||||
],
|
||||
'posts' => [
|
||||
['id' => 301, 'title' => '从零构建知识产品体系', 'pay_type' => 'reading', 'author' => 201, 'category' => 101],
|
||||
['id' => 302, 'title' => '现代 WordPress 主题工程实践', 'pay_type' => 'video', 'author' => 202, 'category' => 102],
|
||||
['id' => 303, 'title' => '高质量课程资料包', 'pay_type' => 'download', 'author' => 201, 'category' => 102],
|
||||
['id' => 304, 'title' => '视觉案例图库', 'pay_type' => 'gallery', 'author' => 202, 'category' => 101],
|
||||
['id' => 305, 'title' => '会员专属进阶课', 'pay_type' => 'vip', 'author' => 201, 'category' => 101],
|
||||
['id' => 306, 'title' => '公开学习指南', 'pay_type' => 'no', 'author' => 203, 'category' => 102],
|
||||
],
|
||||
'comments' => [
|
||||
['id' => 401, 'user' => 203, 'post' => 301, 'content' => '课程结构清晰,案例非常实用。'],
|
||||
['id' => 402, 'user' => 202, 'post' => 302, 'content' => '对主题工程化讲解得很完整。'],
|
||||
],
|
||||
'payment_states' => ['guest-locked', 'member-locked', 'free', 'purchased', 'vip-authorized', 'unconfigured'],
|
||||
'media' => ['images' => $images, 'video' => 'https://fixtures.aksrt.test/media/lesson.m3u8'],
|
||||
],
|
||||
'components' => $components,
|
||||
'screenshot_matrix' => $matrix,
|
||||
];
|
||||
|
||||
$output = $root . '/tests/fixtures/widget-golden-v1.json';
|
||||
file_put_contents($output, json_encode($fixture, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
|
||||
echo 'Generated widget golden fixture: ' . count($components) . ' components, ' . count($matrix) . " screenshots.\n";
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
function createClassList() {
|
||||
const values = new Set();
|
||||
return {
|
||||
add(name) { values.add(name); },
|
||||
remove(name) { values.delete(name); },
|
||||
contains(name) { return values.has(name); },
|
||||
toggle(name, force) {
|
||||
if (force === undefined ? !values.has(name) : force) values.add(name);
|
||||
else values.delete(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createButton(attributes = {}) {
|
||||
const listeners = new Map();
|
||||
return {
|
||||
addEventListener(type, listener) {
|
||||
if (!listeners.has(type)) listeners.set(type, []);
|
||||
listeners.get(type).push(listener);
|
||||
},
|
||||
getAttribute(name) { return attributes[name] || null; },
|
||||
listenerCount(type) { return (listeners.get(type) || []).length; },
|
||||
click() { (listeners.get('click') || []).forEach((listener) => listener()); },
|
||||
};
|
||||
}
|
||||
|
||||
function createModal() {
|
||||
return {
|
||||
hidden: true,
|
||||
dataset: {},
|
||||
classList: createClassList(),
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
};
|
||||
}
|
||||
|
||||
const autoFunHandlers = new Map();
|
||||
const body = { classList: createClassList() };
|
||||
const chargeButton = createButton();
|
||||
const transferButton = createButton({ 'data-aksrt-transfer-open': 'balance' });
|
||||
const chargeModal = createModal();
|
||||
const transferModal = createModal();
|
||||
let contentInjected = false;
|
||||
|
||||
const documentStub = {
|
||||
body,
|
||||
querySelector(selector) {
|
||||
if (!contentInjected) return null;
|
||||
if (selector === '[data-aksrt-charge-modal]') return chargeModal;
|
||||
if (selector === '[data-aksrt-transfer-modal]') return transferModal;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (!contentInjected) return [];
|
||||
if (selector === '[data-aksrt-charge-open]') return [chargeButton];
|
||||
if (selector === '[data-aksrt-transfer-open]') return [transferButton];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.document = documentStub;
|
||||
globalThis.window = {
|
||||
aksrtData: {},
|
||||
AksrtRequest: {
|
||||
request() { return Promise.resolve({ ok: false, json: {} }); },
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.jQuery = function () {
|
||||
return {
|
||||
off(name) { autoFunHandlers.delete(name); return this; },
|
||||
on(name, handler) { autoFunHandlers.set(name, handler); return this; },
|
||||
};
|
||||
};
|
||||
|
||||
const testFile = fileURLToPath(new URL('../assets/js/credits.js', import.meta.url));
|
||||
const source = readFileSync(testFile, 'utf8');
|
||||
new Function(source)();
|
||||
|
||||
assert.ok(autoFunHandlers.has('auto_fun.aksrt-credits'), 'credits.js should own one namespaced auto_fun subscription');
|
||||
|
||||
contentInjected = true;
|
||||
const rehydrateCredits = autoFunHandlers.get('auto_fun.aksrt-credits');
|
||||
rehydrateCredits();
|
||||
rehydrateCredits();
|
||||
|
||||
assert.equal(chargeButton.listenerCount('click'), 1, 'AJAX-injected recharge button should bind exactly once');
|
||||
assert.equal(transferButton.listenerCount('click'), 1, 'AJAX-injected transfer button should bind exactly once');
|
||||
|
||||
chargeButton.click();
|
||||
assert.equal(chargeModal.hidden, false, 'recharge click should open the AJAX-injected modal');
|
||||
assert.equal(chargeModal.classList.contains('is-open'), true, 'recharge modal should receive its open state');
|
||||
|
||||
transferButton.click();
|
||||
assert.equal(transferModal.hidden, false, 'transfer click should open the AJAX-injected modal');
|
||||
assert.equal(transferModal.classList.contains('is-open'), true, 'transfer modal should receive its open state');
|
||||
|
||||
console.log('credits AJAX rehydrate regression: ok');
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
|
||||
|
||||
use AKSRT\WidgetSystem\WidgetRegistry;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$fixture_path = $root . '/tests/fixtures/widget-golden-v1.json';
|
||||
$fixture = json_decode((string) file_get_contents($fixture_path), true, 512, JSON_THROW_ON_ERROR);
|
||||
$errors = [];
|
||||
if (($fixture['schema'] ?? '') !== 'aksrt.widget-golden.v1') $errors[] = 'Invalid golden fixture schema.';
|
||||
$components = (array) ($fixture['components'] ?? []);
|
||||
$matrix = (array) ($fixture['screenshot_matrix'] ?? []);
|
||||
if (array_keys($components) !== array_keys(WidgetRegistry::all()) || count($components) !== 37) $errors[] = 'Golden fixture must contain the exact 37 registry widgets.';
|
||||
if (count($matrix) !== 222) $errors[] = 'Screenshot matrix must contain 37 × 3 × 2 = 222 entries.';
|
||||
foreach ($components as $slug => $component) {
|
||||
$definition = WidgetRegistry::get((string) $slug);
|
||||
if ($definition === null) continue;
|
||||
if (($component['id_base'] ?? '') !== $definition->id_base()) $errors[] = $slug . ': id_base mismatch.';
|
||||
if (($component['settings'] ?? []) !== $definition->normalize((array) ($component['settings'] ?? []))) $errors[] = $slug . ': settings are not normalized.';
|
||||
foreach (['default', 'hover', 'focus'] as $state) {
|
||||
if (!in_array($state, (array) ($component['states'] ?? []), true)) $errors[] = $slug . ': missing state ' . $state . '.';
|
||||
}
|
||||
}
|
||||
foreach ((array) ($fixture['reference']['files'] ?? []) as $relative => $expected) {
|
||||
$path = rtrim((string) ($fixture['reference']['source_root'] ?? ''), '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $relative);
|
||||
if (!is_file($path) || hash_file('sha256', $path) !== $expected) $errors[] = 'Reference drift: ' . $relative;
|
||||
}
|
||||
|
||||
$require_results = in_array('--require-results', $argv, true);
|
||||
if ($require_results) {
|
||||
$result_path = $root . '/tests/visual-results/manifest.json';
|
||||
if (!is_file($result_path)) {
|
||||
$errors[] = 'Visual result manifest is missing.';
|
||||
} else {
|
||||
$result = json_decode((string) file_get_contents($result_path), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (($result['schema'] ?? '') !== 'aksrt.widget-visual-results.v1') $errors[] = 'Invalid visual result schema.';
|
||||
$screenshots = (array) ($result['screenshots'] ?? []);
|
||||
foreach ($matrix as $id => $expected) {
|
||||
$actual = (array) ($screenshots[$id] ?? []);
|
||||
$path = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) ($expected['path'] ?? ''));
|
||||
if (!$actual || !is_file($path)) $errors[] = $id . ': screenshot result is missing.';
|
||||
}
|
||||
foreach ($components as $slug => $component) {
|
||||
$states = (array) ($result['components'][$slug]['states'] ?? []);
|
||||
foreach ((array) ($component['states'] ?? []) as $state) {
|
||||
if (($states[$state] ?? '') !== 'pass') $errors[] = $slug . ': state not passed: ' . $state . '.';
|
||||
}
|
||||
}
|
||||
$acceptance = (array) ($fixture['acceptance'] ?? []);
|
||||
$metrics = (array) ($result['metrics'] ?? []);
|
||||
if ((float) ($metrics['dimension_delta_px_max'] ?? INF) > (float) $acceptance['dimension_delta_px_max']) $errors[] = 'Dimension delta exceeds threshold.';
|
||||
if ((float) ($metrics['pixel_difference_percent_max'] ?? INF) > (float) $acceptance['pixel_difference_percent_max']) $errors[] = 'Pixel difference exceeds threshold.';
|
||||
if ((int) ($result['console']['errors'] ?? PHP_INT_MAX) > (int) $acceptance['console_errors_max']) $errors[] = 'Console contains errors.';
|
||||
if ((int) ($result['network']['unexpected_failures'] ?? PHP_INT_MAX) > (int) $acceptance['unexpected_network_failures_max']) $errors[] = 'Network contains unexpected failures.';
|
||||
if ((int) ($result['a11y']['critical'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_critical_max']) $errors[] = 'Accessibility critical findings remain.';
|
||||
if ((int) ($result['a11y']['serious'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_serious_max']) $errors[] = 'Accessibility serious findings remain.';
|
||||
if (empty($result['reduced_motion']['passed'])) $errors[] = 'Reduced-motion behavior was not verified.';
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
fwrite(STDERR, "Widget golden validation failed:\n- " . implode("\n- ", array_slice($errors, 0, 50)) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo 'Widget golden fixture passed: 37 components, 222 screenshot targets, frozen reference intact';
|
||||
echo $require_results ? ", runtime results passed.\n" : ", runtime results pending.\n";
|
||||
Reference in New Issue
Block a user