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
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";
}