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