chore(repo): track full tests/ and tools/ suites, drop per-file whitelist

This commit is contained in:
2026-09-21 20:53:12 +08:00
parent 0afead968a
commit 2618530afb
18 changed files with 9949 additions and 22 deletions
+2 -22
View File
@@ -11,29 +11,9 @@
/.specs/
/docs/
# tools/: 只版本化两个共享入口,其余为本地脚本。
/tools/*
!/tools/build-public-assets.mjs
!/tools/wp-cli-visual-fixtures.php
# tests/: 放行整个套件,按名字排除本地专有产物。
#
# 这里刻意从「逐文件白名单」改为「先放行目录、再逐个排除」的反向写法:
# 白名单要求每新增一个 *.test.cjs 都同步改本文件,一旦漏改,该测试文件会静默
# 不被跟踪,在 CI 上表现为「测试数量莫名变少」,且极难排查。反向写法下新增测试
# 自动纳入版本控制,只有确需本地专有的文件才需要追加排除。
#
# 下列 10 个路径是切换前就已处于「忽略且未跟踪」状态的本地专有文件,逐一保留原状。
!/tests/
/tests/visual-results/
/tests/fixtures/widget-golden-v1.json
/tests/widget-system/a11y-contract.test.cjs
/tests/widget-system/admin-core.test.cjs
/tests/widget-system/captcha-cloud-contract.test.cjs
/tests/widget-system/comment-hard-cut-contract.test.cjs
/tests/widget-system/download-config-contract.test.cjs
/tests/widget-system/run.php
/tests/widget-system/scratch-term-aggregation-harness.php
/CLAUDE.md
/AGENTS.md
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
# Widget visual results
This directory is intentionally empty until validation runs against the user-provided WordPress site.
The browser run must create the screenshot paths declared by `tests/fixtures/widget-golden-v1.json` and a `manifest.json` using schema `aksrt.widget-visual-results.v1`. Validate completed results with:
```text
php tools/validate-widget-golden.php --require-results
```
The validator requires all 222 viewport/theme screenshots, every declared interaction state, zero console errors, zero unexpected network failures, zero critical/serious accessibility findings, reduced-motion verification, a maximum 2 px component-boundary delta, and a maximum 1.5% pixel difference.
@@ -0,0 +1,28 @@
# AKSRT widget static accessibility audit
Scope: `src/WidgetSystem/Renderers/`, `assets/widgets/frontend.js`, and `assets/widgets/frontend.css`.
The source audit was repeated after remediation. Browser, screen-reader, Lighthouse/axe, console, and network validation remain pending until a target WordPress URL and browser MCP are available.
## Critical (WCAG A)
No static critical issue was found.
## Serious (WCAG AA)
No unresolved static serious issue remains.
## Resolved source findings
- **WCAG 2.1.1 Keyboard:** article and feature tabs now implement Arrow Left/Right, Home/End, focus movement, roving `tabindex`, and stable tab/panel associations.
- **WCAG 2.4.7 Focus Visible:** all widget links, buttons, inputs, selects, textareas, and explicit tabindex targets receive high-contrast light, dark, and forced-colors focus rings.
- **WCAG 2.2.2 Pause, Stop, Hide:** autoplay carousels pause on keyboard focus and expose a labeled, persistent pause/resume button; reduced-motion mode continues to disable autoplay.
- **WCAG 4.1.3 Status Messages:** AJAX containers expose `aria-live` and `aria-busy`, loading skeletons use `role="status"`, and video failure uses an assertive alert.
These contracts are enforced by `tests/widget-system/a11y-contract.test.cjs`.
## Browser verification still required
- Confirm Swiper-generated pagination bullets, disabled navigation states, and pause/resume behavior with the bundled runtime.
- Verify 200% zoom, high-contrast mode, dark-mode contrast, focus order, and focus retention after AJAX replacement.
- Run Lighthouse or axe and perform keyboard-only checks at 1440, 1024, and 390 px in light and dark modes.
@@ -0,0 +1,33 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const js = read('assets/widgets/frontend.js');
const css = read('assets/widgets/frontend.css');
const content = read('src/WidgetSystem/Renderers/ContentRenderer.php');
const knowledge = read('src/WidgetSystem/Renderers/KnowledgeProductRenderer.php');
const general = read('src/WidgetSystem/Renderers/GeneralRenderer.php');
for (const key of ['ArrowRight', 'ArrowLeft', 'Home', 'End']) assert.match(js, new RegExp(key));
assert.match(js, /setAttribute\('tabindex', tab === button \? '0' : '-1'\)/);
assert.match(js, /setAttribute\('aria-labelledby', button\.id\)/);
assert.match(content, /role="tab"[^;]+aria-controls=/);
assert.match(content, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(knowledge, /role="tab"[^;]+aria-controls=/);
assert.match(knowledge, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(css, /:focus-visible/);
assert.match(css, /forced-colors: active/);
assert.match(content, /aria-live="polite" aria-busy="true"/);
assert.match(content, /role="status"/);
assert.match(js, /setAttribute\('aria-busy', 'true'\)/);
assert.match(js, /setAttribute\('aria-busy', 'false'\)/);
assert.match(general, /role="alert" aria-live="assertive"/);
assert.match(css, /prefers-reduced-motion: reduce/);
assert.match(js, /prefers-reduced-motion: reduce/);
console.log('Widget accessibility contract tests passed: keyboard tabs, focus, live states and reduced motion.');
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const assert = require('node:assert/strict');
const core = require('../../assets/widgets/admin-core.js');
assert.deepEqual(core.parseDependency('["mode","==","cards"]'), ['mode', '==', 'cards']);
assert.deepEqual(core.parseDependency('{broken'), []);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'cards'), true);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'list'), false);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'list'), true);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'cards'), false);
assert.equal(core.dependencyVisible([], 'anything'), true);
assert.equal(
core.replaceRepeaterIndexes('widget-aksrt_widget_slider[4][slides][__index_ab12cd34__][layers][9][title]', [2, 1]),
'widget-aksrt_widget_slider[4][slides][2][layers][1][title]'
);
assert.equal(core.replaceRepeaterIndexes('plain[name]', [1]), 'plain[name]');
console.log('Widget admin core tests passed: dependency visibility and nested repeater indexing.');
@@ -0,0 +1,189 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const exists = (file) => fs.existsSync(path.join(root, file));
test('comment area styles are fully owned (hard cut from vendor main.css)', () => {
const layout = read('assets/css/frontend/comments/layout.css');
const mobile = read('assets/css/frontend/comments/mobile-item.css');
// 1:1 移植的关键布局选择器必须存在于自有文件。
const layoutSelectors = [
'#comments .comment-box .comments-closed',
'#comments #postcomments .comt-ctrl .but',
'#comment {',
'.comment-signarea {',
'#postcomments .comment-footer {',
'#postcomments .avatar-img {',
'#postcomments .children {',
'#postcomments .comt-meta > span:not(:empty)',
'#postcomments .comment-approve-link.approve {',
'#postcomments .comment-approve-link.unapprove {',
'#postcomments .comt-meta > span .comment-trash-link {',
'.comment-content {',
'#postcomments .comment-content img {',
'#comments .virtual-input {',
'.commentlist .no-more {',
];
for (const selector of layoutSelectors) {
assert.ok(layout.includes(selector), `layout.css must contain ${selector}`);
}
// 桌面隐藏必须带 #comments 前缀(id 特异性),任何后加载的
// .flex{display:flex} 单类工具规则都无法在宽屏把假输入条顶出来。
assert.equal(layout.includes('\n.virtual-input {'), false, 'desktop hide must be id-scoped, not bare .virtual-input');
// 移动端弹层 / 虚拟输入 / 640px 覆盖。
const mobileSelectors = [
'@media (max-width: 767px)',
'@media (max-width: 640px)',
'#comments .virtual-input {',
'.drop-fixed-sm .dropdown-menu,',
'.mobile-fixed {',
'#respond.mobile-fixed {',
'.drop-fixed-sm .dropdown-menu::before {',
'#comments .comt-box {',
'#postcomments .commentlist > .comment > .list-inline .comment-footer {',
];
for (const selector of mobileSelectors) {
assert.ok(mobile.includes(selector), `mobile-item.css must contain ${selector}`);
}
// 自有文件不得包含表情/快捷回复样式。
for (const [file, source] of [['layout.css', layout], ['mobile-item.css', mobile]]) {
for (const token of ['smilie', 'dropdown-quick-often', 'quick-reply-item', 'quick-reply-action']) {
assert.equal(source.includes(token), false, `${file} must not contain ${token}`);
}
}
// 构建产物 comments.css 聚合了自有规则。
const built = read('assets/css/build/comments.css');
for (const selector of ['#postcomments .comt-meta > span:not(:empty)', '.mobile-fixed {', '#comments .virtual-input {', '#comments .comt-title {']) {
assert.ok(built.includes(selector), `build/comments.css must contain ${selector}`);
}
});
test('vendor main.css no longer carries comment-area rules (no dual path)', () => {
for (const file of ['assets/css/main.css', 'assets/css/main.min.css']) {
const source = read(file);
for (const token of [
'#postcomments',
'.commentlist',
'.comt-',
'.comment-signarea',
'.comment-filter',
'.mobile-fixed',
'.drop-fixed-sm',
'#respond',
'#comments',
]) {
assert.equal(source.includes(token), false, `${file} must not contain ${token}`);
}
// 小部件与共享规则仍留在全站表。
assert.ok(source.includes('.comment-widget-item'), `${file} must keep .comment-widget-item`);
assert.ok(source.includes('.comment-mini-lists'), `${file} must keep .comment-mini-lists`);
assert.ok(source.includes('.content-after-box'), `${file} must keep .content-after-box`);
assert.ok(source.includes('.theme-pagination.pagenav'), `${file} must keep .theme-pagination.pagenav`);
}
});
test('quick reply is removed across the owned comment stack', () => {
const files = [
'assets/admin/src/schema/sections/post-list/comment.js',
'assets/css/main.css',
'assets/css/build/comments.css',
'assets/js/comment-tools.js',
'inc/helpers/aksrt-comments.php',
'inc/helpers/comments/form-parts.php',
'src/Admin/Schema/SchemaPostList.php',
'src/Core/ScriptsPart1c.php',
'src/Settings/PostDisplay.php',
];
const tokens = [
'comment_quick_s',
'comment_quick_often',
'quick_phrases',
'parse_comment_quick_phrases',
'quick-reply-item',
'dropdown-quick-often',
'input-quick',
'commentQuickPhrases',
'快捷回复',
];
for (const file of files) {
const source = read(file);
for (const token of tokens) {
assert.equal(source.includes(token), false, `${file} must not contain ${token}`);
}
}
});
test('dead vendor comment archives stay deleted', () => {
for (const file of [
'assets/js/runtime/comment.js',
'assets/js/runtime/comment.min.js',
'assets/js/runtime/input-expand.js',
'assets/js/runtime/input-expand.min.js',
]) {
assert.equal(exists(file), false, `${file} must not exist`);
}
const loader = read('assets/js/runtime/loader.js');
assert.doesNotMatch(loader, /['"](?:comment|input-expand)['"]\s*:/, 'legacy comment modules must have no loader mapping');
assert.doesNotMatch(read('assets/js/runtime/main.js'), /stubVendorCommentModule|unbindVendorAjaxLoop/);
});
test('every comment-rendering singular loads the owned build bundle', () => {
// 旧的静态聚合文件已删除:它缺 layout.css 且 @import 无版本号,
// 曾导致页面/资源页手机端 .mobile-fixed 抽屉规则缺失、表单直接内联显示。
assert.equal(exists('assets/css/frontend/comments.css'), false, 'stale frontend/comments.css aggregator must not exist');
const part1b = read('src/Core/ScriptsPart1b.php');
// 统一入口:任何渲染评论区的 singular 都加载 build 聚合。
assert.ok(
part1b.includes('is_singular() && !empty($singular_assets[\'needs_comments\'])'),
'ScriptsPart1b must enqueue the comments bundle for every comment-rendering singular'
);
assert.ok(part1b.includes('assets/css/build/'), 'comments enqueue must point at the build bundle');
// 旧的两处按需入口必须移除(否则资源页/页面回到缺规则的聚合文件)。
assert.equal(part1b.includes("$enqueue_module('comments')"), false, 'stale aggregator enqueue must be gone');
});
test('comment markup keeps the 1:1 legacy DOM contract', () => {
const item = read('inc/helpers/comments/item.php');
// 点赞按钮(legacy: zib_get_comment_like — svg + text + count)。
assert.ok(item.includes("<text></text><count>"), 'like button must render <text> before <count>');
assert.ok(item.includes('aksrt_get_comment_icon(\'like\''), 'like button must use the sprite icon');
assert.ok(item.includes('aksrt_get_comment_icon(\'topping\')'), 'topping badge must use the sprite icon');
assert.ok(item.includes('aksrt_get_comment_icon(\'approve\''), 'approve action must use the sprite icon');
assert.ok(item.includes('aksrt_get_comment_icon(\'menu_2\')'), 'action dropdown must use the sprite icon');
assert.ok(item.includes('data-toggle="tooltip"'), 'time/reply tooltip parity');
assert.equal(item.includes('fa fa-thumbs-up'), false, 'legacy fa like icon must be gone');
const comments = read('inc/helpers/aksrt-comments.php');
assert.ok(comments.includes('post_password_required() || !comments_open()'), 'closed comments must render nothing');
assert.ok(comments.includes('user_url_enabled'), 'guest url field toggle must be wired');
assert.ok(comments.includes("name=\"url\""), 'guest url input must render');
assert.ok(comments.includes("aksrt_get_comment_icon('signup', 'icon mr10')"), 'signarea signup must use the sprite icon');
assert.ok(comments.includes('aksrt_render_vip_badge'), 'comt-title must render the vip badge (legacy: zibpay_get_vip_icon)');
assert.equal(comments.includes('aksrt_render_user_identity_chips'), false, 'identity chips must not crowd the narrow comt-title');
const formParts = read('inc/helpers/comments/form-parts.php');
assert.ok(formParts.includes("case 'link':"), 'link dropdown case must exist');
assert.equal(formParts.includes("case 'quick':"), false, 'quick case must be gone');
assert.equal(formParts.includes('fa-bolt'), false, 'quick button icon must be gone');
const icons = read('inc/helpers/comments/icons.php');
assert.ok(icons.includes('function aksrt_get_comment_icon'), 'sprite icon helper must exist');
const bootstrap = read('inc/bootstrap.php');
assert.ok(bootstrap.includes("comments/icons.php"), 'bootstrap must load the icon helper');
// 设置契约:link / user_url 开关进入显示设置。
const settings = read('src/Settings/PostDisplay.php');
assert.ok(settings.includes("'link' => !empty($settings['comment_link'])"), 'toolbar link toggle');
assert.ok(settings.includes("'user_url' => !empty($settings['comment_user_url_s'])"), 'user url toggle');
});
@@ -0,0 +1,173 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('download settings section exists on both schema sides with matching defaults', () => {
const php = read('src/Admin/Schema/SchemaPermissions.php');
const vue = read('assets/admin/src/schema/sections/permissions/index.js');
// PHP side is the authoritative contract.
assert.match(php, /'download' => \[/);
assert.match(php, /'key'\s+=> 'download'/);
assert.match(php, /'option_key' => 'aksrt_download_config'/);
// Field parity: same four keys with the same defaults on both ends.
const fields = [
['enabled', 'true', 'enabled: true'],
['url_alias', "'download'", "url_alias: 'download'"],
['default_max_uses', '1', 'default_max_uses: 1'],
['ttl_minutes', '10', 'ttl_minutes: 10'],
];
for (const [key, phpDefault, vueDefault] of fields) {
const phpField = new RegExp(`'key' => '${key}'[^\\]]*'default' => ${phpDefault}`);
assert.match(php, phpField, `PHP default mismatch for ${key}`);
assert.match(vue, new RegExp(`${vueDefault}`), `Vue default mismatch for ${key}`);
}
// The link rules depend on the master switch on both ends.
assert.match(php, /'title' => '下载总开关'/);
assert.match(php, /'dependsOn' => \['key' => 'enabled', 'value' => true\]/);
assert.match(vue, /dependsOn: \{ key: 'enabled', value: true \}/);
// Vue mirrors labels so admins see the same wording as validation errors.
assert.match(vue, /download: \{/);
assert.match(vue, /下载链接 URL 别名/);
assert.match(vue, /下载链接默认使用次数/);
assert.match(vue, /下载链接默认有效期限(分钟)/);
});
test('the download settings page is registered under the permissions menu', () => {
const menu = read('assets/admin/src/constants/sections/permissions.js');
assert.match(menu, /schemaPage\(\{\s*key: 'permissions-download'/);
assert.match(menu, /route: '\/permissions\/download'/);
assert.match(menu, /canonicalDomain: 'permissions',\s*section: 'download',/);
const settings = read('src/Settings/Settings.php');
assert.match(settings, /'aksrt_download_config'/);
});
test('invalid url aliases are rejected server-side with admin-facing messages', () => {
const provider = read('src/Admin/SchemaProvider.php');
assert.match(provider, /\$domain === 'permissions' && \$section === 'download'/);
assert.match(provider, /URL 别名不能为空/);
assert.match(provider, /\/\^\[A-Za-z0-9_-\]\+\$\//);
assert.match(provider, /禁止中文、空格和 \/ \\\\ \? # & 等特殊符号/);
assert.match(provider, /reserved_download_aliases/);
assert.match(provider, /'wp-json', 'wp-admin', 'wp-content', 'wp-includes'/);
});
test('Download.php derives endpoint, ttl and max uses from configuration', () => {
const dl = read('src/Content/Download.php');
// Dead hard-coded values are gone.
assert.doesNotMatch(dl, /const ENDPOINT/);
assert.doesNotMatch(dl, /const TOKEN_TTL/);
assert.doesNotMatch(dl, /aksrt-dl/);
// Rewrite regex and URL building share the configured alias.
assert.match(dl, /preg_quote\(\$this->config\(\)\['endpoint'\], '\/'\)/);
assert.match(dl, /home_url\('\/' \. \$config\['endpoint'\] \. '\/' \. \$token\)/);
assert.match(dl, /add_action\('init', \[\$this, 'maybe_flush_rewrite'\], 99\)/);
assert.match(dl, /flush_rewrite_rules\(false\)/);
assert.match(dl, /aksrt_dl_rewrite_version/);
// TTL and per-token max uses come from the settings section.
assert.match(dl, /'expires_in'\s+=> \$config\['ttl'\]/);
assert.match(dl, /'max_uses'\s+=> \(int\) \$config\['max_uses'\]/);
});
test('usage is counted only after full delivery, never for probes', () => {
const dl = read('src/Content/Download.php');
// Probe detection covers HEAD requests and speculative prefetches, which
// validate availability but consume nothing.
assert.match(dl, /private function is_probe_request\(\): bool/);
assert.match(dl, /REQUEST_METHOD.*=== 'HEAD'/);
assert.match(dl, /HTTP_SEC_PURPOSE/);
assert.match(dl, /'prefetch'/);
assert.match(dl, /!\$is_probe\) \{\s*\$this->consume_token/);
// Local streaming splits headers from body and aborts stop consumption.
assert.match(dl, /private function send_download_headers\(string \$path\): void/);
assert.match(dl, /private function stream_body\(string \$path\): bool/);
assert.match(dl, /if \(connection_aborted\(\)\) \{/);
assert.match(dl, /return !connection_aborted\(\);/);
// Atomic cap so concurrent finishes can not overshoot max_uses.
assert.match(dl, /SET used_count = used_count \+ 1 WHERE token = %s AND used_count < max_uses/);
// Expired / exhausted links stay a dead end instead of re-issuing silently.
assert.match(dl, /status_header\(410\)/);
assert.match(dl, /请回到资源页重新获取/);
assert.doesNotMatch(dl, /fail_410\(\);\s*issue_token/);
});
test('expired links render a themed flat 410 page that follows the theme color', () => {
const dl = read('src/Content/Download.php');
// Full theme chrome instead of a bare wp_die page.
assert.match(dl, /private function render_expired_page\(\): void/);
assert.match(dl, /get_header\(\)/);
assert.match(dl, /get_footer\(\)/);
// wp_die remains only as the safety net when output already started.
assert.match(dl, /headers_sent\(\)/);
// Flat hero keyed to the runtime theme variables (light + html.dark).
assert.match(dl, /var\(--color-primary-soft/);
assert.match(dl, /var\(--color-primary/);
assert.match(dl, /var\(--key-color/);
assert.match(dl, /var\(--muted-4-color/);
// Theme button system and FA5 icon form only (never "fa fa-").
assert.match(dl, /class="btn-primary"/);
assert.match(dl, /class="btn-outline"/);
assert.match(dl, /fa fas fa-hourglass-end/);
assert.doesNotMatch(dl, /\bfa fa-/);
// The 410 page is a utility rewrite; the header component must never match
// a page type for it (custom rewrites retain the is_home flag).
const slider = read('inc/helpers/homepage/header-components.php');
assert.match(slider, /class_exists\('\\AKSRT\\Content\\Download'\)/);
assert.match(slider, /get_query_var\(\\AKSRT\\Content\\Download::QUERY_VAR\)/);
});
test('token generation retries on primary-key collisions', () => {
const dl = read('src/Content/Download.php');
assert.match(dl, /PRIMARY KEY\(token\) already guarantees uniqueness/);
assert.match(dl, /for \(\$attempt = 0; \$attempt < 3; \$attempt\+\+\)/);
});
test('master switch off puts the token system to sleep and renders direct links', () => {
const dl = read('src/Content/Download.php');
const box = read('templates/parts/resource-download-box.php');
const pay = read('inc/helpers/aksrt-pay.php');
// The switch is a public runtime check; missing key means enabled.
assert.match(dl, /public function is_enabled\(\): bool/);
assert.match(dl, /!isset\(\$cfg\['enabled'\]\) \|\| !empty\(\$cfg\['enabled'\]\)/);
// Rewrite, REST routes and token serving all bail out when disabled.
assert.match(dl, /if \(!\$this->is_enabled\(\)\) \{\s*return;\s*\}/);
assert.match(dl, /Master switch off: the token system is retired/);
// The unused policy endpoint stays removed.
assert.doesNotMatch(dl, /download\/policy/);
assert.doesNotMatch(dl, /rest_policy/);
// Direct mode renders anchors to the raw source URL instead of token buttons.
assert.match(box, /\$direct_download = class_exists\('\\AKSRT\\Content\\Download'\)/);
assert.match(box, /elseif \(\$direct_download\) \{/);
assert.match(box, /href="<\?php echo esc_url\(\$btn_href\); \?>/);
assert.match(box, /data-clipboard-text="<\?php echo esc_attr\(\$extract_code\); \?>/);
assert.match(pay, /if \(\$direct_download\) \{\s*\$buttons \.= '<a class="mr10 but /);
// Dead standalone-page helper stays removed.
assert.doesNotMatch(pay, /aksrt_is_download_alone_page_enabled/);
});
+351
View File
@@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\FormRenderer;
use AKSRT\WidgetSystem\PostQuery;
use AKSRT\WidgetSystem\RestController;
use AKSRT\WidgetSystem\SchemaWidget;
use AKSRT\WidgetSystem\WidgetDefinition;
use AKSRT\WidgetSystem\WidgetRegistry;
use AKSRT\WidgetSystem\WidgetTransfer;
use AKSRT\WidgetSystem\WidgetVisibility;
use AKSRT\WidgetSystem\WidgetSchemas;
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
use AKSRT\WidgetSystem\Renderers\GeneralRenderer;
use AKSRT\WidgetSystem\Renderers\SliderRenderer;
final class WidgetSystemTest {
private int $assertions = 0;
private array $failures = [];
public function run(): void {
$this->schema_contract();
$this->query_contract();
$this->modern_widget_contract();
$this->visibility_contract();
$this->transfer_round_trip();
$this->rest_contract();
$this->payment_states();
if ($this->failures) {
fwrite(STDERR, "Widget system tests failed:\n- " . implode("\n- ", $this->failures) . "\n");
exit(1);
}
echo "Widget system tests passed: {$this->assertions} assertions across schema, query, visibility, transfer, REST and payment states.\n";
}
private function schema_contract(): void {
$fields = [
['id' => 'mode', 'type' => 'select', 'default' => 'cards', 'options' => ['cards' => 'Cards', 'list' => 'List']],
['id' => 'count', 'type' => 'slider', 'default' => 3, 'min' => 1, 'max' => 8],
['id' => 'ids', 'type' => 'text', 'sanitize' => 'ids'],
['id' => 'link', 'type' => 'link'],
['id' => 'layout', 'type' => 'fieldset', 'fields' => [
['id' => 'enabled', 'type' => 'switcher', 'default' => false],
['id' => 'gap', 'type' => 'number', 'default' => 8, 'min' => 0, 'max' => 40],
]],
['id' => 'rows', 'type' => 'group', 'max_items' => 2, 'fields' => [
['id' => 'title', 'type' => 'text'],
['id' => 'tone', 'type' => 'radio', 'default' => 'blue', 'options' => ['blue' => 'Blue', 'red' => 'Red']],
]],
];
$defaults = FieldSchema::defaults($fields);
$this->same('cards', $defaults['mode'], 'schema defaults preserve enums');
$this->same(['enabled' => false, 'gap' => 8], $defaults['layout'], 'nested defaults are recursive');
$normalized = FieldSchema::normalize($fields, [
'mode' => 'invalid', 'count' => 99, 'ids' => '2, 2,7 bad -4',
'link' => ['url' => 'javascript:alert(1)', 'text' => '<b>课程</b>', 'target' => 'popup'],
'layout' => ['enabled' => 'yes', 'gap' => -9, 'unknown' => 'drop'],
'rows' => [
['title' => '<script>x</script>第一项', 'tone' => 'red'],
['title' => '第二项', 'tone' => 'invalid'],
['title' => '第三项'],
],
]);
$this->same('cards', $normalized['mode'], 'invalid enum falls back');
$this->same(8, $normalized['count'], 'numeric value clamps to maximum');
$this->same('2,7,4', $normalized['ids'], 'ID list is unique and normalized');
$this->same(['url' => '', 'text' => '课程', 'target' => ''], $normalized['link'], 'link parts sanitize independently');
$this->same(['enabled' => true, 'gap' => 0], $normalized['layout'], 'nested values clamp and drop unknown keys');
$this->same(2, count($normalized['rows']), 'group max_items is enforced');
$this->same('blue', $normalized['rows'][1]['tone'], 'nested enum fallback is enforced');
$definition = new WidgetDefinition([
'slug' => 'dependency_test', 'name' => 'Dependency Test',
'common' => ['title' => false, 'affix' => false, 'visibility' => false, 'animation' => false, 'layout' => false],
'fields' => [
['id' => 'controller', 'type' => 'select', 'default' => 'off', 'options' => ['off' => 'Off', 'on' => 'On']],
['id' => 'dependent', 'type' => 'text', 'dependency' => ['controller', '==', 'on']],
],
]);
$widget = new SchemaWidget($definition);
ob_start();
$widget->render_full_form(['controller' => 'on', 'dependent' => 'visible']);
$html = (string) ob_get_clean();
$this->contains('data-dependency=', $html, 'form exports dependency rule');
$this->contains('&quot;controller&quot;', $html, 'dependency JSON is safely escaped');
}
private function query_contract(): void {
$args = PostQuery::arguments([
'count' => 99, 'category_ids' => '2,5', 'category_id' => 9, 'tag_ids' => '8,13',
'limit_day' => 14, 'orderby' => 'like', 'order' => 'asc', 'pay_type' => 'gallery',
], 0);
$this->same(50, $args['posts_per_page'], 'query count clamps');
$this->same(1, $args['paged'], 'query page clamps');
$this->same([9], $args['category__in'], 'segment category overrides category list');
$this->same([8, 13], $args['tag__in'], 'query tag filter is normalized');
$this->same('ASC', $args['order'], 'query order allowlist is applied');
$this->same('meta_value_num', $args['orderby'], 'metric ordering uses numeric meta');
$this->contains('gallery', json_encode($args['meta_query']), 'DomainModel pay type reaches meta query');
$reference = PostQuery::arguments([
'count' => 6, 'cat' => '1, -2,3 bad',
'orderby' => 'views', 'order' => 'DESC', 'limit_day' => 7, 'paginate' => '',
], 3);
$this->same([1, 3], $reference['category__in'], 'reference cat includes positive IDs');
$this->same([2], $reference['category__not_in'], 'reference cat excludes negative IDs');
$this->false(isset($reference['tax_query']), 'topic filters are removed with the topics feature');
$this->same('aksrt_post_views', $reference['meta_key'], 'reference view ordering uses numeric view meta');
$this->false(isset($reference['meta_query']), 'modern reference queries do not apply commerce filters by default');
$this->same(1, $reference['paged'], 'no-pagination queries stay on first page');
$paywalled = PostQuery::arguments(['cat' => '1', 'zibpay_type' => ['1', '6'], 'paginate' => ''], 1, ['cat' => '1']);
$this->contains('reading', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters map to pay meta');
$this->contains('video', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters cover every selected type');
$invalid = PostQuery::arguments(['cat' => '', 'orderby' => 'drop table'], 1, ['cat' => '']);
$this->false(isset($invalid['meta_query']), 'modern reference fields never add commerce filters');
$this->same('date', $invalid['orderby'], 'unknown orderby falls back');
$tabs = ['tabs' => [['title' => 'A', 'category_id' => 2], ['title' => 'B', 'category_id' => 3]]];
$this->same(3, PostQuery::segment($tabs, '1')['category_id'], 'valid tab segment resolves');
$this->same(2, PostQuery::segment($tabs, '99')['category_id'], 'invalid tab segment falls back to first');
}
private function modern_widget_contract(): void {
$tab_fields = WidgetSchemas::for('tab_posts');
$tab_defaults = FieldSchema::defaults($tab_fields);
$this->same('mini', $tab_defaults['style'], 'modern tabs default to mini');
$this->same(6, $tab_defaults['count'], 'modern tabs default to six posts');
$this->same(['热门推荐', '最近更新', '猜你喜欢'], array_column($tab_defaults['tabs'], 'title'), 'modern tab defaults match reference ordering');
$tab = FieldSchema::normalize($tab_fields, ['tabs' => [[
'title' => '<b>栏目</b><script>x</script>', 'cat' => '1,-2 3', 'zibpay_type' => ['1', '6'],
'order' => 'asc',
]]]);
$this->same('1,-2,3', $tab['tabs'][0]['cat'], 'tab cat keeps signed reference IDs');
$this->false(isset($tab['tabs'][0]['topics']), 'topic filters are gone from tab configuration');
$this->same(['1', '6'], $tab['tabs'][0]['zibpay_type'], 'tab commerce filters are preserved');
$this->contains('<b>栏目</b>', $tab['tabs'][0]['title'], 'tab title keeps controlled HTML');
$this->false(str_contains($tab['tabs'][0]['title'], '<script>'), 'tab title strips unsafe HTML');
$slider_defaults = FieldSchema::defaults(WidgetSchemas::for('slider'));
$this->true($slider_defaults['option']['button'], 'modern slider defaults to navigation buttons');
$this->true($slider_defaults['option']['pagination'], 'modern slider defaults to pagination');
$this->same(240, $slider_defaults['option']['m_height'], 'modern slider keeps reference mobile height default');
$this->same(2, count($slider_defaults['slides'][0]['image_layer']), 'modern slider ships the two reference layer assets');
$single = SliderRenderer::render('slider', ['slides' => [['background' => 'https://example.test/a.jpg', 'text' => ['title' => '<b>A</b>']]], 'option' => ['button' => true, 'pagination' => true]]);
$this->contains('new-swiper slide-widget mb20', $single, 'standalone slider emits reference wrapper');
$this->contains('swiper-container-initialized', $single, 'single slider is marked initialized');
$this->false(str_contains($single, 'swiper-button-prev'), 'single slider suppresses buttons');
$this->false(str_contains($single, 'swiper-pagination'), 'single slider suppresses pagination');
$multi = SliderRenderer::render('slider', ['slides' => [['background' => 'https://example.test/a.jpg'], ['background' => 'https://example.test/b.jpg']], 'option' => ['button' => true, 'pagination' => true]]);
$this->contains('swiper-button-prev', $multi, 'multi slider emits buttons');
$this->contains('swiper-pagination kaoyou', $multi, 'multi slider emits reference pagination');
$combo_defaults = FieldSchema::defaults(WidgetSchemas::for('slider_media_combo'));
$this->same(4, count($combo_defaults['covers']), 'combo defaults to four graphic covers');
$this->false(isset($combo_defaults['side_cards']), 'combo no longer exposes legacy side cards');
$combo = SliderRenderer::render('slider_media_combo', FieldSchema::normalize(WidgetSchemas::for('slider_media_combo'), [
'slides' => [['background' => 'https://example.test/slide.jpg', 'text' => ['title' => '幻灯片一']]],
'covers' => [
['image' => 'https://example.test/a.jpg', 'title' => '卡片一'],
['image' => 'https://example.test/b.jpg'],
],
]));
$this->contains('widget-slider-graphic m-vertical', $combo, 'combo emits reference wrapper');
$this->contains('new-swiper slide-widget scale-height', $combo, 'combo slider keeps reference classes');
$this->contains('<span><img class="radius8 lazyload swiper-lazy"', $combo, 'combo slide keeps span wrapper and lazy contract');
$this->contains('cover-col mb10', $combo, 'combo emits reference cover column');
$this->contains('widget-graphic-cover mb10', $combo, 'combo cover keeps reference wrapper');
$this->contains('col-sm-6 col-xs-6', $combo, 'combo emits reference grid columns');
$this->contains('graphic-text this-font', $combo, 'combo emits reference cover text');
$this->contains('--cover-ratio:72%', $combo, 'combo derives cover ratio from layout options');
$this->same('', SliderRenderer::render('slider_media_combo', []), 'combo renders nothing without content');
$this->same(
preg_match_all('/<div\b/', $combo),
substr_count($combo, '</div>'),
'combo markup is div-balanced (cover-col must be a sibling of the slider)'
);
$this->same(preg_match_all('/<div\b/', $single), substr_count($single, '</div>'), 'slider markup is div-balanced');
$this->same(preg_match_all('/<div\b/', $multi), substr_count($multi, '</div>'), 'multi slider markup is div-balanced');
$button_defaults = FieldSchema::defaults(WidgetSchemas::for('buttons'));
$this->same('large', $button_defaults['pc_padding'], 'button group keeps reference PC size');
$this->same('medium', $button_defaults['m_padding'], 'button group keeps reference mobile size');
$this->same(12, $button_defaults['pc_gap'], 'button group keeps reference PC spacing');
$this->same(6, $button_defaults['m_gap'], 'button group keeps reference mobile spacing');
$buttons = GeneralRenderer::render('buttons', FieldSchema::normalize(WidgetSchemas::for('buttons'), ['items' => [[
'text' => '<b>开始</b>', 'icon_left' => 'fa-play', 'icon_right' => 'fa-angle-right',
'link' => ['url' => 'https://example.test/start', 'target' => '_blank'], 'color' => 'jb-blue',
]]]));
$this->contains('widget-buttons mb20 pc-size-large m-size-medium', $buttons, 'button group emits reference wrapper');
$this->contains('widget-buttons-wrap flex hh align-center', $buttons, 'button group emits reference flex contract');
$this->contains('开始', $buttons, 'button text is rendered');
$notice_defaults = FieldSchema::defaults(WidgetSchemas::for('notice'));
$this->same(3, $notice_defaults['interval'], 'notice keeps reference interval');
$this->same('c-blue', $notice_defaults['color'], 'notice keeps reference color');
$this->same(2, count($notice_defaults['img_ids']), 'notice ships reference custom messages');
$notice = GeneralRenderer::render('notice', $notice_defaults);
$this->contains('swiper-bulletin mb20 c-blue main-radius', $notice, 'notice emits reference surface classes');
$this->contains('data-direction="vertical" data-loop="true" data-autoplay="1"', $notice, 'notice emits reference swiper options');
$posts_defaults = FieldSchema::defaults(WidgetSchemas::for('posts'));
$this->same('ajax', $posts_defaults['load_mode'], 'posts default to lazy ajax loading');
$this->same(12, $posts_defaults['count'], 'posts default count matches reference');
$this->same('', $posts_defaults['paginate'], 'posts default to no pager');
$this->same('list', $posts_defaults['style'], 'posts default to list style');
$oneline_defaults = FieldSchema::defaults(WidgetSchemas::for('oneline_posts'));
$this->same('card', $oneline_defaults['style'], 'oneline posts default to card style');
$this->false(isset($oneline_defaults['paginate']), 'oneline posts never paginate');
$hot_defaults = FieldSchema::defaults(WidgetSchemas::for('hot_posts'));
$this->same('views', $hot_defaults['orderby'], 'hot posts rank by views by default');
$this->same(6, $hot_defaults['count'], 'hot posts default count matches reference');
$term_cards_defaults = FieldSchema::defaults(WidgetSchemas::for('term_cards'));
$this->same('style-1', $term_cards_defaults['type'], 'term cards default to the simple style');
$this->same('2', $term_cards_defaults['pc_row'], 'term cards default to two per row');
$this->same(60, $term_cards_defaults['height_scale'], 'term cards keep reference cover ratio');
$term_aggregation_defaults = FieldSchema::defaults(WidgetSchemas::for('term_aggregation'));
$this->same('modified', $term_aggregation_defaults['orderby'], 'category aggregation orders by modified');
$this->same(4, $term_aggregation_defaults['count'], 'category aggregation keeps reference post count');
$media_defaults = FieldSchema::defaults(WidgetSchemas::for('media_cards'));
$this->same('4', $media_defaults['pc_row'], 'graphic covers default to four per row');
$this->same(30, $media_defaults['height_scale'], 'graphic covers keep reference ratio');
$this->same([], $media_defaults['covers'], 'graphic covers ship empty content');
$partners_defaults = FieldSchema::defaults(WidgetSchemas::for('partners'));
$this->same('3', $partners_defaults['rows'], 'partners default to three rows');
$this->same('card', $partners_defaults['style_type'], 'partners default to card style');
$this->same('link', $partners_defaults['link_type'], 'partners read from link manager by default');
$combo_defaults_info = FieldSchema::defaults(WidgetSchemas::for('combo_info'));
$this->same('立即体验', $combo_defaults_info['cta_text'], 'combo info keeps reference CTA copy');
$this->same('auto', $combo_defaults_info['cards'][0]['band_color'] ?? 'auto', 'combo info defaults to auto band color');
$comments_defaults = FieldSchema::defaults(WidgetSchemas::for('recent_comments'));
$this->same(8, $comments_defaults['limit'], 'recent comments default count matches reference');
$this->same('', $comments_defaults['style'], 'recent comments default to compact style');
$toc_defaults = FieldSchema::defaults(WidgetSchemas::for('posts_toc'));
$this->same('文章目录', $toc_defaults['title'], 'posts toc keeps reference default title');
$this->false(isset($toc_defaults['min_headings']), 'posts toc drops legacy heading options');
}
private function visibility_contract(): void {
$this->true(WidgetVisibility::should_render([], 0, false), 'unrestricted widget renders');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 8, true), 'show list matches singular ID');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 4, true), 'show list rejects other IDs');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 0, false), 'show list rejects archives');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 3, true), 'hide list suppresses matching ID');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 0, false), 'hide list keeps archives');
$this->same('aksrt-widget--only-pc', WidgetVisibility::device_class(['show_type' => 'only_pc']), 'PC device class is stable');
$this->same('aksrt-widget--only-mobile', WidgetVisibility::device_class(['show_type' => 'only_sm']), 'mobile device class is stable');
$this->same('', WidgetVisibility::device_class(['show_type' => 'invalid']), 'invalid device rule adds no class');
}
private function transfer_round_trip(): void {
aksrt_test_reset_state();
$GLOBALS['wp_registered_sidebars'] = ['home_sidebar' => [], 'single_sidebar' => []];
update_option('widget_aksrt_widget_posts', [2 => ['title' => '<b>课程列表</b>', 'count' => 999, 'style' => 'mini'], '_multiwidget' => 1]);
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_sidebar' => ['text-1', 'aksrt_widget_posts-2']];
$payload = WidgetTransfer::export('home_sidebar');
$this->same(WidgetTransfer::FORMAT, $payload['format'], 'export format is versioned');
$this->same(1, count($payload['sidebars']['home_sidebar']), 'export ignores non-schema widgets');
$this->same(20, $payload['sidebars']['home_sidebar'][0]['settings']['count'], 'export normalizes saved settings');
update_option('widget_aksrt_widget_posts', []);
$GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'] = ['text-9'];
$result = WidgetTransfer::import($payload, 'append', 'single_sidebar');
$this->same(['sidebars' => 1, 'widgets' => 1], $result, 'append import reports changes');
$this->same(['text-9', 'aksrt_widget_posts-1'], $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], 'append import preserves existing widgets');
$round_trip = WidgetTransfer::export('single_sidebar');
$this->same($payload['sidebars']['home_sidebar'][0], $round_trip['sidebars']['single_sidebar'][0], 'export/import round trip preserves normalized module');
$replacement = WidgetTransfer::template_payload('links_navigation');
$result = WidgetTransfer::import($replacement, 'replace', 'single_sidebar');
$this->same(3, $result['widgets'], 'targeted template import combines template areas');
$this->false(in_array('text-9', $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], true), 'replace import removes previous assignment');
try {
WidgetTransfer::import(['format' => 'legacy.widgets', 'sidebars' => []]);
$this->failures[] = 'transfer rejects legacy formats: no exception thrown';
} catch (InvalidArgumentException $error) {
$this->contains(WidgetTransfer::FORMAT, $error->getMessage(), 'legacy import rejection names accepted format');
}
}
private function rest_contract(): void {
aksrt_test_reset_state();
$controller = new RestController();
$controller->register_routes();
$this->same(2, count($GLOBALS['aksrt_test_state']['routes']), 'REST registers items and quote routes');
$route = $GLOBALS['aksrt_test_state']['routes']['aksrt/v1/widgets/(?P<widget>[a-z][a-z0-9_]*)/items'];
$this->same('__return_true', $route['permission_callback'], 'public items route is read-only accessible');
$this->true($controller->validate_widget('posts'), 'known widget validates');
$this->false($controller->validate_widget('shop_products'), 'unknown/shop widget is rejected');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1, 'query' => 'arbitrary']));
$this->error('aksrt_widget_unknown_parameter', $error, 'REST rejects arbitrary query parameters');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'search', 'instance' => 'aksrt_widget_search-1', 'page' => 1]));
$this->error('aksrt_widget_items_unsupported', $error, 'REST rejects non-dynamic widgets');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1]));
$this->error('aksrt_widget_instance_not_registered', $error, 'REST requires an assigned instance');
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_top_content' => ['aksrt_widget_posts-1']];
update_option('widget_aksrt_widget_posts', [1 => ['title' => 'Saved only', 'count' => 2, 'style' => 'list'], '_multiwidget' => 1]);
$response = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 2, 'segment' => '']));
$this->true($response instanceof WP_REST_Response, 'registered instance returns REST response');
$data = $response->get_data();
$this->true($data['success'], 'REST success envelope is stable');
$this->same(2, $data['page'], 'REST page reaches saved-query pipeline');
$this->contains('暂无内容', $data['html'], 'REST returns deterministic empty state');
$this->same(4, end($GLOBALS['aksrt_test_state']['query_args'])['posts_per_page'], 'REST reloads saved normalized configuration');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_hot_posts-1', 'page' => 1]));
$this->error('aksrt_widget_invalid_instance', $error, 'REST binds instance ID to requested widget');
}
private function payment_states(): void {
aksrt_test_reset_state();
$GLOBALS['aksrt_test_state']['current_post'] = new WP_Post(77, 5);
$method = new ReflectionMethod(ContentRenderer::class, 'post_pay');
$render = static fn(): string => (string) $method->invoke(null, ['theme' => 'jb-red']);
$GLOBALS['aksrt_test_state']['pay_meta'] = ['pay_type' => 'no', 'price' => 0];
$this->same('', $render(), 'unconfigured article renders nothing and aborts the widget');
$authorized = [
'free' => ['pay_type' => 'reading', 'price' => 0],
'purchased' => ['pay_type' => 'reading', 'price' => 29],
'vip' => ['pay_type' => 'vip', 'price' => 0],
];
foreach ($authorized as $label => $meta) {
$GLOBALS['aksrt_test_state']['pay_meta'] = $meta;
$GLOBALS['aksrt_test_state']['paid_access'] = true;
$this->contains('内容已解锁', $render(), $label . ' authorization delegates to existing access service');
}
$GLOBALS['aksrt_test_state']['pay_meta'] = ['pay_type' => 'video', 'price' => 49];
$GLOBALS['aksrt_test_state']['paid_access'] = false;
$GLOBALS['aksrt_test_state']['logged_in'] = false;
$this->contains('guest-locked', $render(), 'guest unauthorized state delegates to paywall');
$GLOBALS['aksrt_test_state']['logged_in'] = true;
$this->contains('member-locked', $render(), 'logged-in unauthorized state delegates to paywall');
}
private function same($expected, $actual, string $message): void {
$this->assertions++;
if ($expected !== $actual) $this->failures[] = $message . ' (expected ' . var_export($expected, true) . ', got ' . var_export($actual, true) . ')';
}
private function true($actual, string $message): void { $this->same(true, (bool) $actual, $message); }
private function false($actual, string $message): void { $this->same(false, (bool) $actual, $message); }
private function contains(string $needle, ?string $haystack, string $message): void {
$this->assertions++;
if ($haystack === null || !str_contains($haystack, $needle)) $this->failures[] = $message . ' (missing ' . $needle . ')';
}
private function error(string $code, $value, string $message): void {
$this->assertions++;
if (!$value instanceof WP_Error || $value->get_error_code() !== $code) $this->failures[] = $message . ' (expected error ' . $code . ')';
}
}
(new WidgetSystemTest())->run();
@@ -0,0 +1,322 @@
<?php
/**
* Scratch harness: render term_aggregation with a fuller WP stub surface
* to reproduce the "widget does not work" report. Local-only, not tracked.
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
}
if (!defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
error_reporting(E_ALL);
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
class WP_Error {
public function __construct(private string $code = '', private string $message = '') {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
}
class WP_Post {
public int $ID;
public function __construct(int $id) { $this->ID = $id; }
}
class WP_Term {
public int $term_id;
public string $name = '';
public string $description = '';
public string $taxonomy = 'category';
public int $term_taxonomy_id;
public function __construct(int $term_id, string $name, string $description = '', string $taxonomy = 'category') {
$this->term_id = $term_id;
$this->name = $name;
$this->description = $description;
$this->taxonomy = $taxonomy;
$this->term_taxonomy_id = $term_id;
}
}
class WP_User {
public int $ID;
public string $display_name;
public function __construct(int $id, string $name) { $this->ID = $id; $this->display_name = $name; }
}
class wpdb {
public string $postmeta = 'wp_postmeta';
public string $posts = 'wp_posts';
public string $term_relationships = 'wp_term_relationships';
public function prepare(string $query, ...$args): string {
foreach ($args as $arg) {
$query = preg_replace('/%[sd]/', is_numeric($arg) ? (string) (int) $arg : "'" . (string) $arg . "'", $query, 1);
}
return $query;
}
public function get_var(string $query): ?string {
unset($query);
return '12345';
}
}
$GLOBALS['wpdb'] = new wpdb();
// Fake posts the stub WP_Query will serve, keyed by expected cat.
$GLOBALS['aksrt_harness_posts'] = [
['id' => 101, 'title' => '第一篇文章', 'cat' => 5],
['id' => 102, 'title' => '第二篇文章', 'cat' => 5],
['id' => 103, 'title' => '第三篇文章', 'cat' => 5],
['id' => 104, 'title' => '第四篇文章', 'cat' => 5],
];
$GLOBALS['aksrt_harness_cursor'] = 0;
class WP_Query {
public array $posts = [];
public int $found_posts = 0;
public function __construct(public array $query_args = []) {
$cat_in = (array) ($query_args['category__in'] ?? []);
$matched = array_values(array_filter($GLOBALS['aksrt_harness_posts'], static fn(array $p): bool => in_array($p['cat'], $cat_in, true)));
$this->found_posts = count($matched);
foreach ($matched as $post) {
$this->posts[] = new WP_Post($post['id']);
$GLOBALS['aksrt_harness_lookup'][$post['id']] = $post;
}
}
public function have_posts(): bool { return $GLOBALS['aksrt_harness_cursor'] < count($this->posts); }
public function the_post(): void {
$post = $this->posts[$GLOBALS['aksrt_harness_cursor']];
$GLOBALS['aksrt_harness_cursor']++;
$GLOBALS['aksrt_harness_current_post'] = $GLOBALS['aksrt_harness_lookup'][$post->ID] ?? ['id' => $post->ID, 'title' => '文章' . $post->ID];
}
}
function __($text, $domain = null): string { unset($domain); return (string) $text; }
function esc_html__($text, $domain = null): string { return esc_html(__($text, $domain)); }
function esc_attr__($text, $domain = null): string { return esc_attr(__($text, $domain)); }
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_attr($value): string { return esc_html($value); }
function esc_url_raw($value): string {
$value = trim((string) $value);
return preg_match('#^(https?://|/)#', $value) ? $value : '';
}
function esc_url($value): string { return esc_attr(esc_url_raw($value)); }
function absint($value): int { return abs((int) $value); }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_html_class($value): string { return preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value) ?? ''; }
function wp_parse_args($args, array $defaults = []): array { return array_merge($defaults, is_array($args) ? $args : []); }
function wp_kses($value, array $allowed): string {
unset($allowed);
return strip_tags((string) $value, '<i><span><item><b><strong><em><badge><div>');
}
function wp_kses_post($value): string { return strip_tags((string) $value, '<a><b><br><p><span>'); }
function wp_strip_all_tags($value): string { return trim(strip_tags((string) $value)); }
function wp_trim_words($text, int $count = 55, string $more = '…'): string {
$words = preg_split('/\s+/', trim((string) $text)) ?: [];
if (count($words) <= $count) return implode(' ', $words);
return implode(' ', array_slice($words, 0, $count)) . $more;
}
function wp_is_mobile(): bool { return false; }
function wp_reset_postdata(): void {}
function is_wp_error($value): bool { return $value instanceof WP_Error; }
function is_super_admin(): bool { return false; }
function is_sticky($id = 0): bool { unset($id); return false; }
function current_time(string $type): int { return $type === 'timestamp' ? time() : 0; }
function human_time_diff(int $from, int $to = 0): string {
unset($to);
return (($from % 3600) + 1) . ' 小时';
}
function get_avatar_url($id, array $args = []): string { return 'https://example.test/avatar-' . (int) $id . '.jpg'; }
function get_post_meta($id, $key = '', $single = false) { unset($id, $key, $single); return ''; }
function get_term_meta(int $term_id, string $key, bool $single = false) {
if ($key === 'aksrt_category_cover_id') {
return $term_id === 5 ? 55 : 0;
}
return '';
}
function wp_get_attachment_url(int $id): string { return $id === 55 ? 'https://example.test/cover-cat-5.jpg' : ''; }
function get_posts(array $args): array {
unset($args);
return [102];
}
function get_the_post_thumbnail_url($post = null, string $size = 'post-thumbnail') {
unset($post, $size);
return 'https://example.test/thumb-cat-8.jpg';
}
function get_terms(array $args): array {
unset($args);
return [new WP_Term(5, '技术分享', '这里是分类描述文本'), new WP_Term(8, '生活记录', '')];
}
function get_term(int $term_id, string $taxonomy = '') {
unset($taxonomy);
return match ($term_id) {
5 => new WP_Term(5, '技术分享', '这里是分类描述文本'),
8 => new WP_Term(8, '生活记录', ''),
default => new WP_Error('invalid_term', 'Term not found'),
};
}
function get_term_link(WP_Term $term) {
return 'https://example.test/cat-' . $term->term_id;
}
function get_the_ID(): int { return (int) ($GLOBALS['aksrt_harness_current_post']['id'] ?? 0); }
function get_the_title($id = 0): string {
unset($id);
return (string) ($GLOBALS['aksrt_harness_current_post']['title'] ?? '');
}
function get_permalink($id = 0): string { return 'https://example.test/post-' . (int) ($id ?: get_the_ID()); }
function get_the_excerpt($id = 0): string { unset($id); return '这是摘要'; }
function get_post_field($field, $id = 0, $context = 'display') {
unset($context);
return $field === 'post_author' ? 1 : '';
}
function get_userdata($id): ?WP_User { return $id > 0 ? new WP_User((int) $id, '作者' . (int) $id) : null; }
function get_the_time($format, $id = 0): string { unset($format, $id); return '2026-09-01 10:00:00'; }
function get_the_modified_time($format, $id = 0): string { unset($format, $id); return '2026-09-04 10:00:00'; }
function get_the_category($id = 0): array { unset($id); return [new WP_Term(5, '技术分享')]; }
function get_the_tags($id = 0) { unset($id); return []; }
function get_comments_number($id = 0): int { unset($id); return 3; }
function get_comments_link($id = 0): string { return get_permalink($id) . '#comments'; }
function comments_open($id = 0): bool { unset($id); return true; }
function get_post_type($id = 0): string { unset($id); return 'post'; }
function has_post_format($formats, $id = 0): bool { unset($formats, $id); return false; }
function aksrt_get_pay_meta($post = null): array { unset($post); return ['pay_type' => 'no', 'price' => 0]; }
function aksrt_get_post_views($id = 0): int { return (int) ($id % 90) + 10; }
function aksrt_get_post_likes($id = 0): int { return (int) ($id % 7); }
function aksrt_get_post_favorites($id = 0): int { return (int) ($id % 5); }
function aksrt_resource_card_badge_text($id = 0): string { unset($id); return ''; }
function aksrt_format_number($num): string { return (string) $num; }
function aksrt_is_lazy(string $key, bool $default = false): bool { unset($key); return $default; }
function aksrt_get_lazy_thumb(string $size = ''): string { unset($size); return 'https://example.test/lazy.svg'; }
function aksrt_get_lazy_attr(string $type, string $src, string $class = '', string $lazy_src = ''): string {
$url = $lazy_src !== '' ? $lazy_src : $src;
return ' class="' . esc_attr($class) . '" src="' . esc_url($url) . '" data-src="' . esc_url($src) . '" data-lazy-type="' . esc_attr($type) . '"';
}
function aksrt_renderer_resolve_images($post_id, array $cfg): array {
unset($post_id, $cfg);
return ['primary' => '', 'has_real' => false, 'gallery' => [], 'slides' => [], 'is_slide' => false, 'video_url' => ''];
}
// --- full pipeline stubs (AssetManager / SchemaWidget / registry) ---
function wp_register_style(...$args): void { unset($args); }
function wp_register_script(...$args): void { unset($args); }
function wp_enqueue_style(...$args): void { unset($args); }
function wp_enqueue_script(...$args): void { unset($args); }
function wp_add_inline_script(...$args): void { unset($args); }
function rest_url(string $path = ''): string { return 'https://example.test/wp-json/' . $path; }
function get_template_directory(): string { return dirname(__DIR__, 2); }
function get_template_directory_uri(): string { return 'https://example.test/theme'; }
function wp_json_encode($value): string { return (string) json_encode($value, JSON_UNESCAPED_UNICODE); }
function esc_textarea($value): string { return esc_html($value); }
function checked($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'checked="checked"' : '';
if ($display) echo $result;
return $result;
}
function selected($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'selected="selected"' : '';
if ($display) echo $result;
return $result;
}
class WP_Widget {
public string $id_base = '';
public string $id = '';
public int $number = 0;
public function __construct(string $id_base = '') { $this->id_base = $id_base; }
public function get_field_name(string $field): string { return 'widget-' . $this->id_base . '[' . $this->number . '][' . $field . ']'; }
public function get_field_id(string $field): string { return 'widget-' . $this->id_base . '-' . $this->number . '-' . $field; }
public function _set(int $number): void { $this->number = $number; $this->id = $this->id_base . '-' . $number; }
}
require dirname(__DIR__, 2) . '/vendor/autoload.php';
require dirname(__DIR__, 2) . '/inc/helpers/aksrt-graphic-card.php';
require dirname(__DIR__, 2) . '/inc/helpers/aksrt-widgets-markup.php';
require dirname(__DIR__, 2) . '/inc/helpers/posts-renderer/post-data.php';
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
$instance = [
'term_id' => [5, 8],
'pc_row' => 2,
'orderby' => 'modified',
'order' => 'desc',
'count' => 4,
'target_blank' => false,
'obs_animation' => '',
'animation_repeat' => false,
];
try {
$html = ContentRenderer::render('term_aggregation', $instance);
echo "=== RENDER OK ===\n";
echo $html;
echo "\n=== END ===\n";
} catch (Throwable $e) {
echo '=== FATAL: ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . "\n";
echo $e->getTraceAsString() . "\n";
}
// Single-term variant (is_row=false) + animation variant.
try {
$GLOBALS['aksrt_harness_cursor'] = 0;
$single = ContentRenderer::render('term_aggregation', array_merge($instance, ['term_id' => [5], 'obs_animation' => 'fade']));
echo "=== SINGLE OK (len=" . strlen($single) . ") ===\n";
echo $single . "\n";
} catch (Throwable $e) {
echo '=== SINGLE FATAL: ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . "\n";
}
// Full pipeline: registry -> definition -> normalize(raw form input '5,8') -> FrontendRenderer.
try {
$definitions = \AKSRT\WidgetSystem\WidgetRegistry::all();
echo "=== REGISTRY: term_aggregation present = " . (isset($definitions['term_aggregation']) ? 'yes' : 'NO') . " ===\n";
$definition = $definitions['term_aggregation'];
$widget = new \AKSRT\WidgetSystem\SchemaWidget($definition);
$widget->_set(2);
$normalized = $definition->normalize(['term_id' => '5,8']);
echo "=== NORMALIZED term_id = " . json_encode($normalized['term_id']) . " ===\n";
$GLOBALS['aksrt_harness_cursor'] = 0;
ob_start();
\AKSRT\WidgetSystem\FrontendRenderer::render(
$definition,
['before_widget' => '<section class="w">', 'after_widget' => '</section>'],
$normalized,
$widget
);
$full = (string) ob_get_clean();
echo "=== FULL PATH OK (len=" . strlen($full) . ") ===\n";
echo $full . "\n";
// Empty selection must render nothing at all.
ob_start();
\AKSRT\WidgetSystem\FrontendRenderer::render($definition, ['before_widget' => '<section>', 'after_widget' => '</section>'], $definition->normalize([]), $widget);
$empty = (string) ob_get_clean();
echo "=== EMPTY SELECTION OUTPUT len=" . strlen($empty) . " ===\n";
// Admin form HTML for the terms field (what the widgets screen renders).
ob_start();
$widget->render_full_form($definition->normalize(['term_id' => '5,8']));
$form = (string) ob_get_clean();
if (preg_match('/<div class="aksrt-widget-field aksrt-widget-field--terms".*?<\/div>\s*<\/div>\s*<\/div>/s', $form, $m)) {
echo "=== TERMS FIELD HTML ===\n" . $m[0] . "\n";
}
echo "=== FORM contains hidden terms value name = " . (strpos($form, 'name="widget-aksrt_widget_term_aggregation[2][term_id]"') !== false ? 'yes' : 'NO') . " ===\n";
echo "=== FORM hidden value = ";
preg_match('/class="aksrt-widget-terms__value"[^>]*value="([^"]*)"/', $form, $v);
echo ($v[1] ?? '(not found)') . " ===\n";
} catch (Throwable $e) {
echo '=== FULL PATH FATAL: ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() . "\n";
echo $e->getTraceAsString() . "\n";
}
+50
View File
@@ -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`
+271
View File
@@ -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"
]
}
}
+61
View File
@@ -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
+529
View File
@@ -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);
+320
View File
@@ -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";
+229
View File
@@ -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";
+102
View File
@@ -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');
+70
View File
@@ -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";