fix(theme): align reference layouts and restore passing contracts
Align homepage and category discovery, modern widgets, admin controls, quotes, and comments with the reference theme. Remove superseded paths and add rendering and behavior coverage. Update stale asset and finance contracts; all 760 Node tests and 139 widget assertions pass.
This commit is contained in:
1 parent
b520d29368
commit
8aa116ec43
63 files changed
+1985
-2304
No files matched your search
@@ -21,7 +21,7 @@ const termMeta = read('src/Core/TermMeta.php');
|
||||
const filtersHelper = read('inc/helpers/aksrt-archive-filters.php');
|
||||
const categoryTpl = read('category.php');
|
||||
const tagTpl = read('tag.php');
|
||||
const archiveJs = read('assets/js/core/archive.js');
|
||||
const listJs = read('assets/js/runtime/main.js');
|
||||
const globalController = read('src/Admin/GlobalController.php');
|
||||
|
||||
// 参考主题字段键(顺序即参考主题定义顺序)。
|
||||
@@ -150,18 +150,16 @@ test('前台链路:覆盖与门禁(参考主题 zib-category.php 语义)',
|
||||
test('前台链路:维度筛选渲染与查询过滤', () => {
|
||||
// 渲染:维度 chip 组(全部 chip 参考主题 focus 语义)。
|
||||
assert.match(filtersHelper, /function aksrt_archive_build_custom_filter_groups\(/);
|
||||
assert.match(filtersHelper, /data-filter-dim/);
|
||||
assert.match(filtersHelper, /ajax-replace="true" class="ajax-next/);
|
||||
assert.match(filtersHelper, /\$keys !== 'all' && !in_array\(\$dim_key, \(array\) \$keys, true\)/);
|
||||
assert.match(categoryTpl, /aksrt_archive_build_custom_filter_groups\(/);
|
||||
assert.match(tagTpl, /aksrt_archive_build_custom_filter_groups\(/);
|
||||
// 查询:_aksrt_cf_{key} meta_query LIKE(主查询 + AJAX 端点)。
|
||||
assert.match(termMeta, /'_aksrt_cf_' \. \$dim_key/);
|
||||
assert.match(termMeta, /'compare'\s*=>\s*'LIKE'/);
|
||||
assert.match(globalController, /'custom_filter'\s*=>\s*\['type' => 'string'/);
|
||||
assert.match(globalController, /json_decode\(\$custom_raw, true\)/);
|
||||
assert.match(globalController, /'_aksrt_cf_' \. \$dim_key/);
|
||||
// JS:维度 chip 状态、请求参数与 URL 同步。
|
||||
assert.match(archiveJs, /data-filter-dim/);
|
||||
assert.match(archiveJs, /JSON\.stringify\(state\.custom\)/);
|
||||
assert.match(archiveJs, /state\.custom\[dimKey\]/);
|
||||
// Category filtering uses the same HTML AJAX pipeline as the homepage.
|
||||
assert.doesNotMatch(globalController, /rest_archive_posts|\/archive\/posts/);
|
||||
assert.match(listJs, /win-ajax-replace/);
|
||||
assert.match(listJs, /post_ajax/);
|
||||
assert.match(filtersHelper, /'key'\s*=>\s*'_aksrt_cf_|\$dim_key/);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
# 首页、分类与新版小工具对齐记录
|
||||
|
||||
参考目录:`C:\Users\Xing\Desktop\zibll`。对照当前启用的实现,不使用标记为旧版或即将删除的文章小工具。
|
||||
|
||||
## 七项改动
|
||||
|
||||
| 任务 | 实现与修正 | 主要参考源码 |
|
||||
| --- | --- | --- |
|
||||
| 首页 | 恢复 14px 基础字号和参考行高;清除首页额外卡片、导航和分页覆盖;默认按发布时间排序;允许清空排序按钮;栏目骨架采用对应分类的列表模式 | `index.php`、`inc/functions/zib-index.php`、`inc/functions/zib-category.php` |
|
||||
| 分类页 | 封面、标题计数、描述、搜索入口、分页提示和筛选菜单结构对齐;分类与下拉项分开;子分类按层级展开;筛选使用通用 HTML 列表加载机制 | `category.php`、`inc/functions/zib-category.php` |
|
||||
| 多栏目文章 | 采用新版 `widget-tab-post` 的 list/card/mini 结构;恢复导航间距、mini 内边距和下划线;保存已加载栏目和翻页内容;列表模式禁用全局多图/无图模式干扰 | `inc/widgets/widget-posts.php` 的 `zib_widget_ui_tab_post`、`css/main.css` |
|
||||
| 分类聚合卡片 | SVG 白名单保留火焰;时钟、文章统计改为线框字形并修正字重;更新时间随排序来源变化;恢复各类统计文案;空分类不产生空白列 | `inc/functions/zib-category.php` 的 `zib_term_aggregation`、`inc/functions/zib-theme.php` |
|
||||
| 后台小工具 | 按区域操作工具条、单一导入/导出/模板弹窗、自定义器移除按钮;表单懒加载、失败重试、折叠分组、色样和分类勾选行为 | `inc/widgets/widget-index.php`、`js/widget-set.js`、当前 `inc/csf-framework` |
|
||||
| 一言 | 文章内一言和新版小工具共用本地双语句库、点击切换和 30 秒刷新;保留参考主题的自定义句库入口 | `yiyan/qv-yiyan.php`、`yiyan/qv-yiyan.txt`、`js/main.js` |
|
||||
| 评论区 | 最新查询固定 DESC;最热按点赞与时间排序且保留无点赞元数据的评论;空态插图、文案和筛选栏显示条件对齐 | `template/comments.php`、`inc/functions/zib-comments-list.php`、`css/main.css` |
|
||||
|
||||
已删除独立分类 REST 筛选端点与 `assets/js/core/archive.js`,以及在线一言 REST 接口、旧一言来源配置、后台常驻三栏工具面板和被替代的样式。没有添加迁移或兼容双路。
|
||||
|
||||
## 验证
|
||||
|
||||
- `node tools/build-public-assets.mjs`:通过,公共 CSS 和前台运行时资源已更新。
|
||||
- `assets/admin` 中 `npm run build`:通过,SPA 和 NaiveUI 样式已生成。
|
||||
- 所有改动及新增 PHP 文件的 `php -l`:通过。
|
||||
- 改动 JavaScript 的 `node --check`:通过。
|
||||
- `php tests/widget-system/run.php`:139 项断言通过。
|
||||
- 递归执行 `tests/` 下全部 `*.test.cjs`,包含小工具、后台、导航和投稿测试:760 项全部通过,无失败、跳过或取消。
|
||||
- `git diff --check`:通过。
|
||||
|
||||
新增发现页渲染、评论排序行为、一言和后台表单契约测试。分类页头覆盖首屏计数、默认封面、搜索、第二页和简洁标题;小工具覆盖 SVG、行内/下拉分离、栏目缓存标记、数字分页和 mini 样式。渲染 harness 使用轻量 WordPress 桩,未启动真实 WordPress。
|
||||
|
||||
按任务要求,没有进行浏览器截图对比或真实视觉效果验收。
|
||||
|
||||
## 既有 8 项失败的修复
|
||||
|
||||
这些失败均由模块拆分或资源打包后未更新的测试契约引起。已核对现有实现并保留原有功能校验,没有恢复旧实现或删除断言来掩盖失败:
|
||||
|
||||
- `financial-flow-contract.test.cjs`:提现历史交互已经拆到 `credits-withdraw-history.js`,测试改为检查该模块;每页 10 条、到账金额、时间、隐私字段等原有断言保留。
|
||||
- `referral-qr-contract.test.cjs`:推广海报已拆到 `credits-affiliate.js`,测试改为检查真实模块及其本地二维码依赖;画布、弹窗静止隐藏和复制等原有功能断言保留。
|
||||
- `toast-contract.test.cjs`:缓存版本现在取全部 runtime JS 文件的最新修改时间,校验更新为该机制,继续禁止旧支付运行时入口。
|
||||
- `vip-modal-skeleton.test.cjs`:用户菜单样式已经并入公共 CSS,校验改为公共包仅包含一次且不再单独入队,与导航契约一致。
|
||||
|
||||
另新增两项实际行为测试:
|
||||
|
||||
- `withdrawal-history-runtime.test.cjs`:执行真实提现历史脚本,验证打开弹窗、第一页和第二页请求、固定每页 10 条、到账金额渲染及隐藏私有 note。
|
||||
- `runtime-assets-behavior-contract.test.cjs`:通过 PHP harness 调用真实 `ScriptsPart1c`,验证主运行时文件更新、叶子模块更新及无文件时的主题版本回退。
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* Exercise the hottest comments_clauses hook without booting WordPress.
|
||||
* Run with: php tests/widget-system/comment-order-behavior.php
|
||||
*/
|
||||
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
|
||||
final class WP_Comment_Query {
|
||||
/** @var array<string,mixed> */
|
||||
public array $query_vars;
|
||||
|
||||
/** @param array<string,mixed> $query_vars */
|
||||
public function __construct(array $query_vars) {
|
||||
$this->query_vars = $query_vars;
|
||||
}
|
||||
}
|
||||
|
||||
final class AKSRT_Test_WPDB {
|
||||
public string $comments = 'wp_comments';
|
||||
public string $commentmeta = 'wp_commentmeta';
|
||||
}
|
||||
|
||||
$GLOBALS['wpdb'] = new AKSRT_Test_WPDB();
|
||||
$GLOBALS['aksrt_test_filters'] = [];
|
||||
|
||||
function add_filter(string $tag, callable $callback, int $priority = 10, int $accepted_args = 1): void {
|
||||
$GLOBALS['aksrt_test_filters'][$tag][] = [$callback, $priority, $accepted_args];
|
||||
}
|
||||
|
||||
function sanitize_key(string $key): string {
|
||||
return strtolower(preg_replace('/[^a-z0-9_\-]/', '', $key) ?? '');
|
||||
}
|
||||
|
||||
function wp_unslash($value) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
function aksrt_test_apply_comments_clauses(array $clauses, WP_Comment_Query $query): array {
|
||||
$filters = $GLOBALS['aksrt_test_filters']['comments_clauses'] ?? [];
|
||||
usort($filters, static fn (array $a, array $b): int => $a[1] <=> $b[1]);
|
||||
foreach ($filters as [$callback, $priority, $accepted_args]) {
|
||||
$clauses = $callback($clauses, $query);
|
||||
}
|
||||
return $clauses;
|
||||
}
|
||||
|
||||
require __DIR__ . '/../../inc/helpers/aksrt-comments.php';
|
||||
|
||||
$newest_args = aksrt_comments_template_query_args(['order' => 'ASC']);
|
||||
if (($newest_args['order'] ?? '') !== 'DESC' || ($newest_args['orderby'] ?? '') !== 'comment_date_gmt') {
|
||||
throw new RuntimeException('The newest tab must use descending comment date order regardless of the WordPress discussion setting.');
|
||||
}
|
||||
|
||||
$_GET['aksrt_comment_order'] = 'hottest';
|
||||
$hottest_args = aksrt_comments_template_query_args([]);
|
||||
if (empty($hottest_args['aksrt_hottest_order']) || ($hottest_args['order'] ?? '') !== 'DESC') {
|
||||
throw new RuntimeException('The hottest tab must set a descending, query-local hottest marker.');
|
||||
}
|
||||
unset($_GET['aksrt_comment_order']);
|
||||
|
||||
$base = [
|
||||
'join' => '',
|
||||
'where' => 'wp_comments.comment_post_ID = 42',
|
||||
'orderby' => 'wp_comments.comment_date_gmt DESC',
|
||||
];
|
||||
|
||||
$hot = aksrt_test_apply_comments_clauses($base, new WP_Comment_Query([
|
||||
'post_id' => 42,
|
||||
'aksrt_hottest_order' => true,
|
||||
]));
|
||||
|
||||
if (!str_contains($hot['join'], 'LEFT JOIN wp_commentmeta AS aksrt_cm_hot')) {
|
||||
throw new RuntimeException('Hottest ordering must use LEFT JOIN so comments without like metadata remain in the result.');
|
||||
}
|
||||
if (!str_contains($hot['orderby'], 'COALESCE(aksrt_cm_hot.meta_value, 0) AS UNSIGNED) DESC')) {
|
||||
throw new RuntimeException('Hottest ordering must sort likes from high to low and treat missing metadata as zero.');
|
||||
}
|
||||
if (!str_contains($hot['orderby'], 'wp_comments.comment_date_gmt DESC')) {
|
||||
throw new RuntimeException('Equal like counts must use newest date as the stable tie-breaker.');
|
||||
}
|
||||
if ($hot['where'] !== $base['where']) {
|
||||
throw new RuntimeException('Hottest sorting must not alter the query scope.');
|
||||
}
|
||||
|
||||
$ordinary = aksrt_test_apply_comments_clauses($base, new WP_Comment_Query([
|
||||
'post_id' => 99,
|
||||
'orderby' => 'comment_date_gmt',
|
||||
]));
|
||||
if ($ordinary !== $base) {
|
||||
throw new RuntimeException('Queries without the hottest marker must remain unchanged.');
|
||||
}
|
||||
|
||||
fwrite(STDOUT, "Hottest comment query behavior passed.\n");
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
test('hottest query hook retains unliked comments and is query-local', () => {
|
||||
const script = path.resolve(__dirname, 'comment-order-behavior.php');
|
||||
const result = spawnSync('php', [script], { encoding: 'utf8' });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(result.stdout, /Hottest comment query behavior passed/);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
'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('comment order matches the reference newest and hottest behavior', () => {
|
||||
const php = read('inc/helpers/aksrt-comments.php');
|
||||
|
||||
assert.match(php, /\$args\['order'\] = 'DESC';\s*\$args\['orderby'\] = 'comment_date_gmt';/);
|
||||
assert.match(php, /\$args\['aksrt_hottest_order'\] = true;/);
|
||||
assert.match(php, /CAST\(COALESCE\(aksrt_cm_hot\.meta_value, 0\) AS UNSIGNED\) DESC/);
|
||||
assert.match(php, /\$query->query_vars\['aksrt_hottest_order'\]/);
|
||||
assert.equal(php.includes("$args['order'] = 'ASC'"), false, 'the reference always orders newest comments first');
|
||||
assert.equal(php.includes("$order === 'oldest'"), false, 'the unsupported oldest tab must not remain in the UI query path');
|
||||
assert.equal(php.includes('aksrt_comments_apply_hottest_order'), false, 'unscoped global hot-order hook must be removed');
|
||||
assert.equal(php.includes('reverse_top_level'), false, 'DESC results must not be reversed a second time');
|
||||
});
|
||||
|
||||
test('empty comment state uses the reference illustration and caption', () => {
|
||||
const php = read('inc/helpers/aksrt-comments.php');
|
||||
|
||||
assert.match(php, /aksrt_get_null\(\$text, 40, 'comment-null\.svg', 'comment comment-null', 280\)/);
|
||||
assert.match(php, /\$text = __\('暂无评论内容', 'aksrt'\);/);
|
||||
assert.ok(fs.existsSync(path.join(root, 'assets/img/comment-null.svg')), 'the reference empty-state illustration must ship with the theme');
|
||||
assert.equal(php.includes('暂无作者评论'), false, 'empty filter state uses the reference caption');
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
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');
|
||||
|
||||
function renderFixtures() {
|
||||
const output = execFileSync('php', [path.join(__dirname, 'discovery-reference-harness.php')], { encoding: 'utf8' });
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
test('term aggregation renders the real SVG use element, total count, and configured first page', () => {
|
||||
const fixture = renderFixtures();
|
||||
const card = fixture.aggregation;
|
||||
assert.match(card, /<svg class="zibsvg-hot" viewbox="0 0 16 16" aria-hidden="true"><use href="#zibsvg-hot"><\/use><\/svg>/);
|
||||
const sanitizer = read('inc/helpers/aksrt-graphic-card.php');
|
||||
assert.match(sanitizer, /'svg'\s*=>\s*\['class'\s*=>\s*true,\s*'viewbox'\s*=>\s*true,\s*'aria-hidden'\s*=>\s*true\]/);
|
||||
assert.match(sanitizer, /'use'\s*=>\s*\['href'\s*=>\s*true,\s*'xlink:href'\s*=>\s*true\]/);
|
||||
assert.match(card, /7篇文章/);
|
||||
assert.match(card, /文章 101/);
|
||||
assert.match(card, /文章 102/);
|
||||
assert.match(card, /class="fa far fa-file-alt fa-fw"/);
|
||||
assert.match(card, /class="fa far fa-clock mr3"/);
|
||||
assert.doesNotMatch(card, /文章 103|空分类/);
|
||||
|
||||
const baseCss = read('assets/css/frontend/base.css');
|
||||
assert.match(baseCss, /\.fa\.far\s*\{[^}]*font-weight:\s*400;[^}]*\}/s);
|
||||
|
||||
const firstQuery = fixture.aggregation_queries[0];
|
||||
assert.equal(firstQuery.posts_per_page, 2);
|
||||
assert.equal(firstQuery.no_found_rows, false);
|
||||
assert.deepEqual(firstQuery.category__in, [7]);
|
||||
assert.equal(fixture.aggregation_queries[1].category__in[0], 8);
|
||||
});
|
||||
|
||||
test('category filters keep dropdown and inline terms in separate rendered groups on the ajax-option pipeline', () => {
|
||||
const fixture = renderFixtures();
|
||||
const html = fixture.archive_filter_html;
|
||||
const group = fixture.filter_group;
|
||||
assert.deepEqual(group.chips.map((chip) => chip.label), ['技术分类']);
|
||||
assert.deepEqual(group.dropdown.map((chip) => chip.label), ['下拉分类']);
|
||||
assert.match(html, /class="ajax-option ajax-replace" win-ajax-replace="filter"/);
|
||||
assert.match(html, /class="ajax-next" href="https:\/\/example\.test\/category\/7"/);
|
||||
const dropdown = html.match(/<ul class="dropdown-menu">([\s\S]*?)<\/ul>/)[1];
|
||||
const inline = html.match(/<ul class="list-inline[^>]*option-items">([\s\S]*?)<\/ul>/)[1];
|
||||
assert.match(dropdown, /下拉分类/);
|
||||
assert.doesNotMatch(dropdown, /技术分类/);
|
||||
assert.match(inline, /技术分类/);
|
||||
assert.doesNotMatch(inline, /下拉分类/);
|
||||
});
|
||||
|
||||
test('category header matches first-page, later-page, and simple-title render contracts', () => {
|
||||
const fixture = renderFixtures();
|
||||
const first = fixture.term_header_first;
|
||||
assert.match(first, /win-ajax-replace="page-cover"/);
|
||||
assert.match(first, /src="https:\/\/example\.test\/theme\/assets\/img\/user_t\.jpg"/);
|
||||
assert.match(first, /共31篇/);
|
||||
assert.match(first, /class="main-search-btn"[^>]*search-trem="7"/);
|
||||
|
||||
const second = fixture.term_header_second;
|
||||
assert.doesNotMatch(second, /win-ajax-replace="page-cover"|共31篇/);
|
||||
assert.match(second, /第2页/);
|
||||
assert.match(second, /search-trem="7"/);
|
||||
|
||||
const simple = fixture.term_header_simple;
|
||||
assert.match(simple, /class="aksrt-panel relative"/);
|
||||
assert.match(simple, /title-h-left/);
|
||||
assert.match(simple, /共31篇/);
|
||||
assert.match(simple, /简洁描述/);
|
||||
assert.doesNotMatch(simple, /class="page-cover aksrt-panel|<img/);
|
||||
});
|
||||
|
||||
test('homepage sorting retains distinct dropdown and inline option containers', () => {
|
||||
const html = renderFixtures().home_sort;
|
||||
assert.match(html, /<ul class="dropdown-menu">[\s\S]*更新[\s\S]*随机[\s\S]*<\/ul>/);
|
||||
assert.match(html, /<ul class="list-inline[^>]*option-items"><a[^>]*orderby=date[\s\S]*发布[\s\S]*<a[^>]*orderby=views[\s\S]*浏览[\s\S]*<\/ul>/);
|
||||
assert.equal((html.match(/class="ajax-next/g) || []).length, 9);
|
||||
});
|
||||
|
||||
test('number pagination renders a page trigger and tab panes distinguish loaded from lazy content', () => {
|
||||
const fixture = renderFixtures();
|
||||
assert.match(fixture.posts, /class="widget-ajaxpager"[^>]*data-loaded="1"/);
|
||||
assert.match(fixture.posts, /class="next-page page-numbers"[^>]*data-widget-page="2"/);
|
||||
assert.match(fixture.tabs, /data-segment="0" data-loaded="1"/);
|
||||
const lazyPane = fixture.tabs.match(/<div class="widget-ajaxpager"[^>]*data-segment="1"[^>]*>[\s\S]*?<\/div>/)[0];
|
||||
assert.doesNotMatch(lazyPane, /data-loaded="1"/);
|
||||
assert.match(fixture.tabs, /class="next-page page-numbers"[^>]*data-widget-page="2"/);
|
||||
|
||||
const runtime = read('assets/widgets/frontend.js');
|
||||
assert.match(runtime, /container\.dataset\.loaded = '1'/);
|
||||
assert.match(runtime, /if \(panel\.dataset\.loaded !== '1'\) loadItems\(panel, 1, false\)/);
|
||||
assert.match(runtime, /var pageLink = event\.target\.closest\('\.page-numbers\[data-widget-page\]'\)/);
|
||||
assert.match(runtime, /loadItems\(container, Number\(pageLink\.dataset\.widgetPage \|\| 1\), false\)/);
|
||||
});
|
||||
|
||||
test('archive filters use the shared ajax-next handler without a category REST/archive script path', () => {
|
||||
const filters = read('inc/helpers/aksrt-archive-filters.php');
|
||||
const ajax = read('assets/js/core/ajax-list.js');
|
||||
const archives = read('inc/helpers/aksrt-archive.php');
|
||||
const globalController = read('src/Admin/GlobalController.php');
|
||||
assert.match(filters, /class="ajax-option ajax-replace" win-ajax-replace="filter"/);
|
||||
assert.match(filters, /class="ajax-next/);
|
||||
assert.match(ajax, /link\.closest\("\.ajax-option, \.post_ajax_trigger"\)/);
|
||||
assert.doesNotMatch(archives, /register_rest_route|rest_archive_posts|\/archive\/posts/);
|
||||
assert.doesNotMatch(globalController, /register_rest_route\([^\n]*['"][^'"]*archive\/posts/);
|
||||
assert.equal(fs.existsSync(path.join(root, 'assets', 'js', 'core', 'archive.js')), false);
|
||||
|
||||
const widgetCss = read('assets/widgets/frontend.css');
|
||||
assert.match(widgetCss, /\.zib-widget\.posts-mini-lists\s*\{\s*padding:\s*5px;/);
|
||||
assert.doesNotMatch(widgetCss, /\.widget-tab-post[^{}]*\{[^}]*!important/s);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
/** Focused render harness for homepage/category discovery widgets and filters. */
|
||||
declare(strict_types=1);
|
||||
|
||||
define('ABSPATH', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR);
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
|
||||
class WP_Error { public function __construct(public string $code = '') {} }
|
||||
class WP_Term {
|
||||
public int $term_taxonomy_id;
|
||||
public function __construct(public int $term_id, public string $name, public string $description = '', public string $taxonomy = 'category', public int $count = 7, public int $parent = 0) { $this->term_taxonomy_id = $term_id; }
|
||||
}
|
||||
class WP_Post { public function __construct(public int $ID) {} }
|
||||
class WP_Query {
|
||||
public array $posts = [];
|
||||
public int $found_posts = 0;
|
||||
public int $max_num_pages = 1;
|
||||
private int $cursor = 0;
|
||||
public function __construct(public array $query_args = []) {
|
||||
$GLOBALS['discovery_queries'][] = $query_args;
|
||||
$cats = array_map('intval', (array) ($query_args['category__in'] ?? []));
|
||||
$rows = array_values(array_filter($GLOBALS['discovery_posts'], static fn(array $post): bool => !$cats || in_array($post['cat'], $cats, true)));
|
||||
$this->found_posts = count($rows);
|
||||
$per_page = max(1, (int) ($query_args['posts_per_page'] ?? 10));
|
||||
$this->max_num_pages = (int) ceil($this->found_posts / $per_page);
|
||||
$page = max(1, (int) ($query_args['paged'] ?? 1));
|
||||
foreach (array_slice($rows, ($page - 1) * $per_page, $per_page) as $row) $this->posts[] = new WP_Post($row['id']);
|
||||
}
|
||||
public function have_posts(): bool { return $this->cursor < count($this->posts); }
|
||||
public function the_post(): void { $post = $this->posts[$this->cursor++]; $GLOBALS['discovery_current_post'] = $post; }
|
||||
}
|
||||
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 { return $query; }
|
||||
public function get_var(string $query): string { return '789'; }
|
||||
}
|
||||
|
||||
$GLOBALS['wpdb'] = new wpdb();
|
||||
$GLOBALS['discovery_queries'] = [];
|
||||
$GLOBALS['discovery_current_post'] = null;
|
||||
$GLOBALS['discovery_terms'] = [
|
||||
7 => new WP_Term(7, '技术分类', '<p>分类说明</p>'),
|
||||
8 => new WP_Term(8, '空分类', ''),
|
||||
9 => new WP_Term(9, '下拉分类', ''),
|
||||
];
|
||||
$GLOBALS['discovery_queried_term'] = null;
|
||||
$GLOBALS['discovery_paged'] = 1;
|
||||
$GLOBALS['discovery_posts'] = [];
|
||||
for ($id = 101; $id <= 107; $id++) $GLOBALS['discovery_posts'][] = ['id' => $id, 'cat' => 7];
|
||||
|
||||
function __($text, $domain = null): string { return (string) $text; }
|
||||
function esc_html__($text, $domain = null): string { return esc_html($text); }
|
||||
function esc_attr__($text, $domain = null): string { return esc_attr($text); }
|
||||
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_html_e($text, $domain = null): void { echo esc_html($text); }
|
||||
function esc_attr_e($text, $domain = null): void { echo esc_attr($text); }
|
||||
function esc_url_raw($value): string { return (string) $value; }
|
||||
function esc_url($value): string { return esc_attr($value); }
|
||||
function wp_parse_args($args, array $defaults = []): array { return array_merge($defaults, is_array($args) ? $args : []); }
|
||||
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_-]/', '', strtolower((string) $value)) ?? ''; }
|
||||
function sanitize_html_class($value): string { return preg_replace('/[^A-Za-z0-9_-]/', '', (string) $value) ?? ''; }
|
||||
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
|
||||
function absint($value): int { return abs((int) $value); }
|
||||
function wp_strip_all_tags($value): string { return trim(strip_tags((string) $value)); }
|
||||
function wp_kses_post($value): string { return strip_tags((string) $value, '<a><b><strong><em><br><p><span>'); }
|
||||
function wp_kses($value, array $allowed): string {
|
||||
$tags = '<' . implode('><', array_keys($allowed)) . '>';
|
||||
return strip_tags((string) $value, $tags);
|
||||
}
|
||||
function is_wp_error($value): bool { return $value instanceof WP_Error; }
|
||||
function wp_is_mobile(): bool { return false; }
|
||||
function wp_reset_postdata(): void { $GLOBALS['discovery_current_post'] = null; }
|
||||
function aksrt_is_lazy(string $key, bool $default = false): bool { return false; }
|
||||
function aksrt_get_lazy_attr(string $type, string $url, string $class = '', string $lazy = ''): string { return ' class="' . esc_attr($class) . '" src="' . esc_url($url) . '"'; }
|
||||
function aksrt_format_number($number): string { return number_format((int) $number); }
|
||||
function aksrt_renderer_svg_icon(string $name): string { return '<svg class="zibsvg-' . esc_attr($name) . '" viewbox="0 0 16 16" aria-hidden="true"><use href="#zibsvg-' . esc_attr($name) . '"></use></svg>'; }
|
||||
function aksrt_renderer_collect_post_data(array $config = []): array {
|
||||
$post = $GLOBALS['discovery_current_post'];
|
||||
return ['post_id' => $post->ID, 'title' => '文章 ' . $post->ID, 'subtitle' => '', 'permalink' => 'https://example.test/post/' . $post->ID, 'time_ago' => '2天前', 'views' => 42, 'comments' => 3];
|
||||
}
|
||||
function aksrt_get_post_favorites(int $id): int { return 5; }
|
||||
function aksrt_get_post_likes(int $id): int { return 6; }
|
||||
function get_term(int $id, string $taxonomy = 'category') { return $GLOBALS['discovery_terms'][$id] ?? new WP_Error('missing'); }
|
||||
function get_term_meta(int $id, string $key, bool $single = true): int { return $id === 7 ? 77 : 0; }
|
||||
function wp_get_attachment_url(int $id): string { return $id === 77 ? 'https://example.test/category-cover.jpg' : ''; }
|
||||
function get_posts(array $args): array { return []; }
|
||||
function get_the_post_thumbnail_url($id = null, string $size = 'post-thumbnail'): string { return ''; }
|
||||
function get_term_link(WP_Term $term) { return 'https://example.test/category/' . $term->term_id; }
|
||||
function current_user_can(string $capability): bool { return false; }
|
||||
function get_edit_term_link(int $id, string $taxonomy): string { return ''; }
|
||||
function aksrt_widget_str_cut(string $text, int $length): string { return $text; }
|
||||
function aksrt_render_placeholder(string $style, int $count): string { return '<div data-placeholder="' . esc_attr($style) . ':' . $count . '"></div>'; }
|
||||
function aksrt_render_post_list(array $args, WP_Query $query, bool $echo = true): string { return '<div class="rendered-post-count" data-count="' . count($query->posts) . '"></div>'; }
|
||||
function get_terms(array $args) {
|
||||
$ids = array_map('intval', (array) ($args['include'] ?? []));
|
||||
if (($args['fields'] ?? '') === 'ids') return [];
|
||||
$terms = $ids ? array_intersect_key($GLOBALS['discovery_terms'], array_flip($ids)) : $GLOBALS['discovery_terms'];
|
||||
return array_values($terms);
|
||||
}
|
||||
function get_queried_object() { return $GLOBALS['discovery_queried_term']; }
|
||||
function get_query_var(string $name, $default = '') { return $name === 'paged' ? $GLOBALS['discovery_paged'] : $default; }
|
||||
function get_ancestors(int $id, string $taxonomy, string $resource_type): array { return []; }
|
||||
function wp_parse_args_filter($args, array $defaults): array { return array_merge($defaults, $args); }
|
||||
function aksrt_homepage_get_current_url(): string { return 'https://example.test/?paged=3'; }
|
||||
function aksrt_homepage_url_del_paged(string $url): string { return preg_replace('/[?&]paged=\d+/', '', $url) ?: $url; }
|
||||
function add_query_arg($args, $url = false): string {
|
||||
if (is_array($args)) return (string) $url . (str_contains((string) $url, '?') ? '&' : '?') . http_build_query($args);
|
||||
return 'https://example.test/';
|
||||
}
|
||||
function remove_query_arg(array $keys, string $url): string { return $url; }
|
||||
function home_url(string $path = '/'): string { return 'https://example.test' . $path; }
|
||||
function get_template_directory_uri(): string { return 'https://example.test/theme'; }
|
||||
function wp_unslash($value): string { return (string) $value; }
|
||||
|
||||
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/aksrt-homepage.php';
|
||||
require dirname(__DIR__, 2) . '/inc/helpers/aksrt-archive-filters.php';
|
||||
|
||||
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
|
||||
|
||||
$aggregation = ContentRenderer::render('term_aggregation', [
|
||||
'term_id' => [7, 8], 'pc_row' => 2, 'orderby' => 'modified', 'order' => 'desc', 'count' => 2,
|
||||
]);
|
||||
$tabs = ContentRenderer::render('tab_posts', [
|
||||
'style' => 'list', 'count' => 2, 'paginate' => 'number',
|
||||
'tabs' => [['title' => '技术', 'cat' => '7'], ['title' => '空栏目', 'cat' => '8']],
|
||||
], ['widget_id' => 'aksrt_widget_tab_posts-4']);
|
||||
$posts = ContentRenderer::render('posts', ['style' => 'list', 'count' => 2, 'paginate' => 'number'], ['widget_id' => 'aksrt_widget_posts-3']);
|
||||
|
||||
$filter_group = aksrt_archive_build_term_filter_group('category', [
|
||||
'lists' => [7], 'dropdown' => true, 'dropdown_lists' => [9],
|
||||
], '分类');
|
||||
ob_start();
|
||||
aksrt_archive_render_filter_bar(['groups' => [$filter_group], 'aria_label' => '分类筛选']);
|
||||
$archive_filter_html = (string) ob_get_clean();
|
||||
|
||||
$GLOBALS['discovery_queried_term'] = $GLOBALS['discovery_terms'][7];
|
||||
$GLOBALS['wp_query'] = new WP_Query();
|
||||
$GLOBALS['wp_query']->found_posts = 31;
|
||||
$GLOBALS['discovery_paged'] = 1;
|
||||
ob_start();
|
||||
aksrt_archive_render_term_header([
|
||||
'header_style' => 'cover', 'show_cover' => true, 'cover_url' => '',
|
||||
'title' => '技术分类', 'description' => '首屏描述', 'post_count' => true,
|
||||
]);
|
||||
$term_header_first = (string) ob_get_clean();
|
||||
|
||||
$GLOBALS['discovery_paged'] = 2;
|
||||
ob_start();
|
||||
aksrt_archive_render_term_header([
|
||||
'header_style' => 'cover', 'show_cover' => true, 'cover_url' => '',
|
||||
'title' => '技术分类', 'description' => '第二页描述', 'post_count' => true,
|
||||
]);
|
||||
$term_header_second = (string) ob_get_clean();
|
||||
|
||||
$GLOBALS['discovery_paged'] = 1;
|
||||
ob_start();
|
||||
aksrt_archive_render_term_header([
|
||||
'header_style' => 'simple', 'show_cover' => false,
|
||||
'title' => '技术分类', 'description' => '简洁描述', 'post_count' => true,
|
||||
]);
|
||||
$term_header_simple = (string) ob_get_clean();
|
||||
|
||||
$home_sort = aksrt_homepage_get_option_orderby_but(['lists' => ['date', 'views'], 'dropdown' => true]);
|
||||
|
||||
$result = json_encode([
|
||||
'aggregation' => $aggregation,
|
||||
'aggregation_queries' => $GLOBALS['discovery_queries'],
|
||||
'tabs' => $tabs,
|
||||
'posts' => $posts,
|
||||
'filter_group' => $filter_group,
|
||||
'archive_filter_html' => $archive_filter_html,
|
||||
'term_header_first' => $term_header_first,
|
||||
'term_header_second' => $term_header_second,
|
||||
'term_header_simple' => $term_header_simple,
|
||||
'home_sort' => $home_sort,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($result === false) {
|
||||
fwrite(STDERR, json_last_error_msg());
|
||||
exit(1);
|
||||
}
|
||||
echo $result;
|
||||
@@ -82,7 +82,7 @@ test('ledger history carries no financial flow coupling', () => {
|
||||
test('user withdrawal history uses one paginated ten-item endpoint', () => {
|
||||
const controller = read('src/Payment/CommissionLedger.php')
|
||||
const renderer = read('inc/helpers/user-center-renderers/credits-wallet.php')
|
||||
const script = read('assets/js/credits.js')
|
||||
const script = read('assets/js/credits-withdraw-history.js')
|
||||
const styles = read('assets/css/routes/user-center/credits.css')
|
||||
|
||||
assert.match(controller, /\$withdrawal_args\['per_page'\]\['default'\]\s*=\s*10/)
|
||||
|
||||
@@ -259,7 +259,8 @@ test('front-end page-display consumers read only the canonical section contracts
|
||||
assert.equal(category.includes('get_post_filter_settings'), false)
|
||||
assert.equal(category.includes('get_filter_config_for_category'), false)
|
||||
assert.equal(tag.includes('get_post_filter_settings'), false)
|
||||
assert.match(category, /ajax_category_enabled/)
|
||||
assert.match(category, /'ajax_category'\s*:\s*'ajax_tag'/)
|
||||
assert.match(category, /\$archive_settings\[\$key \. '_enabled'\]/)
|
||||
assert.match(category, /orderby_enabled/)
|
||||
assert.match(tag, /ajax_tag_enabled/)
|
||||
assert.match(tag, /orderby_enabled/)
|
||||
|
||||
@@ -111,7 +111,7 @@ test('forced mode wins and thumb_if_has ignores spare and lazy placeholders', ()
|
||||
assert.match(data, /has_post_format\(\['image', 'gallery'\]/);
|
||||
});
|
||||
|
||||
test('category card mode carries its term id through archive, AJAX and REST rendering', () => {
|
||||
test('category card mode carries its term id through archive and shared AJAX rendering', () => {
|
||||
const resolver = read('inc/helpers/aksrt-archive.php');
|
||||
const config = read('inc/helpers/posts-renderer/config.php');
|
||||
const admin = read('src/Admin/Admin.php');
|
||||
@@ -124,7 +124,8 @@ test('category card mode carries its term id through archive, AJAX and REST rend
|
||||
assert.match(config, /\$term_id = absint\(\$args\['term_id'\]\)/);
|
||||
assert.doesNotMatch(config, /get_queried_object_id/);
|
||||
assert.match(admin, /\$clean\['term_id'\]\s*=\s*absint\(\$config\['term_id'\]\)/);
|
||||
assert.match(global, /aksrt_resolve_post_list_render_config\(\[\s*'context'\s*=>\s*\$archive_type,\s*'term_id'\s*=>\s*\$archive_type === 'category' \? \$term_id : 0,/);
|
||||
assert.doesNotMatch(global, /rest_archive_posts|\/archive\/posts/);
|
||||
assert.match(read('category.php'), /aksrt_archive_get_render_config\(\['context' => 'category'\]\)/);
|
||||
assert.doesNotMatch(config, /\b(?:list_scale|card_scale|mobile_meta)\b/);
|
||||
assert.doesNotMatch(resolver, /aksrt_archive_get_list_style_settings/);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ test('QR generator is bundled locally; no external QR CDN in front-end sources',
|
||||
// 前端引用面不允许出现外部二维码 CDN。
|
||||
const sources = [
|
||||
read('assets', 'js', 'credits.js'),
|
||||
read('assets', 'js', 'credits-affiliate.js'),
|
||||
read('inc', 'helpers', 'user-center-renderers', 'profile-revenue.php'),
|
||||
read('src', 'Core', 'ScriptsPart1b.php'),
|
||||
].join('\n')
|
||||
@@ -53,7 +54,7 @@ test('revenue detail card is a four-row info list with no standalone QR block',
|
||||
// 返佣详解标题走可配置字段,不再硬编码。
|
||||
assert.match(template, /esc_html\(\$details_title\)/)
|
||||
|
||||
const script = read('assets', 'js', 'credits.js')
|
||||
const script = read('assets', 'js', 'credits-affiliate.js')
|
||||
// 独立二维码绘制函数已删除;window.qrcode 仅供海报使用。
|
||||
assert.doesNotMatch(script, /function renderAffiliateQr/)
|
||||
assert.doesNotMatch(script, /renderAffiliateQr\(/)
|
||||
@@ -111,7 +112,7 @@ test('poster canvas is built from admin-configured copy and gated on the poster
|
||||
assert.match(attribution, /'referrer_name' => \$user instanceof WP_User \? \$user->display_name/)
|
||||
assert.match(attribution, /'site_name' => get_bloginfo\('name'\)/)
|
||||
|
||||
const script = read('assets', 'js', 'credits.js')
|
||||
const script = read('assets', 'js', 'credits-affiliate.js')
|
||||
// 海报仅在开关开启时暴露按钮;推荐人昵称也由 REST 下发。
|
||||
assert.match(script, /initAffiliatePoster\(root, url \|\| j\.url \|\| '', j\.poster \|\| \{\}, j\.site_name \|\| document\.title, j\.referrer_name \|\| ''\)/)
|
||||
assert.match(script, /poster\.enabled !== true\) return/)
|
||||
@@ -136,7 +137,7 @@ test('poster canvas is built from admin-configured copy and gated on the poster
|
||||
})
|
||||
|
||||
test('poster canvas layers match the reference rebate branch 1:1', () => {
|
||||
const script = read('assets', 'js', 'credits.js')
|
||||
const script = read('assets', 'js', 'credits-affiliate.js')
|
||||
const body = script.slice(script.indexOf('function buildPosterCanvas'), script.indexOf('// cover 裁切'))
|
||||
|
||||
// 参考主题 rebate 分支用 drawTextOverflow 的换行/截断,不是自研 wrapText。
|
||||
@@ -196,7 +197,7 @@ test('poster modal markup is a 1:1 replica of the reference poster-share dialog'
|
||||
|
||||
test('poster modal shell is hidden at rest so it cannot block the whole page', () => {
|
||||
const modal = read('inc', 'helpers', 'user-center-renderers', 'affiliate-poster-modal.php')
|
||||
const script = read('assets', 'js', 'credits.js')
|
||||
const script = read('assets', 'js', 'credits-affiliate.js')
|
||||
const css = read('assets', 'css', 'routes', 'user-center', 'credits.css')
|
||||
|
||||
// 标记、JS 查询与 CSS 钩子必须共用同一个属性,缺一即失效:
|
||||
@@ -229,12 +230,15 @@ test('invite-code input verifies live against the public REST endpoint', () => {
|
||||
assert.match(script, /\/wp-json\/aksrt\/v1\/invite-code\/verify\?code=/)
|
||||
})
|
||||
|
||||
test('qrcode asset is enqueued only for profile + affiliate and credits depends on it', () => {
|
||||
test('affiliate posters declare the local QR dependency independently of withdrawal history', () => {
|
||||
const scripts = read('src', 'Core', 'ScriptsPart1b.php')
|
||||
assert.match(scripts, /assets\/js\/vendor\/qrcode\.min\.js/)
|
||||
assert.match(scripts, /'aksrt-qrcode'/)
|
||||
// credits.js 的依赖数组必须在 enqueue 调用内合并 QR 库句柄。
|
||||
assert.match(scripts, /array_merge\(\['loader_js', 'aksrt-vip-coordinator'\], \$affiliate_deps\)/)
|
||||
// 海报模块显式依赖本地 QR;提现历史无需 QR。
|
||||
// credits.js 经收银台间接使用 QR,不能把这条支付依赖误认成海报依赖。
|
||||
assert.match(scripts, /\['credits-affiliate' => \['loader_js', 'aksrt-qrcode'\]\]/)
|
||||
assert.match(scripts, /'aksrt-credits'[\s\S]*?\['loader_js', 'aksrt-vip-coordinator', 'aksrt-pay-cashier'\]/)
|
||||
assert.doesNotMatch(scripts, /'credits-withdraw-history' => \['loader_js', 'aksrt-qrcode'\]/)
|
||||
})
|
||||
|
||||
test('client IP honours the trusted-proxy filter before CDN headers', () => {
|
||||
|
||||
@@ -282,8 +282,12 @@ final class WidgetSystemTest {
|
||||
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(1, count($GLOBALS['aksrt_test_state']['routes']), 'REST registers only the widget items route');
|
||||
$items_route = 'aksrt/v1/widgets/(?P<widget>[a-z][a-z0-9_]*)/items';
|
||||
$route = $GLOBALS['aksrt_test_state']['routes'][$items_route] ?? [];
|
||||
$this->true(isset($GLOBALS['aksrt_test_state']['routes'][$items_route]), 'items route remains registered');
|
||||
$this->false(isset($GLOBALS['aksrt_test_state']['routes']['aksrt/v1/widgets/quote']), 'obsolete quote REST route is removed');
|
||||
$this->false(method_exists($controller, 'get_quote'), 'obsolete quote REST callback is removed');
|
||||
$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');
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
test('the public loader version follows every runtime JS mtime and falls back when none exist', () => {
|
||||
const harness = path.join(__dirname, 'runtime-assets-behavior-harness.php');
|
||||
const result = JSON.parse(execFileSync('php', [harness], { encoding: 'utf8' }));
|
||||
|
||||
// Initial tree: main.min.js is newest. Then only main.min.js changes, then
|
||||
// a leaf module changes, and finally the runtime tree is empty.
|
||||
assert.deepEqual(result.versions, result.expected);
|
||||
assert.ok(Number(result.versions[1]) > Number(result.versions[0]));
|
||||
assert.ok(Number(result.versions[2]) > Number(result.versions[1]));
|
||||
assert.equal(result.versions[3], 'theme-fallback');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
/** Exercise ScriptsPart1c's _win runtime version with an isolated asset tree. */
|
||||
declare(strict_types=1);
|
||||
|
||||
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
|
||||
define('AKSRT_VERSION', 'theme-fallback');
|
||||
define('AKSRT_URI', 'https://example.test/theme');
|
||||
|
||||
$GLOBALS['runtime_asset_fixture_root'] = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'aksrt-runtime-assets-' . bin2hex(random_bytes(6));
|
||||
$GLOBALS['runtime_asset_localized'] = [];
|
||||
mkdir($GLOBALS['runtime_asset_fixture_root'] . '/assets/js/runtime', 0777, true);
|
||||
|
||||
function get_template_directory(): string { return $GLOBALS['runtime_asset_fixture_root']; }
|
||||
function get_template_directory_uri(): string { return 'https://example.test/theme'; }
|
||||
function wp_enqueue_script(...$args): void { unset($args); }
|
||||
function wp_enqueue_style(...$args): void { unset($args); }
|
||||
function wp_register_script(...$args): void { unset($args); }
|
||||
function wp_register_style(...$args): void { unset($args); }
|
||||
function wp_localize_script(string $handle, string $object_name, array $data): bool {
|
||||
$GLOBALS['runtime_asset_localized'][$handle][$object_name] = $data;
|
||||
return true;
|
||||
}
|
||||
function __($text, $domain = null): string { return (string) $text; }
|
||||
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_-]/', '', strtolower((string) $value)) ?? ''; }
|
||||
function home_url(string $path = '/'): string { return 'https://example.test' . $path; }
|
||||
function admin_url(string $path = ''): string { return 'https://example.test/wp-admin/' . $path; }
|
||||
function wp_login_url(): string { return 'https://example.test/login'; }
|
||||
function wp_registration_url(): string { return 'https://example.test/register'; }
|
||||
function wp_lostpassword_url(): string { return 'https://example.test/lost-password'; }
|
||||
function rest_url(string $path = ''): string { return 'https://example.test/wp-json/' . $path; }
|
||||
function esc_url_raw($url): string { return (string) $url; }
|
||||
function get_allowed_mime_types(): array { return ['jpg|jpeg' => 'image/jpeg']; }
|
||||
function wp_create_nonce(string $action): string { return 'nonce-' . $action; }
|
||||
function get_query_var(string $name, $default = '') { return $default; }
|
||||
function is_user_logged_in(): bool { return false; }
|
||||
function get_current_user_id(): int { return 0; }
|
||||
function wp_max_upload_size(): int { return 1048576; }
|
||||
function is_page_template(string $template = ''): bool { return false; }
|
||||
function aksrt_is_lazy(string $scope): bool { return false; }
|
||||
function aksrt_get_lazy_thumb(string $size = ''): string { return 'https://example.test/lazy.svg'; }
|
||||
function aksrt_get_default_avatar_url(): string { return 'https://example.test/avatar.png'; }
|
||||
|
||||
require dirname(__DIR__, 2) . '/src/Core/ScriptsPart1c.php';
|
||||
|
||||
class RuntimeAssetBehaviorHarness {
|
||||
use \AKSRT\Core\ScriptsPart1c;
|
||||
|
||||
public function enqueue(): void {
|
||||
$this->enqueue_public_fonts_js_and_data('https://example.test/theme', 'aksrt-public', [], false, false, false, false, AKSRT_VERSION, []);
|
||||
}
|
||||
|
||||
private function resolve_frontend_routes(): array {
|
||||
return ['orders' => '/orders', 'resources' => '/resources'];
|
||||
}
|
||||
}
|
||||
|
||||
function write_runtime_file(string $relative, int $mtime): void {
|
||||
$path = get_template_directory() . '/assets/js/runtime/' . $relative;
|
||||
if (!is_dir(dirname($path))) mkdir(dirname($path), 0777, true);
|
||||
file_put_contents($path, '// ' . $relative);
|
||||
touch($path, $mtime);
|
||||
clearstatcache(true, $path);
|
||||
}
|
||||
|
||||
function latest_runtime_version(): string {
|
||||
$GLOBALS['runtime_asset_localized'] = [];
|
||||
(new RuntimeAssetBehaviorHarness())->enqueue();
|
||||
return (string) $GLOBALS['runtime_asset_localized']['loader_js']['_win']['ver'];
|
||||
}
|
||||
|
||||
$baseTime = time() - 600;
|
||||
write_runtime_file('main.js', $baseTime + 10);
|
||||
write_runtime_file('main.min.js', $baseTime + 30);
|
||||
write_runtime_file('imgbox.js', $baseTime + 20);
|
||||
$versions = [latest_runtime_version()];
|
||||
|
||||
write_runtime_file('main.min.js', $baseTime + 40);
|
||||
$versions[] = latest_runtime_version();
|
||||
|
||||
write_runtime_file('comment-tools.js', $baseTime + 50);
|
||||
$versions[] = latest_runtime_version();
|
||||
|
||||
foreach (glob(get_template_directory() . '/assets/js/runtime/*.js') ?: [] as $file) unlink($file);
|
||||
$versions[] = latest_runtime_version();
|
||||
|
||||
foreach (glob(get_template_directory() . '/assets/js/runtime/*') ?: [] as $file) {
|
||||
if (is_dir($file)) rmdir($file);
|
||||
}
|
||||
rmdir(get_template_directory() . '/assets/js/runtime');
|
||||
rmdir(get_template_directory() . '/assets/js');
|
||||
rmdir(get_template_directory() . '/assets');
|
||||
rmdir(get_template_directory());
|
||||
|
||||
echo json_encode([
|
||||
'versions' => $versions,
|
||||
'expected' => [(string) ($baseTime + 30), (string) ($baseTime + 40), (string) ($baseTime + 50), AKSRT_VERSION],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
@@ -308,8 +308,10 @@ test('payment entry points never load the removed legacy pay runtime', () => {
|
||||
assert.match(content, /data-aksrt-pay-content/);
|
||||
assert.match(scripts, /'aksrt-pay-vip'/);
|
||||
assert.match(scripts, /'aksrt-cms-download'/);
|
||||
assert.match(scripts, /\$runtime_main_path = get_template_directory\(\) \. '\/assets\/js\/runtime\/main\.min\.js'/);
|
||||
assert.match(scripts, /\$runtime_version = file_exists\(\$runtime_main_path\)/);
|
||||
// Every lazy module shares _win.ver, so changing any runtime file must
|
||||
// invalidate the URL. Watching main.min.js alone misses rebuilt leaves.
|
||||
assert.match(scripts, /\$runtime_mtimes = array_map\(\s*'filemtime',\s*glob\(get_template_directory\(\) \. '\/assets\/js\/runtime\/\*\.js'\) \?: \[\]/);
|
||||
assert.match(scripts, /\$runtime_version = \$runtime_mtimes !== \[\] \? \(string\) max\(\$runtime_mtimes\) : \$version/);
|
||||
assert.match(scripts, /'ver'\s*=> \$runtime_version/);
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 3) 弹窗底部提示句与整条 help_text 管线已移除,顶部说明 desc 保留;
|
||||
* 4) 积分/钱包余额合并为一次 LedgerService::points_and_balance();
|
||||
* 5) 访问统计写库移到 shutdown,wp_head 只做访客判定与 setcookie;
|
||||
* 6) header-userbox 样式仅登录用户入队。
|
||||
* 6) header-userbox 样式由公共样式包提供,避免单独重复入队。
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
@@ -129,6 +129,12 @@ test('访问统计写库移出渲染关键路径', () => {
|
||||
assert.match(recordStats, /cleanup_old_stats\(\)/);
|
||||
});
|
||||
|
||||
test('header-userbox 样式仅登录用户入队', () => {
|
||||
assert.match(scripts, /if \(is_user_logged_in\(\)\) \{\s*\$enqueue_module\('header-userbox'\);/);
|
||||
test('header-userbox styles ship once in the shared public bundle', () => {
|
||||
const builder = read('tools', 'build-public-assets.mjs');
|
||||
const bundle = read('assets', 'css', 'build', 'public.css');
|
||||
assert.strictEqual((builder.match(/'assets\/css\/frontend\/header-userbox\.css'/g) || []).length, 1);
|
||||
assert.strictEqual((bundle.match(/\/\* source: assets\/css\/frontend\/header-userbox\.css \*\//g) || []).length, 1);
|
||||
assert.match(bundle, /\.aksrt-header-userbox\s*\{/);
|
||||
assert.match(scripts, /wp_enqueue_style\(\s*'aksrt-public',[\s\S]*?\['aksrt-main'\]/);
|
||||
assert.doesNotMatch(scripts, /\$enqueue_module\('header-userbox'\)/);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '../..')
|
||||
const core = require(path.join(root, 'assets/widgets/admin-core.js'))
|
||||
const admin = fs.readFileSync(path.join(root, 'assets/widgets/admin.js'), 'utf8')
|
||||
const controller = fs.readFileSync(path.join(root, 'src/WidgetSystem/AdminFormController.php'), 'utf8')
|
||||
const transfer = fs.readFileSync(path.join(root, 'src/WidgetSystem/TransferController.php'), 'utf8')
|
||||
const renderer = fs.readFileSync(path.join(root, 'src/WidgetSystem/FormRenderer.php'), 'utf8')
|
||||
const styles = fs.readFileSync(path.join(root, 'assets/widgets/admin.css'), 'utf8')
|
||||
|
||||
test('lazy widget forms resolve assigned instances across core and customizer markup', () => {
|
||||
assert.equal(core.resolveWidgetNumber('widget-aksrt_widget_posts[12][title]', 'widget-aksrt_widget_posts-12', '__i__'), '12')
|
||||
assert.equal(core.resolveWidgetNumber('', 'widget-aksrt_widget_posts-8', '__i__'), '8')
|
||||
assert.equal(core.resolveWidgetNumber('', '', '4'), '4')
|
||||
assert.equal(core.resolveWidgetNumber('', '', '__i__'), '')
|
||||
assert.equal(core.resolveWidgetNumber('', 'widget-aksrt_widget_posts-0', ''), '')
|
||||
assert.match(admin, /customize-control-widget_form \.aksrt-widget-form-lazy/)
|
||||
})
|
||||
|
||||
test('lazy form AJAX failures remain retryable and instance filters match WordPress widget forms', () => {
|
||||
assert.match(admin, /data\('loading', false\)/)
|
||||
assert.match(controller, /apply_filters\('widget_form_callback', \$instance, \$widget\)/)
|
||||
})
|
||||
|
||||
test('sidebar toolbars open one contextual import, export, or template dialog', () => {
|
||||
assert.match(admin, /widgets-holder-wrap:not\(#available-widgets\)/)
|
||||
assert.match(admin, /control-section-sidebar/)
|
||||
assert.match(admin, /data-aksrt-transfer-action/)
|
||||
assert.match(admin, /data-sidebar-id/)
|
||||
assert.match(admin, /data-transfer-tab/)
|
||||
assert.match(transfer, /aksrt-widget-transfer-modal__dialog/)
|
||||
assert.match(transfer, /aksrt_widgets_template_preview/)
|
||||
assert.doesNotMatch(transfer, /aksrt-widget-transfer__grid/)
|
||||
assert.doesNotMatch(transfer, /function sidebar_select/)
|
||||
assert.match(admin, /aksrt-widget-customizer-remove/)
|
||||
})
|
||||
|
||||
test('widget fields follow the current CSF compact palette and cloneable group patterns', () => {
|
||||
assert.match(renderer, /aksrt-widget-palette-choice/)
|
||||
assert.match(renderer, /<details class="aksrt-widget-repeater__row"/)
|
||||
assert.match(renderer, /data-min-items=/)
|
||||
assert.match(admin, /function updateRepeaterControls/)
|
||||
assert.match(styles, /\.aksrt-widget-choices--palette/)
|
||||
assert.match(styles, /\.aksrt-widget-repeater__bar/)
|
||||
assert.match(styles, /\.customize-control-widget_form \.aksrt-widget-field__label/)
|
||||
assert.match(admin, /if \(!response\?\.success \|\| !response\.data\) \{/)
|
||||
assert.match(admin, /responseJSON\?\.data\?\.message/)
|
||||
assert.doesNotMatch(admin, /if \(!response\?\.success\) throw new Error/)
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
|
||||
class Element {
|
||||
constructor(tag = 'div') {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attributes = {};
|
||||
this.disabled = false;
|
||||
this.hidden = false;
|
||||
this.listeners = {};
|
||||
this._textContent = '';
|
||||
this.className = '';
|
||||
this.classList = {
|
||||
values: new Set(),
|
||||
add: (...values) => values.forEach((value) => this.classList.values.add(value)),
|
||||
};
|
||||
}
|
||||
|
||||
set textContent(value) {
|
||||
this._textContent = String(value);
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
get textContent() {
|
||||
return this._textContent + this.children.map((child) => child.textContent || '').join('');
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
addEventListener(type, callback) {
|
||||
this.listeners[type] = callback;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
}
|
||||
|
||||
closest(selector) {
|
||||
return selector === '[data-aksrt-withdraw-history-page]' && this.dataset.aksrtWithdrawHistoryPage
|
||||
? this
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function textContent(node) {
|
||||
return node.textContent;
|
||||
}
|
||||
|
||||
test('withdrawal history requests paged ten-item results and renders the returned page', async () => {
|
||||
const requests = [];
|
||||
const pagePayloads = {
|
||||
1: {
|
||||
items: [{ status: 'completed', gross_amount: '10.00', amount: '9.50', withdrawal_number: 'W1', created_at: '2026-10-01', note: 'private note' }],
|
||||
page: 1,
|
||||
max_pages: 2,
|
||||
},
|
||||
2: {
|
||||
items: [{ status: 'pending', gross_amount: '20.00', amount: '20.00', withdrawal_number: 'W2', created_at: '2026-10-02' }],
|
||||
page: 2,
|
||||
max_pages: 2,
|
||||
},
|
||||
};
|
||||
const list = new Element();
|
||||
const pagination = new Element('nav');
|
||||
const closeButtons = [];
|
||||
const modal = new Element();
|
||||
modal.querySelector = (selector) => selector === '[data-aksrt-withdraw-history-list]' ? list : pagination;
|
||||
modal.querySelectorAll = () => closeButtons;
|
||||
const openButton = new Element('button');
|
||||
const document = {
|
||||
body: { classList: { add() {}, remove() {} } },
|
||||
querySelector: (selector) => selector === '[data-aksrt-withdraw-history-modal]' ? modal : null,
|
||||
querySelectorAll: (selector) => selector === '[data-aksrt-withdraw-history-open]' ? [openButton] : [],
|
||||
createElement: (tag) => new Element(tag),
|
||||
createTextNode: (text) => ({ textContent: String(text) }),
|
||||
};
|
||||
let init;
|
||||
const jQuery = () => ({
|
||||
off() { return this; },
|
||||
on(_event, callback) { init = callback; return this; },
|
||||
});
|
||||
const fetch = (url) => {
|
||||
requests.push(url);
|
||||
const page = Number(new URL(url).searchParams.get('page'));
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(pagePayloads[page]),
|
||||
});
|
||||
};
|
||||
const source = fs.readFileSync(path.join(root, 'assets/js/credits-withdraw-history.js'), 'utf8');
|
||||
|
||||
vm.runInNewContext(source, {
|
||||
window: { aksrtData: { restUrl: 'https://example.test/wp-json/aksrt/v1', restNonce: 'nonce' } },
|
||||
document,
|
||||
jQuery,
|
||||
fetch,
|
||||
Array,
|
||||
Number,
|
||||
String,
|
||||
Math,
|
||||
parseInt,
|
||||
Promise,
|
||||
Error,
|
||||
});
|
||||
|
||||
assert.equal(typeof init, 'function', 'module registers its lifecycle initializer');
|
||||
init();
|
||||
openButton.listeners.click({ preventDefault() {} });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(new URL(requests[0]).pathname, '/wp-json/aksrt/v1/earnings/withdrawals');
|
||||
assert.equal(new URL(requests[0]).searchParams.get('page'), '1');
|
||||
assert.equal(new URL(requests[0]).searchParams.get('per_page'), '10');
|
||||
assert.match(textContent(list.children[0]), /实际到账:9\.50元/);
|
||||
assert.doesNotMatch(textContent(list.children[0]), /private note/);
|
||||
|
||||
const pageTwo = pagination.children.find((button) => button.dataset.aksrtWithdrawHistoryPage === '2');
|
||||
assert.ok(pageTwo, 'the API max_pages value creates a second-page control');
|
||||
pagination.listeners.click({ target: pageTwo, preventDefault() {} });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(new URL(requests[1]).searchParams.get('page'), '2');
|
||||
assert.equal(new URL(requests[1]).searchParams.get('per_page'), '10');
|
||||
assert.match(textContent(list.children[0]), /W2/);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const test = require('node:test');
|
||||
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');
|
||||
|
||||
test('article and modern widget yiyan use the reference bilingual quote source and interactions', () => {
|
||||
const helper = read('inc/helpers/aksrt-yiyan.php');
|
||||
const client = read('assets/js/yiyan.js');
|
||||
const widgetClient = read('assets/widgets/frontend.js');
|
||||
const renderer = read('src/WidgetSystem/Renderers/GeneralRenderer.php');
|
||||
const rest = read('src/WidgetSystem/RestController.php');
|
||||
const schema = read('src/WidgetSystem/WidgetSchemas.php');
|
||||
const widgetStyles = read('assets/widgets/frontend.css');
|
||||
const catalog = read('yiyan/qv-yiyan.txt').split(/\r?\n/).filter(Boolean);
|
||||
|
||||
assert.match(helper, /WP_CONTENT_DIR\s*\.\s*'\/yiyan\.txt'/);
|
||||
assert.match(helper, /\/yiyan\/qv-yiyan\.txt/);
|
||||
assert.match(helper, /wp_ajax_nopriv_aksrt_yiyan/);
|
||||
assert.match(helper, /'en'\s*=>\s*\$lines\[\$i\],\s*'cn'\s*=>\s*\$lines\[\$i\s*\+\s*1\]/);
|
||||
assert.match(helper, /class="yiyan"><div class="en"><\/div><div class="cn">/);
|
||||
assert.match(client, /REFRESH_MS\s*=\s*30000/);
|
||||
assert.match(client, /on\('auto_fun\.aksrt-yiyan', function \(\) \{\s*init\(document\)/);
|
||||
assert.match(client, /event\.key === 'Enter' \|\| event\.key === ' '/);
|
||||
assert.match(widgetClient, /window\.AksrtYiyan\.init\(figure\)/);
|
||||
assert.match(renderer, /aksrt_get_yiyan_markup\(\['class' => 'aksrt-widget-quote'\]\)/);
|
||||
assert.doesNotMatch(rest, /v1\.hitokoto\.cn|function get_quote/);
|
||||
assert.match(schema, /case 'quote':\s*return \[\];/);
|
||||
assert.match(widgetStyles, /\.aksrt-widget-quote \.yiyan \.en/);
|
||||
assert.ok(catalog.length > 10, 'the bundled quote file should contain a useful quote set');
|
||||
assert.equal((catalog.length - 1) % 2, 0, 'after the title, quote lines must form English/Chinese pairs');
|
||||
});
|
||||
Reference in new issue
Block a user