chore(repo): track full tests/ and tools/ suites, drop per-file whitelist
This commit is contained in:
Vendored
+7188
File diff suppressed because it is too large
Load Diff
@@ -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.');
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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('"controller"', $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";
|
||||
}
|
||||
Reference in New Issue
Block a user