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:
kate522 committed 2026-10-29 19:16:03 +08:00
1 parent b520d29368
commit 8aa116ec43
63 files changed
+1985 -2304

No files matched your search

@@ -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', () => {
+6 -2
View File
@@ -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);
+4 -2
View File
@@ -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');
});