feat(widgets): add phase 4 validation and accessibility

This commit is contained in:
2026-08-17 19:12:38 +08:00
parent 31e72748c3
commit 009b66326a
23 changed files with 8078 additions and 53 deletions
+2 -2
View File
@@ -34,7 +34,7 @@
## Phase 4 — 自动化与视觉终验 ## Phase 4 — 自动化与视觉终验
- [ ] 完成 schema、查询、可见性、导入导出、REST 与支付适配测试。 - [x] 完成 schema、查询、可见性、导入导出、REST 与支付适配测试。
- [ ] 生成参考源码黄金 fixture,并完成 1440/1024/390、明暗模式截图矩阵。 - [ ] 生成参考源码黄金 fixture,并完成 1440/1024/390、明暗模式截图矩阵。
- [ ] 验证交互、无障碍、控制台、网络和像素差异阈值。 - [ ] 验证交互、无障碍、控制台、网络和像素差异阈值。
- [ ] 复跑 WordPress 项目检测、全量 PHP 检查和管理端契约审计。 - [x] 复跑 WordPress 项目检测、全量 PHP 检查和管理端契约审计。
+44
View File
@@ -0,0 +1,44 @@
(function (root, factory) {
'use strict';
var api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.AKSRTWidgetAdminCore = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
function parseDependency(raw) {
if (Array.isArray(raw)) return raw;
try {
var parsed = JSON.parse(String(raw || '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
}
function dependencyVisible(rule, current) {
rule = parseDependency(rule);
if (rule.length < 3) return true;
var expected = String(rule[2]);
var actual = String(current == null ? '' : current);
return rule[1] === '==' ? actual === expected : actual !== expected;
}
function replaceRepeaterIndexes(name, rowIndexes) {
var foundWidgetNumber = false;
var cursor = 0;
return String(name || '').replace(/\[(\d+|__index_[a-f0-9]+__)\]/g, function (match, index) {
if (!foundWidgetNumber && /^\d+$/.test(index)) {
foundWidgetNumber = true;
return match;
}
return cursor < rowIndexes.length ? '[' + Number(rowIndexes[cursor++]) + ']' : match;
});
}
return {
parseDependency: parseDependency,
dependencyVisible: dependencyVisible,
replaceRepeaterIndexes: replaceRepeaterIndexes
};
});
+4 -15
View File
@@ -2,6 +2,7 @@
'use strict'; 'use strict';
const config = window.aksrtWidgetAdmin || {}; const config = window.aksrtWidgetAdmin || {};
const core = window.AKSRTWidgetAdminCore;
function initForm(root) { function initForm(root) {
const $root = $(root); const $root = $(root);
@@ -68,15 +69,7 @@
$form.find('[name]').each(function () { $form.find('[name]').each(function () {
const rowIndexes = $(this).parents('.aksrt-widget-repeater__row').get().reverse().map((row) => Number(row.dataset.rowIndex || 0)); const rowIndexes = $(this).parents('.aksrt-widget-repeater__row').get().reverse().map((row) => Number(row.dataset.rowIndex || 0));
if (!rowIndexes.length) return; if (!rowIndexes.length) return;
let foundWidgetNumber = false; this.name = core.replaceRepeaterIndexes(this.name, rowIndexes);
let cursor = 0;
this.name = this.name.replace(/\[(\d+|__index_[a-f0-9]+__)\]/g, (match, index) => {
if (!foundWidgetNumber && /^\d+$/.test(index)) {
foundWidgetNumber = true;
return match;
}
return cursor < rowIndexes.length ? `[${rowIndexes[cursor++]}]` : match;
});
}); });
} }
@@ -89,14 +82,10 @@
function evaluateDependencies($form) { function evaluateDependencies($form) {
$form.find('[data-dependency]').each(function () { $form.find('[data-dependency]').each(function () {
let rule; const rule = core.parseDependency(this.dataset.dependency || '[]');
try { rule = JSON.parse(this.dataset.dependency || '[]'); } catch (_error) { return; }
if (!Array.isArray(rule) || rule.length < 3) return;
const $row = $(this).closest('.aksrt-widget-repeater__row'); const $row = $(this).closest('.aksrt-widget-repeater__row');
const current = fieldValue($row.length ? $row : $form, String(rule[0])); const current = fieldValue($row.length ? $row : $form, String(rule[0]));
const expected = String(rule[2]); this.hidden = !core.dependencyVisible(rule, current);
const visible = rule[1] === '==' ? current === expected : current !== expected;
this.hidden = !visible;
}); });
} }
+7
View File
@@ -15,6 +15,9 @@
.aksrt-widget img, .aksrt-widget video { max-width: 100%; } .aksrt-widget img, .aksrt-widget video { max-width: 100%; }
.aksrt-widget a { color: inherit; text-decoration: none; } .aksrt-widget a { color: inherit; text-decoration: none; }
.aksrt-widget button, .aksrt-widget input { font: inherit; } .aksrt-widget button, .aksrt-widget input { font: inherit; }
.aksrt-widget :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline: 3px solid var(--focus-color, #ff5473) !important; outline-offset: 3px; }
.dark-theme .aksrt-widget :where(a, button, input, select, textarea, [tabindex]):focus-visible,
[data-theme="dark"] .aksrt-widget :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline-color: #ffd166 !important; }
.aksrt-widget__header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 16px; } .aksrt-widget__header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.aksrt-widget__header--big { display: block; text-align: center; margin-bottom: 30px; } .aksrt-widget__header--big { display: block; text-align: center; margin-bottom: 30px; }
.aksrt-widget__title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 700; } .aksrt-widget__title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 700; }
@@ -96,6 +99,7 @@
.aksrt-widget-comments { display: grid; gap: 15px; margin: 0; padding: 0; list-style: none; }.aksrt-widget-comments li { display: flex; gap: 11px; }.aksrt-widget-comments__avatar img { width: 42px; height: 42px; border-radius: 50%; }.aksrt-widget-comments p { margin: 3px 0; color: var(--muted-color,#8a8c98); font-size: 13px; line-height: 1.55; }.aksrt-widget-comments__source { font-size: 12px; opacity: .65; } .aksrt-widget-comments { display: grid; gap: 15px; margin: 0; padding: 0; list-style: none; }.aksrt-widget-comments li { display: flex; gap: 11px; }.aksrt-widget-comments__avatar img { width: 42px; height: 42px; border-radius: 50%; }.aksrt-widget-comments p { margin: 3px 0; color: var(--muted-color,#8a8c98); font-size: 13px; line-height: 1.55; }.aksrt-widget-comments__source { font-size: 12px; opacity: .65; }
.aksrt-widget-links { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; }.aksrt-widget-links a { display: flex; flex-direction: column; gap: 4px; padding: 14px; border-radius: 9px; background: var(--main-bg,#fff); box-shadow: 0 6px 20px rgba(31,35,48,.06); }.aksrt-widget-links small { color: var(--muted-color,#8a8c98); }.aksrt-widget-links--simple { display: flex; flex-wrap: wrap; }.aksrt-widget-links--simple a { padding: 5px 9px; box-shadow: none; background: transparent; } .aksrt-widget-links { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; }.aksrt-widget-links a { display: flex; flex-direction: column; gap: 4px; padding: 14px; border-radius: 9px; background: var(--main-bg,#fff); box-shadow: 0 6px 20px rgba(31,35,48,.06); }.aksrt-widget-links small { color: var(--muted-color,#8a8c98); }.aksrt-widget-links--simple { display: flex; flex-wrap: wrap; }.aksrt-widget-links--simple a { padding: 5px 9px; box-shadow: none; background: transparent; }
.aksrt-widget-notice { display: flex; height: 48px; align-items: center; gap: 12px; padding: 0 16px; overflow: hidden; border-radius: 9px; background: var(--main-bg,#fff); box-shadow: 0 7px 22px rgba(31,35,48,.06); }.aksrt-widget-notice .swiper { height: 48px; flex: 1; }.aksrt-widget-notice .swiper-slide, .aksrt-widget-notice .swiper-slide a { display: flex; height: 48px; align-items: center; gap: 10px; }.aksrt-widget-notice .swiper-slide span { overflow: hidden; color: var(--muted-color,#8a8c98); text-overflow: ellipsis; white-space: nowrap; } .aksrt-widget-notice { display: flex; height: 48px; align-items: center; gap: 12px; padding: 0 16px; overflow: hidden; border-radius: 9px; background: var(--main-bg,#fff); box-shadow: 0 7px 22px rgba(31,35,48,.06); }.aksrt-widget-notice .swiper { height: 48px; flex: 1; }.aksrt-widget-notice .swiper-slide, .aksrt-widget-notice .swiper-slide a { display: flex; height: 48px; align-items: center; gap: 10px; }.aksrt-widget-notice .swiper-slide span { overflow: hidden; color: var(--muted-color,#8a8c98); text-overflow: ellipsis; white-space: nowrap; }
.aksrt-widget-swiper { position: relative; }.aksrt-widget-swiper__pause { position: absolute; z-index: 6; top: 10px; right: 10px; display: inline-flex; width: 34px; height: 34px; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,.45); border-radius: 50%; background: rgba(20,21,27,.72); color: #fff; cursor: pointer; }.aksrt-widget-notice .aksrt-widget-swiper__pause { top: 7px; right: 4px; width: 30px; height: 30px; }
.aksrt-term-card { display: block; overflow: hidden; border-radius: var(--aksrt-widget-radius); background: var(--main-bg,#fff); box-shadow: 0 8px 28px rgba(31,35,48,.07); }.aksrt-term-card__media { display: block; aspect-ratio: 16/9; background: linear-gradient(135deg,#667eea,#764ba2); }.aksrt-term-card__media img { width: 100%; height: 100%; object-fit: cover; }.aksrt-term-card__body { display: flex; flex-direction: column; gap: 5px; padding: 14px; }.aksrt-term-card__body small { min-height: 38px; color: var(--muted-color,#8a8c98); }.aksrt-term-card__body em { font-size: 12px; color: var(--focus-color,#ff5473); font-style: normal; } .aksrt-term-card { display: block; overflow: hidden; border-radius: var(--aksrt-widget-radius); background: var(--main-bg,#fff); box-shadow: 0 8px 28px rgba(31,35,48,.07); }.aksrt-term-card__media { display: block; aspect-ratio: 16/9; background: linear-gradient(135deg,#667eea,#764ba2); }.aksrt-term-card__media img { width: 100%; height: 100%; object-fit: cover; }.aksrt-term-card__body { display: flex; flex-direction: column; gap: 5px; padding: 14px; }.aksrt-term-card__body small { min-height: 38px; color: var(--muted-color,#8a8c98); }.aksrt-term-card__body em { font-size: 12px; color: var(--focus-color,#ff5473); font-style: normal; }
.aksrt-term-aggregation { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }.aksrt-term-aggregation__card { padding: 16px; border-radius: var(--aksrt-widget-radius); background: var(--main-bg,#fff); box-shadow: 0 8px 28px rgba(31,35,48,.07); }.aksrt-term-aggregation header a { display: flex; justify-content: space-between; }.aksrt-term-aggregation header span { color: var(--muted-color,#8a8c98); font-size: 13px; }.aksrt-term-aggregation ul { display: grid; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }.aksrt-term-aggregation li a { display: flex; align-items: center; gap: 10px; }.aksrt-term-aggregation li img { width: 55px; height: 42px; border-radius: 6px; object-fit: cover; } .aksrt-term-aggregation { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }.aksrt-term-aggregation__card { padding: 16px; border-radius: var(--aksrt-widget-radius); background: var(--main-bg,#fff); box-shadow: 0 8px 28px rgba(31,35,48,.07); }.aksrt-term-aggregation header a { display: flex; justify-content: space-between; }.aksrt-term-aggregation header span { color: var(--muted-color,#8a8c98); font-size: 13px; }.aksrt-term-aggregation ul { display: grid; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }.aksrt-term-aggregation li a { display: flex; align-items: center; gap: 10px; }.aksrt-term-aggregation li img { width: 55px; height: 42px; border-radius: 6px; object-fit: cover; }
@@ -131,5 +135,8 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.aksrt-widget *, .aksrt-widget *::before, .aksrt-widget *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } .aksrt-widget *, .aksrt-widget *::before, .aksrt-widget *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
} }
@media (forced-colors: active) {
.aksrt-widget :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline-color: Highlight !important; }
}
.aksrt-widget--animate { animation: aksrt-widget-fade .55s both; }.aksrt-widget--animate-slideup { animation-name: aksrt-widget-slideup; }.aksrt-widget--animate-slidedown { animation-name: aksrt-widget-slidedown; }.aksrt-widget--animate-slideright { animation-name: aksrt-widget-slideright; }.aksrt-widget--animate-slideleft { animation-name: aksrt-widget-slideleft; }.aksrt-widget--animate-zoomin { animation-name: aksrt-widget-zoomin; }.aksrt-widget--animate-zoomout { animation-name: aksrt-widget-zoomout; }.aksrt-widget--animate-repeat { animation-iteration-count: infinite; animation-duration: 2s; } .aksrt-widget--animate { animation: aksrt-widget-fade .55s both; }.aksrt-widget--animate-slideup { animation-name: aksrt-widget-slideup; }.aksrt-widget--animate-slidedown { animation-name: aksrt-widget-slidedown; }.aksrt-widget--animate-slideright { animation-name: aksrt-widget-slideright; }.aksrt-widget--animate-slideleft { animation-name: aksrt-widget-slideleft; }.aksrt-widget--animate-zoomin { animation-name: aksrt-widget-zoomin; }.aksrt-widget--animate-zoomout { animation-name: aksrt-widget-zoomout; }.aksrt-widget--animate-repeat { animation-iteration-count: infinite; animation-duration: 2s; }
@keyframes aksrt-widget-fade { from { opacity: 0; } }@keyframes aksrt-widget-slideup { from { opacity:0;transform:translateY(25px); } }@keyframes aksrt-widget-slidedown { from { opacity:0;transform:translateY(-25px); } }@keyframes aksrt-widget-slideright { from { opacity:0;transform:translateX(-25px); } }@keyframes aksrt-widget-slideleft { from { opacity:0;transform:translateX(25px); } }@keyframes aksrt-widget-zoomin { from { opacity:0;transform:scale(.9); } }@keyframes aksrt-widget-zoomout { from { opacity:0;transform:scale(1.1); } } @keyframes aksrt-widget-fade { from { opacity: 0; } }@keyframes aksrt-widget-slideup { from { opacity:0;transform:translateY(25px); } }@keyframes aksrt-widget-slidedown { from { opacity:0;transform:translateY(-25px); } }@keyframes aksrt-widget-slideright { from { opacity:0;transform:translateX(-25px); } }@keyframes aksrt-widget-slideleft { from { opacity:0;transform:translateX(25px); } }@keyframes aksrt-widget-zoomin { from { opacity:0;transform:scale(.9); } }@keyframes aksrt-widget-zoomout { from { opacity:0;transform:scale(1.1); } }
+73 -4
View File
@@ -11,6 +11,59 @@
return true; return true;
} }
function moveTabFocus(event, root) {
var current = event.target.closest('[role="tab"]');
if (!current) return;
var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
var index = tabs.indexOf(current);
var next = index;
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = tabs.length - 1;
else return;
event.preventDefault();
tabs[next].focus();
tabs[next].click();
}
function initSwiperPause(el, swiper) {
if (!swiper.autoplay) return;
var i18n = config.i18n || {};
var manuallyPaused = false;
var button = document.createElement('button');
button.type = 'button';
button.className = 'aksrt-widget-swiper__pause';
button.setAttribute('aria-pressed', 'false');
function pause() {
if (typeof swiper.autoplay.pause === 'function') swiper.autoplay.pause();
else if (typeof swiper.autoplay.stop === 'function') swiper.autoplay.stop();
}
function resume() {
if (typeof swiper.autoplay.resume === 'function') swiper.autoplay.resume();
else if (typeof swiper.autoplay.start === 'function') swiper.autoplay.start();
}
function updateButton() {
button.setAttribute('aria-pressed', manuallyPaused ? 'true' : 'false');
button.setAttribute('aria-label', manuallyPaused ? (i18n.resume || '继续自动播放') : (i18n.pause || '暂停自动播放'));
button.textContent = manuallyPaused ? '▶' : 'Ⅱ';
}
button.addEventListener('click', function () {
manuallyPaused = !manuallyPaused;
if (manuallyPaused) pause();
else resume();
updateButton();
});
el.addEventListener('focusin', pause);
el.addEventListener('focusout', function (event) {
if (!manuallyPaused && !el.contains(event.relatedTarget)) resume();
});
updateButton();
el.appendChild(button);
}
function initSwiper(el) { function initSwiper(el) {
if (!once(el, 'Swiper') || typeof window.Swiper !== 'function') return; if (!once(el, 'Swiper') || typeof window.Swiper !== 'function') return;
var kind = el.dataset.aksrtSwiper || 'cards'; var kind = el.dataset.aksrtSwiper || 'cards';
@@ -37,11 +90,13 @@
options.effect = el.dataset.effect || 'slide'; options.effect = el.dataset.effect || 'slide';
options.parallax = !!el.querySelector('[data-swiper-parallax]'); options.parallax = !!el.querySelector('[data-swiper-parallax]');
} }
if (!reducedMotion && (el.dataset.autoplay === '1' || kind === 'notice')) { var shouldAutoplay = !reducedMotion && (el.dataset.autoplay === '1' || kind === 'notice');
if (shouldAutoplay) {
options.autoplay = { delay: Number(el.dataset.interval || 4000), disableOnInteraction: false, pauseOnMouseEnter: true }; options.autoplay = { delay: Number(el.dataset.interval || 4000), disableOnInteraction: false, pauseOnMouseEnter: true };
} }
try { try {
el.aksrtSwiper = new window.Swiper(el, options); el.aksrtSwiper = new window.Swiper(el, options);
if (shouldAutoplay) initSwiperPause(el, el.aksrtSwiper);
} catch (error) { } catch (error) {
el.classList.add('is-fallback'); el.classList.add('is-fallback');
} }
@@ -161,6 +216,7 @@
if (!widget || !instance || !config.restUrl || container.dataset.loading === '1') return; if (!widget || !instance || !config.restUrl || container.dataset.loading === '1') return;
container.dataset.loading = '1'; container.dataset.loading = '1';
container.classList.add('is-loading'); container.classList.add('is-loading');
container.setAttribute('aria-busy', 'true');
fetch(itemsUrl(widget, instance, page, segment), { credentials: 'same-origin', headers: { Accept: 'application/json' } }) fetch(itemsUrl(widget, instance, page, segment), { credentials: 'same-origin', headers: { Accept: 'application/json' } })
.then(function (response) { if (!response.ok) throw new Error('items'); return response.json(); }) .then(function (response) { if (!response.ok) throw new Error('items'); return response.json(); })
.then(function (data) { .then(function (data) {
@@ -180,7 +236,11 @@
scan(container); scan(container);
}) })
.catch(function () { container.innerHTML = errorMarkup(); }) .catch(function () { container.innerHTML = errorMarkup(); })
.finally(function () { delete container.dataset.loading; container.classList.remove('is-loading'); }); .finally(function () {
delete container.dataset.loading;
container.classList.remove('is-loading');
container.setAttribute('aria-busy', 'false');
});
} }
function initItems(container) { function initItems(container) {
@@ -207,12 +267,17 @@
function initTabs(tabs) { function initTabs(tabs) {
if (!once(tabs, 'Tabs')) return; if (!once(tabs, 'Tabs')) return;
tabs.addEventListener('keydown', function (event) { moveTabFocus(event, tabs); });
tabs.addEventListener('click', function (event) { tabs.addEventListener('click', function (event) {
var button = event.target.closest('[data-segment]'); var button = event.target.closest('[data-segment]');
if (!button) return; if (!button) return;
tabs.querySelectorAll('[role="tab"]').forEach(function (tab) { tab.setAttribute('aria-selected', tab === button ? 'true' : 'false'); }); tabs.querySelectorAll('[role="tab"]').forEach(function (tab) {
tab.setAttribute('aria-selected', tab === button ? 'true' : 'false');
tab.setAttribute('tabindex', tab === button ? '0' : '-1');
});
var panel = tabs.querySelector('[data-widget-items]'); var panel = tabs.querySelector('[data-widget-items]');
panel.dataset.segment = button.dataset.segment; panel.dataset.segment = button.dataset.segment;
panel.setAttribute('aria-labelledby', button.id);
loadItems(panel, 1, false); loadItems(panel, 1, false);
}); });
} }
@@ -236,11 +301,15 @@
function initFeatures(features) { function initFeatures(features) {
if (!once(features, 'Features')) return; if (!once(features, 'Features')) return;
features.addEventListener('keydown', function (event) { moveTabFocus(event, features); });
features.addEventListener('click', function (event) { features.addEventListener('click', function (event) {
var button = event.target.closest('[data-feature-index]'); var button = event.target.closest('[data-feature-index]');
if (!button) return; if (!button) return;
var index = button.dataset.featureIndex; var index = button.dataset.featureIndex;
features.querySelectorAll('[data-feature-index]').forEach(function (tab) { tab.setAttribute('aria-selected', tab === button ? 'true' : 'false'); }); features.querySelectorAll('[data-feature-index]').forEach(function (tab) {
tab.setAttribute('aria-selected', tab === button ? 'true' : 'false');
tab.setAttribute('tabindex', tab === button ? '0' : '-1');
});
features.querySelectorAll('[data-feature-panel]').forEach(function (panel) { panel.hidden = panel.dataset.featurePanel !== index; }); features.querySelectorAll('[data-feature-panel]').forEach(function (panel) { panel.hidden = panel.dataset.featurePanel !== index; });
}); });
} }
+2 -1
View File
@@ -28,7 +28,8 @@ final class AdminFormController {
wp_enqueue_script('jquery-ui-sortable'); wp_enqueue_script('jquery-ui-sortable');
wp_enqueue_style('aksrt-widget-admin', AKSRT_URI . '/assets/widgets/admin.css', ['wp-color-picker'], AKSRT_VERSION); wp_enqueue_style('aksrt-widget-admin', AKSRT_URI . '/assets/widgets/admin.css', ['wp-color-picker'], AKSRT_VERSION);
wp_enqueue_style('aksrt-widget-preview', AKSRT_URI . '/assets/widgets/frontend.css', [], AKSRT_VERSION); wp_enqueue_style('aksrt-widget-preview', AKSRT_URI . '/assets/widgets/frontend.css', [], AKSRT_VERSION);
wp_enqueue_script('aksrt-widget-admin', AKSRT_URI . '/assets/widgets/admin.js', ['jquery', 'jquery-ui-sortable', 'wp-color-picker'], AKSRT_VERSION, true); wp_enqueue_script('aksrt-widget-admin-core', AKSRT_URI . '/assets/widgets/admin-core.js', [], AKSRT_VERSION, true);
wp_enqueue_script('aksrt-widget-admin', AKSRT_URI . '/assets/widgets/admin.js', ['jquery', 'jquery-ui-sortable', 'wp-color-picker', 'aksrt-widget-admin-core'], AKSRT_VERSION, true);
wp_localize_script('aksrt-widget-admin', 'aksrtWidgetAdmin', [ wp_localize_script('aksrt-widget-admin', 'aksrtWidgetAdmin', [
'ajaxUrl' => admin_url('admin-ajax.php'), 'ajaxUrl' => admin_url('admin-ajax.php'),
'action' => self::AJAX_ACTION, 'action' => self::AJAX_ACTION,
+2
View File
@@ -33,6 +33,8 @@ final class AssetManager {
'loading' => __('正在加载…', 'aksrt'), 'loading' => __('正在加载…', 'aksrt'),
'error' => __('加载失败,请重试。', 'aksrt'), 'error' => __('加载失败,请重试。', 'aksrt'),
'retry' => __('重试', 'aksrt'), 'retry' => __('重试', 'aksrt'),
'pause' => __('暂停自动播放', 'aksrt'),
'resume' => __('继续自动播放', 'aksrt'),
], ],
]; ];
wp_add_inline_script('aksrt-widgets', 'window.aksrtWidgets=' . wp_json_encode($config) . ';', 'before'); wp_add_inline_script('aksrt-widgets', 'window.aksrtWidgets=' . wp_json_encode($config) . ';', 'before');
+4 -16
View File
@@ -9,7 +9,7 @@ defined('ABSPATH') || exit;
*/ */
final class FrontendRenderer { final class FrontendRenderer {
public static function render(WidgetDefinition $definition, array $args, array $instance, SchemaWidget $widget): void { public static function render(WidgetDefinition $definition, array $args, array $instance, SchemaWidget $widget): void {
if (!self::is_visible($instance)) { if (!WidgetVisibility::should_render($instance)) {
return; return;
} }
@@ -24,10 +24,9 @@ final class FrontendRenderer {
AssetManager::enqueue_for($definition); AssetManager::enqueue_for($definition);
$classes = ['aksrt-widget', 'aksrt-widget--' . $definition->slug(), 'aksrt-widget--' . $definition->size()]; $classes = ['aksrt-widget', 'aksrt-widget--' . $definition->slug(), 'aksrt-widget--' . $definition->size()];
if (($instance['show_type'] ?? 'all') === 'only_pc') { $device_class = WidgetVisibility::device_class($instance);
$classes[] = 'aksrt-widget--only-pc'; if ($device_class !== '') {
} elseif (($instance['show_type'] ?? 'all') === 'only_sm') { $classes[] = $device_class;
$classes[] = 'aksrt-widget--only-mobile';
} }
if (!empty($instance['sidebar_affix'])) { if (!empty($instance['sidebar_affix'])) {
$classes[] = 'aksrt-widget--affix'; $classes[] = 'aksrt-widget--affix';
@@ -49,17 +48,6 @@ final class FrontendRenderer {
echo (string) ($args['after_widget'] ?? '</section>'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo (string) ($args['after_widget'] ?? '</section>'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
} }
private static function is_visible(array $instance): bool {
$type = (string) ($instance['show_id_type'] ?? '');
$ids = RendererHelpers::ids((string) ($instance['show_ids'] ?? ''));
if ($type === '' || !$ids) {
return true;
}
$current = function_exists('is_singular') && is_singular() && function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0;
$matched = in_array($current, $ids, true);
return $type === 'show' ? $matched : !$matched;
}
private static function title(array $instance): string { private static function title(array $instance): string {
$title = trim((string) ($instance['title'] ?? '')); $title = trim((string) ($instance['title'] ?? ''));
if ($title === '') { if ($title === '') {
@@ -97,10 +97,10 @@ final class ContentRenderer {
private static function posts(string $slug, array $instance, array $context): string { private static function posts(string $slug, array $instance, array $context): string {
$widget_id = (string) ($context['widget_id'] ?? ''); $widget_id = (string) ($context['widget_id'] ?? '');
if (($instance['load_mode'] ?? 'sync') === 'ajax') { if (($instance['load_mode'] ?? 'sync') === 'ajax') {
return '<div class="aksrt-widget-items is-loading" data-widget-items data-widget="' . esc_attr($slug) . '" data-instance="' . esc_attr($widget_id) . '" data-page="1"><div class="aksrt-widget-skeleton" aria-label="' . esc_attr__('正在加载', 'aksrt') . '"></div></div>'; return '<div class="aksrt-widget-items is-loading" data-widget-items data-widget="' . esc_attr($slug) . '" data-instance="' . esc_attr($widget_id) . '" data-page="1" aria-live="polite" aria-busy="true"><div class="aksrt-widget-skeleton" role="status" aria-label="' . esc_attr__('正在加载', 'aksrt') . '"></div></div>';
} }
$payload = PostRenderer::payload($instance, $slug, 1); $payload = PostRenderer::payload($instance, $slug, 1);
return '<div class="aksrt-widget-items" data-widget-items data-widget="' . esc_attr($slug) . '" data-instance="' . esc_attr($widget_id) . '" data-page="1">' . $payload['html'] . $payload['paginate_html'] . '</div>'; return '<div class="aksrt-widget-items" data-widget-items data-widget="' . esc_attr($slug) . '" data-instance="' . esc_attr($widget_id) . '" data-page="1" aria-live="polite" aria-busy="false">' . $payload['html'] . $payload['paginate_html'] . '</div>';
} }
private static function tab_posts(array $instance, array $context): string { private static function tab_posts(array $instance, array $context): string {
@@ -109,11 +109,15 @@ final class ContentRenderer {
return RendererHelpers::empty_state(__('请配置文章栏目', 'aksrt')); return RendererHelpers::empty_state(__('请配置文章栏目', 'aksrt'));
} }
$buttons = ''; $buttons = '';
$key = sanitize_html_class((string) ($context['widget_id'] ?? 'preview')) ?: 'preview';
$panel_id = 'aksrt-tab-panel-' . $key;
$first_tab_id = 'aksrt-tab-' . $key . '-0';
foreach ($tabs as $index => $tab) { foreach ($tabs as $index => $tab) {
$buttons .= '<button type="button" role="tab" aria-selected="' . ($index === 0 ? 'true' : 'false') . '" data-segment="' . $index . '">' . esc_html((string) ($tab['title'] ?? sprintf(__('栏目 %d', 'aksrt'), $index + 1))) . '</button>'; $tab_id = 'aksrt-tab-' . $key . '-' . $index;
$buttons .= '<button type="button" role="tab" id="' . esc_attr($tab_id) . '" aria-controls="' . esc_attr($panel_id) . '" aria-selected="' . ($index === 0 ? 'true' : 'false') . '" tabindex="' . ($index === 0 ? '0' : '-1') . '" data-segment="' . $index . '">' . esc_html((string) ($tab['title'] ?? sprintf(__('栏目 %d', 'aksrt'), $index + 1))) . '</button>';
} }
$payload = PostRenderer::payload($instance, 'tab_posts', 1, '0'); $payload = PostRenderer::payload($instance, 'tab_posts', 1, '0');
return '<div class="aksrt-widget-tabs" data-widget="tab_posts" data-instance="' . esc_attr((string) ($context['widget_id'] ?? '')) . '"><div class="aksrt-widget-tabs__nav" role="tablist">' . $buttons . '</div><div class="aksrt-widget-tabs__panel" role="tabpanel" data-widget-items data-segment="0">' . $payload['html'] . $payload['paginate_html'] . '</div></div>'; return '<div class="aksrt-widget-tabs" data-widget="tab_posts" data-instance="' . esc_attr((string) ($context['widget_id'] ?? '')) . '"><div class="aksrt-widget-tabs__nav" role="tablist" aria-label="' . esc_attr__('文章栏目', 'aksrt') . '">' . $buttons . '</div><div class="aksrt-widget-tabs__panel" role="tabpanel" id="' . esc_attr($panel_id) . '" aria-labelledby="' . esc_attr($first_tab_id) . '" tabindex="0" data-widget-items data-segment="0" aria-live="polite" aria-busy="false">' . $payload['html'] . $payload['paginate_html'] . '</div></div>';
} }
private static function post_pay(array $instance): string { private static function post_pay(array $instance): string {
@@ -67,7 +67,7 @@ final class GeneralRenderer {
return RendererHelpers::empty_state(__('请配置视频地址', 'aksrt')); return RendererHelpers::empty_state(__('请配置视频地址', 'aksrt'));
} }
$ratio = (string) $i['ratio']; $ratio = (string) $i['ratio'];
return '<div class="aksrt-widget-video aksrt-widget-video--' . esc_attr(str_replace(':', '-', $ratio)) . '" data-video-url="' . $url . '" data-video-poster="' . esc_url((string) $i['poster']) . '" data-autoplay="' . (!empty($i['autoplay']) ? '1' : '0') . '" data-muted="' . (!empty($i['muted']) ? '1' : '0') . '"><video controls playsinline preload="metadata"' . (!empty($i['autoplay']) ? ' autoplay' : '') . (!empty($i['muted']) ? ' muted' : '') . (!empty($i['poster']) ? ' poster="' . esc_url((string) $i['poster']) . '"' : '') . '><source src="' . $url . '"></video><p class="aksrt-widget-media-error" hidden>' . esc_html__('视频加载失败,请稍后重试。', 'aksrt') . '</p></div>'; return '<div class="aksrt-widget-video aksrt-widget-video--' . esc_attr(str_replace(':', '-', $ratio)) . '" data-video-url="' . $url . '" data-video-poster="' . esc_url((string) $i['poster']) . '" data-autoplay="' . (!empty($i['autoplay']) ? '1' : '0') . '" data-muted="' . (!empty($i['muted']) ? '1' : '0') . '"><video controls playsinline preload="metadata"' . (!empty($i['autoplay']) ? ' autoplay' : '') . (!empty($i['muted']) ? ' muted' : '') . (!empty($i['poster']) ? ' poster="' . esc_url((string) $i['poster']) . '"' : '') . '><source src="' . $url . '"></video><p class="aksrt-widget-media-error" role="alert" aria-live="assertive" hidden>' . esc_html__('视频加载失败,请稍后重试。', 'aksrt') . '</p></div>';
} }
private static function render_embed(array $i): string { private static function render_embed(array $i): string {
@@ -9,14 +9,13 @@ defined('ABSPATH') || exit;
/** Render the seven knowledge-product presentation widgets. */ /** Render the seven knowledge-product presentation widgets. */
final class KnowledgeProductRenderer { final class KnowledgeProductRenderer {
public static function render(string $slug, array $instance, array $context = []): string { public static function render(string $slug, array $instance, array $context = []): string {
unset($context);
switch ($slug) { switch ($slug) {
case 'pricing_plans': return self::pricing($instance); case 'pricing_plans': return self::pricing($instance);
case 'reviews': return self::reviews($instance); case 'reviews': return self::reviews($instance);
case 'faq': return self::faq($instance); case 'faq': return self::faq($instance);
case 'stats': return self::stats($instance); case 'stats': return self::stats($instance);
case 'highlights': return self::highlights($instance); case 'highlights': return self::highlights($instance);
case 'feature_intro': return self::feature_intro($instance); case 'feature_intro': return self::feature_intro($instance, $context);
case 'tilt_cases': return self::tilt($instance); case 'tilt_cases': return self::tilt($instance);
} }
return ''; return '';
@@ -96,17 +95,20 @@ final class KnowledgeProductRenderer {
return $html . '</div>'; return $html . '</div>';
} }
private static function feature_intro(array $i): string { private static function feature_intro(array $i, array $context): string {
$tabs = $panels = ''; $tabs = $panels = '';
$key = sanitize_html_class((string) ($context['widget_id'] ?? 'preview')) ?: 'preview';
foreach ((array) $i['items'] as $index => $item) { foreach ((array) $i['items'] as $index => $item) {
$tabs .= '<button type="button" role="tab" data-feature-index="' . $index . '" aria-selected="' . ($index === 0 ? 'true' : 'false') . '">' . esc_html((string) ($item['title'] ?? '')) . '</button>'; $tab_id = 'aksrt-feature-' . $key . '-tab-' . $index;
$panel_id = 'aksrt-feature-' . $key . '-panel-' . $index;
$tabs .= '<button type="button" role="tab" id="' . esc_attr($tab_id) . '" aria-controls="' . esc_attr($panel_id) . '" data-feature-index="' . $index . '" aria-selected="' . ($index === 0 ? 'true' : 'false') . '" tabindex="' . ($index === 0 ? '0' : '-1') . '">' . esc_html((string) ($item['title'] ?? '')) . '</button>';
$features = ''; $features = '';
foreach (RendererHelpers::lines((string) ($item['features'] ?? '')) as $feature) { foreach (RendererHelpers::lines((string) ($item['features'] ?? '')) as $feature) {
$features .= '<li>' . esc_html($feature) . '</li>'; $features .= '<li>' . esc_html($feature) . '</li>';
} }
$panels .= '<article role="tabpanel" data-feature-panel="' . $index . '"' . ($index ? ' hidden' : '') . '><div class="aksrt-feature-intro__media">' . RendererHelpers::card_media((array) $item) . '</div><div class="aksrt-feature-intro__copy"><h3>' . esc_html((string) ($item['title'] ?? '')) . '</h3><p>' . esc_html((string) ($item['description'] ?? '')) . '</p><ul>' . $features . '</ul></div></article>'; $panels .= '<article role="tabpanel" id="' . esc_attr($panel_id) . '" aria-labelledby="' . esc_attr($tab_id) . '" tabindex="0" data-feature-panel="' . $index . '"' . ($index ? ' hidden' : '') . '><div class="aksrt-feature-intro__media">' . RendererHelpers::card_media((array) $item) . '</div><div class="aksrt-feature-intro__copy"><h3>' . esc_html((string) ($item['title'] ?? '')) . '</h3><p>' . esc_html((string) ($item['description'] ?? '')) . '</p><ul>' . $features . '</ul></div></article>';
} }
return '<div class="aksrt-feature-intro"><div class="aksrt-feature-intro__tabs" role="tablist">' . $tabs . '</div><div class="aksrt-feature-intro__panels">' . $panels . '</div></div>'; return '<div class="aksrt-feature-intro"><div class="aksrt-feature-intro__tabs" role="tablist" aria-label="' . esc_attr__('知识产品亮点', 'aksrt') . '">' . $tabs . '</div><div class="aksrt-feature-intro__panels">' . $panels . '</div></div>';
} }
private static function tilt(array $i): string { private static function tilt(array $i): string {
+3 -2
View File
@@ -34,8 +34,9 @@ final class WidgetDefinition {
$this->description = trim((string) ($definition['description'] ?? '')); $this->description = trim((string) ($definition['description'] ?? ''));
$this->group = (string) ($definition['group'] ?? 'general'); $this->group = (string) ($definition['group'] ?? 'general');
$this->renderer = (string) ($definition['renderer'] ?? $slug); $this->renderer = (string) ($definition['renderer'] ?? $slug);
$this->size = in_array(($definition['size'] ?? 'any'), ['any', 'wide', 'sidebar'], true) $size = (string) ($definition['size'] ?? 'any');
? (string) $definition['size'] $this->size = in_array($size, ['any', 'wide', 'sidebar'], true)
? $size
: 'any'; : 'any';
$this->fields = array_values((array) ($definition['fields'] ?? [])); $this->fields = array_values((array) ($definition['fields'] ?? []));
$this->common = array_merge( $this->common = array_merge(
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace AKSRT\WidgetSystem;
defined('ABSPATH') || exit;
/** Pure visibility rules shared by rendering and contract tests. */
final class WidgetVisibility {
public static function should_render(array $instance, ?int $current_id = null, ?bool $singular = null): bool {
$type = (string) ($instance['show_id_type'] ?? '');
$ids = RendererHelpers::ids((string) ($instance['show_ids'] ?? ''));
if ($type === '' || !$ids) {
return true;
}
if ($singular === null) {
$singular = function_exists('is_singular') && is_singular();
}
if ($current_id === null) {
$current_id = $singular && function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0;
}
$matched = $singular && in_array($current_id, $ids, true);
return $type === 'show' ? $matched : !$matched;
}
public static function device_class(array $instance): string {
$type = (string) ($instance['show_type'] ?? 'all');
if ($type === 'only_pc') {
return 'aksrt-widget--only-pc';
}
return $type === 'only_sm' ? 'aksrt-widget--only-mobile' : '';
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
# Widget visual results
This directory is intentionally empty until validation runs against the user-provided WordPress site.
The browser run must create the screenshot paths declared by `tests/fixtures/widget-golden-v1.json` and a `manifest.json` using schema `aksrt.widget-visual-results.v1`. Validate completed results with:
```text
php tools/validate-widget-golden.php --require-results
```
The validator requires all 222 viewport/theme screenshots, every declared interaction state, zero console errors, zero unexpected network failures, zero critical/serious accessibility findings, reduced-motion verification, a maximum 2 px component-boundary delta, and a maximum 1.5% pixel difference.
@@ -0,0 +1,28 @@
# AKSRT widget static accessibility audit
Scope: `src/WidgetSystem/Renderers/`, `assets/widgets/frontend.js`, and `assets/widgets/frontend.css`.
The source audit was repeated after remediation. Browser, screen-reader, Lighthouse/axe, console, and network validation remain pending until a target WordPress URL and browser MCP are available.
## Critical (WCAG A)
No static critical issue was found.
## Serious (WCAG AA)
No unresolved static serious issue remains.
## Resolved source findings
- **WCAG 2.1.1 Keyboard:** article and feature tabs now implement Arrow Left/Right, Home/End, focus movement, roving `tabindex`, and stable tab/panel associations.
- **WCAG 2.4.7 Focus Visible:** all widget links, buttons, inputs, selects, textareas, and explicit tabindex targets receive high-contrast light, dark, and forced-colors focus rings.
- **WCAG 2.2.2 Pause, Stop, Hide:** autoplay carousels pause on keyboard focus and expose a labeled, persistent pause/resume button; reduced-motion mode continues to disable autoplay.
- **WCAG 4.1.3 Status Messages:** AJAX containers expose `aria-live` and `aria-busy`, loading skeletons use `role="status"`, and video failure uses an assertive alert.
These contracts are enforced by `tests/widget-system/a11y-contract.test.cjs`.
## Browser verification still required
- Confirm Swiper-generated pagination bullets, disabled navigation states, and pause/resume behavior with the bundled runtime.
- Verify 200% zoom, high-contrast mode, dark-mode contrast, focus order, and focus retention after AJAX replacement.
- Run Lighthouse or axe and perform keyboard-only checks at 1440, 1024, and 390 px in light and dark modes.
@@ -0,0 +1,38 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const js = read('assets/widgets/frontend.js');
const css = read('assets/widgets/frontend.css');
const content = read('src/WidgetSystem/Renderers/ContentRenderer.php');
const knowledge = read('src/WidgetSystem/Renderers/KnowledgeProductRenderer.php');
const general = read('src/WidgetSystem/Renderers/GeneralRenderer.php');
for (const key of ['ArrowRight', 'ArrowLeft', 'Home', 'End']) assert.match(js, new RegExp(key));
assert.match(js, /setAttribute\('tabindex', tab === button \? '0' : '-1'\)/);
assert.match(js, /setAttribute\('aria-labelledby', button\.id\)/);
assert.match(content, /role="tab"[^;]+aria-controls=/);
assert.match(content, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(knowledge, /role="tab"[^;]+aria-controls=/);
assert.match(knowledge, /role="tabpanel"[^;]+aria-labelledby=/);
assert.match(css, /:focus-visible/);
assert.match(css, /forced-colors: active/);
assert.match(js, /aksrt-widget-swiper__pause/);
assert.match(js, /addEventListener\('focusin', pause\)/);
assert.match(js, /addEventListener\('focusout'/);
assert.match(js, /aria-pressed/);
assert.match(content, /aria-live="polite" aria-busy="true"/);
assert.match(content, /role="status"/);
assert.match(js, /setAttribute\('aria-busy', 'true'\)/);
assert.match(js, /setAttribute\('aria-busy', 'false'\)/);
assert.match(general, /role="alert" aria-live="assertive"/);
assert.match(css, /prefers-reduced-motion: reduce/);
assert.match(js, /prefers-reduced-motion: reduce/);
console.log('Widget accessibility contract tests passed: keyboard tabs, focus, autoplay controls, live states and reduced motion.');
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const assert = require('node:assert/strict');
const core = require('../../assets/widgets/admin-core.js');
assert.deepEqual(core.parseDependency('["mode","==","cards"]'), ['mode', '==', 'cards']);
assert.deepEqual(core.parseDependency('{broken'), []);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'cards'), true);
assert.equal(core.dependencyVisible(['mode', '==', 'cards'], 'list'), false);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'list'), true);
assert.equal(core.dependencyVisible(['mode', '!=', 'cards'], 'cards'), false);
assert.equal(core.dependencyVisible([], 'anything'), true);
assert.equal(
core.replaceRepeaterIndexes('widget-aksrt_widget_slider[4][slides][__index_ab12cd34__][layers][9][title]', [2, 1]),
'widget-aksrt_widget_slider[4][slides][2][layers][1][title]'
);
assert.equal(core.replaceRepeaterIndexes('plain[name]', [1]), 'plain[name]');
console.log('Widget admin core tests passed: dependency visibility and nested repeater indexing.');
+177
View File
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
define('ABSPATH', dirname(__DIR__, 2) . DIRECTORY_SEPARATOR);
}
if (!defined('DAY_IN_SECONDS')) {
define('DAY_IN_SECONDS', 86400);
}
if (!defined('MINUTE_IN_SECONDS')) {
define('MINUTE_IN_SECONDS', 60);
}
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
$GLOBALS['aksrt_test_state'] = [];
function aksrt_test_reset_state(): void {
$GLOBALS['aksrt_test_state'] = [
'options' => [],
'sidebars' => [],
'routes' => [],
'query_args' => [],
'current_post' => null,
'current_id' => 0,
'singular' => false,
'pay_meta' => ['pay_type' => 'no', 'price' => 0],
'paid_access' => false,
'logged_in' => false,
];
$GLOBALS['wp_registered_sidebars'] = [];
}
aksrt_test_reset_state();
class WP_Error {
public function __construct(private string $code = '', private string $message = '', private array $data = []) {}
public function get_error_code(): string { return $this->code; }
public function get_error_message(): string { return $this->message; }
public function get_error_data(): array { return $this->data; }
}
class WP_REST_Controller {
public string $namespace = '';
public string $rest_base = '';
}
class WP_REST_Server {
public const READABLE = 'GET';
}
class WP_REST_Request {
public function __construct(private array $params = []) {}
public function get_params(): array { return $this->params; }
public function get_param(string $key) { return $this->params[$key] ?? null; }
}
class WP_REST_Response {
public function __construct(private $data) {}
public function get_data() { return $this->data; }
}
class WP_Post {
public int $ID;
public int $post_author;
public function __construct(int $id = 1, int $author = 1) {
$this->ID = $id;
$this->post_author = $author;
}
}
class WP_Query {
public array $posts = [];
public int $max_num_pages = 1;
public int $found_posts = 0;
public function __construct(array $args = []) {
$GLOBALS['aksrt_test_state']['query_args'][] = $args;
}
public function have_posts(): bool { return false; }
public function the_post(): void {}
}
class WP_Widget {
public string $id_base = '';
public string $id = '';
public int $number = 0;
public function __construct(string $id_base = '', string $name = '', array $widget_options = [], array $control_options = []) {
unset($name, $widget_options, $control_options);
$this->id_base = $id_base;
}
public function get_settings(): array {
$value = get_option('widget_' . $this->id_base, []);
return is_array($value) ? $value : [];
}
public function get_field_id(string $field): string { return 'widget-' . $this->id_base . '-' . $this->number . '-' . $field; }
public function get_field_name(string $field): string { return 'widget-' . $this->id_base . '[' . $this->number . '][' . $field . ']'; }
public function _set(int $number): void {
$this->number = $number;
$this->id = $this->id_base . '-' . $number;
}
}
function __($text): string { return (string) $text; }
function esc_html__($text): string { return esc_html((string) $text); }
function esc_attr__($text): string { return esc_attr((string) $text); }
function esc_html_e($text): void { echo esc_html((string) $text); }
function esc_attr_e($text): void { echo esc_attr((string) $text); }
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function esc_attr($value): string { return esc_html((string) $value); }
function esc_textarea($value): string { return esc_html((string) $value); }
function esc_url_raw($value): string {
$value = trim((string) $value);
return preg_match('#^(?:https?://|/)#', $value) ? $value : '';
}
function esc_url($value): string { return esc_attr(esc_url_raw($value)); }
function sanitize_text_field($value): string { return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? ''); }
function sanitize_textarea_field($value): string { return trim(strip_tags((string) $value)); }
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_hex_color($value): string {
return preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', (string) $value) ? strtolower((string) $value) : '';
}
function sanitize_textarea($value): string { return sanitize_textarea_field($value); }
function wp_kses_post($value): string { return strip_tags((string) $value, '<a><b><strong><em><br><p><span><ul><ol><li>'); }
function wp_json_encode($value): string { return (string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); }
function absint($value): int { return abs((int) $value); }
function checked($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'checked="checked"' : '';
if ($display) echo $result;
return $result;
}
function selected($actual, $expected = true, bool $display = true): string {
$result = $actual == $expected ? 'selected="selected"' : '';
if ($display) echo $result;
return $result;
}
function number_format_i18n($number): string { return number_format((float) $number); }
function wp_trim_words($text, int $count = 55): string {
$words = preg_split('/\s+/', trim((string) $text)) ?: [];
return implode(' ', array_slice($words, 0, $count));
}
function get_option(string $name, $default = false) { return $GLOBALS['aksrt_test_state']['options'][$name] ?? $default; }
function update_option(string $name, $value): bool { $GLOBALS['aksrt_test_state']['options'][$name] = $value; return true; }
function wp_get_sidebars_widgets(): array { return $GLOBALS['aksrt_test_state']['sidebars']; }
function wp_set_sidebars_widgets(array $sidebars): void { $GLOBALS['aksrt_test_state']['sidebars'] = $sidebars; }
function register_rest_route(string $namespace, string $route, array $args): bool {
$GLOBALS['aksrt_test_state']['routes'][$namespace . $route] = $args;
return true;
}
function rest_ensure_response($data): WP_REST_Response { return $data instanceof WP_REST_Response ? $data : new WP_REST_Response($data); }
function is_wp_error($value): bool { return $value instanceof WP_Error; }
function is_singular(): bool { return (bool) $GLOBALS['aksrt_test_state']['singular']; }
function get_queried_object_id(): int { return (int) $GLOBALS['aksrt_test_state']['current_id']; }
function get_post($post = null) {
if ($post instanceof WP_Post) return $post;
return $GLOBALS['aksrt_test_state']['current_post'];
}
function aksrt_get_widget_null(string $message): string { return '<div class="aksrt-widget__empty" role="status">' . esc_html($message) . '</div>'; }
function aksrt_get_pay_meta($post = null): array { unset($post); return $GLOBALS['aksrt_test_state']['pay_meta']; }
function aksrt_has_paid_access($post = null): bool { unset($post); return (bool) $GLOBALS['aksrt_test_state']['paid_access']; }
function aksrt_get_paywall_html(WP_Post $post, bool $fragment = false): string {
unset($post, $fragment);
$state = $GLOBALS['aksrt_test_state']['logged_in'] ? 'member-locked' : 'guest-locked';
return '<div data-test-paywall="' . $state . '">PAYWALL</div>';
}
function aksrt_get_pay_attributes_html($post, string $class): string { unset($post); return '<ul class="' . esc_attr($class) . '"></ul>'; }
function aksrt_get_demo_link_html($post, string $class): string { unset($post); return '<a class="' . esc_attr($class) . '">DEMO</a>'; }
function is_user_logged_in(): bool { return (bool) $GLOBALS['aksrt_test_state']['logged_in']; }
require dirname(__DIR__, 2) . '/vendor/autoload.php';
+217
View File
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\FormRenderer;
use AKSRT\WidgetSystem\PostQuery;
use AKSRT\WidgetSystem\RestController;
use AKSRT\WidgetSystem\SchemaWidget;
use AKSRT\WidgetSystem\WidgetDefinition;
use AKSRT\WidgetSystem\WidgetRegistry;
use AKSRT\WidgetSystem\WidgetTransfer;
use AKSRT\WidgetSystem\WidgetVisibility;
use AKSRT\WidgetSystem\Renderers\ContentRenderer;
final class WidgetSystemTest {
private int $assertions = 0;
private array $failures = [];
public function run(): void {
$this->schema_contract();
$this->query_contract();
$this->visibility_contract();
$this->transfer_round_trip();
$this->rest_contract();
$this->payment_states();
if ($this->failures) {
fwrite(STDERR, "Widget system tests failed:\n- " . implode("\n- ", $this->failures) . "\n");
exit(1);
}
echo "Widget system tests passed: {$this->assertions} assertions across schema, query, visibility, transfer, REST and payment states.\n";
}
private function schema_contract(): void {
$fields = [
['id' => 'mode', 'type' => 'select', 'default' => 'cards', 'options' => ['cards' => 'Cards', 'list' => 'List']],
['id' => 'count', 'type' => 'slider', 'default' => 3, 'min' => 1, 'max' => 8],
['id' => 'ids', 'type' => 'text', 'sanitize' => 'ids'],
['id' => 'link', 'type' => 'link'],
['id' => 'layout', 'type' => 'fieldset', 'fields' => [
['id' => 'enabled', 'type' => 'switcher', 'default' => false],
['id' => 'gap', 'type' => 'number', 'default' => 8, 'min' => 0, 'max' => 40],
]],
['id' => 'rows', 'type' => 'group', 'max_items' => 2, 'fields' => [
['id' => 'title', 'type' => 'text'],
['id' => 'tone', 'type' => 'radio', 'default' => 'blue', 'options' => ['blue' => 'Blue', 'red' => 'Red']],
]],
];
$defaults = FieldSchema::defaults($fields);
$this->same('cards', $defaults['mode'], 'schema defaults preserve enums');
$this->same(['enabled' => false, 'gap' => 8], $defaults['layout'], 'nested defaults are recursive');
$normalized = FieldSchema::normalize($fields, [
'mode' => 'invalid', 'count' => 99, 'ids' => '2, 2,7 bad -4',
'link' => ['url' => 'javascript:alert(1)', 'text' => '<b>课程</b>', 'target' => 'popup'],
'layout' => ['enabled' => 'yes', 'gap' => -9, 'unknown' => 'drop'],
'rows' => [
['title' => '<script>x</script>第一项', 'tone' => 'red'],
['title' => '第二项', 'tone' => 'invalid'],
['title' => '第三项'],
],
]);
$this->same('cards', $normalized['mode'], 'invalid enum falls back');
$this->same(8, $normalized['count'], 'numeric value clamps to maximum');
$this->same('2,7,4', $normalized['ids'], 'ID list is unique and normalized');
$this->same(['url' => '', 'text' => '课程', 'target' => ''], $normalized['link'], 'link parts sanitize independently');
$this->same(['enabled' => true, 'gap' => 0], $normalized['layout'], 'nested values clamp and drop unknown keys');
$this->same(2, count($normalized['rows']), 'group max_items is enforced');
$this->same('blue', $normalized['rows'][1]['tone'], 'nested enum fallback is enforced');
$definition = new WidgetDefinition([
'slug' => 'dependency_test', 'name' => 'Dependency Test',
'common' => ['title' => false, 'affix' => false, 'visibility' => false, 'animation' => false, 'layout' => false],
'fields' => [
['id' => 'controller', 'type' => 'select', 'default' => 'off', 'options' => ['off' => 'Off', 'on' => 'On']],
['id' => 'dependent', 'type' => 'text', 'dependency' => ['controller', '==', 'on']],
],
]);
$widget = new SchemaWidget($definition);
ob_start();
$widget->render_full_form(['controller' => 'on', 'dependent' => 'visible']);
$html = (string) ob_get_clean();
$this->contains('data-dependency=', $html, 'form exports dependency rule');
$this->contains('&quot;controller&quot;', $html, 'dependency JSON is safely escaped');
}
private function query_contract(): void {
$args = PostQuery::arguments([
'count' => 99, 'category_ids' => '2,5', 'category_id' => 9, 'tag_ids' => '8,13',
'limit_day' => 14, 'orderby' => 'downloads', 'order' => 'asc', 'pay_type' => 'gallery',
], 0);
$this->same(50, $args['posts_per_page'], 'query count clamps');
$this->same(1, $args['paged'], 'query page clamps');
$this->same([9], $args['category__in'], 'segment category overrides category list');
$this->same([8, 13], $args['tag__in'], 'query tag filter is normalized');
$this->same('ASC', $args['order'], 'query order allowlist is applied');
$this->same('meta_value_num', $args['orderby'], 'metric ordering uses numeric meta');
$this->contains('gallery', json_encode($args['meta_query']), 'DomainModel pay type reaches meta query');
$invalid = PostQuery::arguments(['pay_type' => '6', 'orderby' => 'drop table'], 1);
$this->false(isset($invalid['meta_query']), 'legacy numeric pay types are rejected');
$this->same('date', $invalid['orderby'], 'unknown orderby falls back');
$tabs = ['tabs' => [['title' => 'A', 'category_id' => 2], ['title' => 'B', 'category_id' => 3]]];
$this->same(3, PostQuery::segment($tabs, '1')['category_id'], 'valid tab segment resolves');
$this->same(2, PostQuery::segment($tabs, '99')['category_id'], 'invalid tab segment falls back to first');
}
private function visibility_contract(): void {
$this->true(WidgetVisibility::should_render([], 0, false), 'unrestricted widget renders');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 8, true), 'show list matches singular ID');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 4, true), 'show list rejects other IDs');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'show', 'show_ids' => '3,8'], 0, false), 'show list rejects archives');
$this->false(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 3, true), 'hide list suppresses matching ID');
$this->true(WidgetVisibility::should_render(['show_id_type' => 'hide', 'show_ids' => '3,8'], 0, false), 'hide list keeps archives');
$this->same('aksrt-widget--only-pc', WidgetVisibility::device_class(['show_type' => 'only_pc']), 'PC device class is stable');
$this->same('aksrt-widget--only-mobile', WidgetVisibility::device_class(['show_type' => 'only_sm']), 'mobile device class is stable');
$this->same('', WidgetVisibility::device_class(['show_type' => 'invalid']), 'invalid device rule adds no class');
}
private function transfer_round_trip(): void {
aksrt_test_reset_state();
$GLOBALS['wp_registered_sidebars'] = ['home_sidebar' => [], 'single_sidebar' => []];
update_option('widget_aksrt_widget_posts', [2 => ['title' => '<b>课程列表</b>', 'count' => 999, 'style' => 'mini'], '_multiwidget' => 1]);
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_sidebar' => ['text-1', 'aksrt_widget_posts-2']];
$payload = WidgetTransfer::export('home_sidebar');
$this->same(WidgetTransfer::FORMAT, $payload['format'], 'export format is versioned');
$this->same(1, count($payload['sidebars']['home_sidebar']), 'export ignores non-schema widgets');
$this->same(50, $payload['sidebars']['home_sidebar'][0]['settings']['count'], 'export normalizes saved settings');
update_option('widget_aksrt_widget_posts', []);
$GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'] = ['text-9'];
$result = WidgetTransfer::import($payload, 'append', 'single_sidebar');
$this->same(['sidebars' => 1, 'widgets' => 1], $result, 'append import reports changes');
$this->same(['text-9', 'aksrt_widget_posts-1'], $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], 'append import preserves existing widgets');
$round_trip = WidgetTransfer::export('single_sidebar');
$this->same($payload['sidebars']['home_sidebar'][0], $round_trip['sidebars']['single_sidebar'][0], 'export/import round trip preserves normalized module');
$replacement = WidgetTransfer::template_payload('links_navigation');
$result = WidgetTransfer::import($replacement, 'replace', 'single_sidebar');
$this->same(3, $result['widgets'], 'targeted template import combines template areas');
$this->false(in_array('text-9', $GLOBALS['aksrt_test_state']['sidebars']['single_sidebar'], true), 'replace import removes previous assignment');
try {
WidgetTransfer::import(['format' => 'zibll.widgets', 'sidebars' => []]);
$this->failures[] = 'transfer rejects legacy formats: no exception thrown';
} catch (InvalidArgumentException $error) {
$this->contains(WidgetTransfer::FORMAT, $error->getMessage(), 'legacy import rejection names accepted format');
}
}
private function rest_contract(): void {
aksrt_test_reset_state();
$controller = new RestController();
$controller->register_routes();
$this->same(2, count($GLOBALS['aksrt_test_state']['routes']), 'REST registers items and quote routes');
$route = $GLOBALS['aksrt_test_state']['routes']['aksrt/v1/widgets/(?P<widget>[a-z][a-z0-9_]*)/items'];
$this->same('__return_true', $route['permission_callback'], 'public items route is read-only accessible');
$this->true($controller->validate_widget('posts'), 'known widget validates');
$this->false($controller->validate_widget('shop_products'), 'unknown/shop widget is rejected');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1, 'query' => 'arbitrary']));
$this->error('aksrt_widget_unknown_parameter', $error, 'REST rejects arbitrary query parameters');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'search', 'instance' => 'aksrt_widget_search-1', 'page' => 1]));
$this->error('aksrt_widget_items_unsupported', $error, 'REST rejects non-dynamic widgets');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1]));
$this->error('aksrt_widget_instance_not_registered', $error, 'REST requires an assigned instance');
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_top_content' => ['aksrt_widget_posts-1']];
update_option('widget_aksrt_widget_posts', [1 => ['title' => 'Saved only', 'count' => 2, 'style' => 'list'], '_multiwidget' => 1]);
$response = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 2, 'segment' => '']));
$this->true($response instanceof WP_REST_Response, 'registered instance returns REST response');
$data = $response->get_data();
$this->true($data['success'], 'REST success envelope is stable');
$this->same(2, $data['page'], 'REST page reaches saved-query pipeline');
$this->contains('暂无文章', $data['html'], 'REST returns deterministic empty state');
$this->same(2, end($GLOBALS['aksrt_test_state']['query_args'])['posts_per_page'], 'REST reloads saved normalized configuration');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_hot_posts-1', 'page' => 1]));
$this->error('aksrt_widget_invalid_instance', $error, 'REST binds instance ID to requested widget');
}
private function payment_states(): void {
aksrt_test_reset_state();
$GLOBALS['aksrt_test_state']['current_post'] = new WP_Post(77, 5);
$method = new ReflectionMethod(ContentRenderer::class, 'post_pay');
$render = static fn(): string => (string) $method->invoke(null, ['theme' => 'jb-red']);
$this->contains('未配置付费内容', $render(), 'unconfigured article renders explicit empty state');
$authorized = [
'free' => ['pay_type' => 'reading', 'price' => 0],
'purchased' => ['pay_type' => 'reading', 'price' => 29],
'vip' => ['pay_type' => 'vip', 'price' => 0],
];
foreach ($authorized as $label => $meta) {
$GLOBALS['aksrt_test_state']['pay_meta'] = $meta;
$GLOBALS['aksrt_test_state']['paid_access'] = true;
$this->contains('内容已解锁', $render(), $label . ' authorization delegates to existing access service');
}
$GLOBALS['aksrt_test_state']['pay_meta'] = ['pay_type' => 'video', 'price' => 49];
$GLOBALS['aksrt_test_state']['paid_access'] = false;
$GLOBALS['aksrt_test_state']['logged_in'] = false;
$this->contains('guest-locked', $render(), 'guest unauthorized state delegates to paywall');
$GLOBALS['aksrt_test_state']['logged_in'] = true;
$this->contains('member-locked', $render(), 'logged-in unauthorized state delegates to paywall');
}
private function same($expected, $actual, string $message): void {
$this->assertions++;
if ($expected !== $actual) $this->failures[] = $message . ' (expected ' . var_export($expected, true) . ', got ' . var_export($actual, true) . ')';
}
private function true($actual, string $message): void { $this->same(true, (bool) $actual, $message); }
private function false($actual, string $message): void { $this->same(false, (bool) $actual, $message); }
private function contains(string $needle, ?string $haystack, string $message): void {
$this->assertions++;
if ($haystack === null || !str_contains($haystack, $needle)) $this->failures[] = $message . ' (missing ' . $needle . ')';
}
private function error(string $code, $value, string $message): void {
$this->assertions++;
if (!$value instanceof WP_Error || $value->get_error_code() !== $code) $this->failures[] = $message . ' (expected error ' . $code . ')';
}
}
(new WidgetSystemTest())->run();
+7 -2
View File
@@ -258,8 +258,13 @@ foreach ($pinned_assets as $asset => $hash) {
$path = $root . '/assets/widgets/third-party/' . $asset; $path = $root . '/assets/widgets/third-party/' . $asset;
if (!is_file($path)) { if (!is_file($path)) {
$errors[] = 'Missing pinned widget dependency: ' . $asset; $errors[] = 'Missing pinned widget dependency: ' . $asset;
} elseif (hash_file('sha256', $path) !== $hash) { } else {
$errors[] = 'Pinned widget dependency changed: ' . $asset; $contents = (string) file_get_contents($path);
$raw_hash = hash('sha256', $contents);
$lf_hash = hash('sha256', str_replace("\r\n", "\n", $contents));
if ($raw_hash !== $hash && $lf_hash !== $hash) {
$errors[] = 'Pinned widget dependency changed: ' . $asset;
}
} }
} }
if (!is_file($root . '/assets/widgets/third-party/THIRD_PARTY.md')) { if (!is_file($root . '/assets/widgets/third-party/THIRD_PARTY.md')) {
+229
View File
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
use AKSRT\WidgetSystem\FieldSchema;
use AKSRT\WidgetSystem\WidgetRegistry;
$root = dirname(__DIR__);
$baseline_path = $root . '/.specs/baselines/widget-reference.json';
$baseline = json_decode((string) file_get_contents($baseline_path), true, 512, JSON_THROW_ON_ERROR);
if (($baseline['schema'] ?? '') !== 'aksrt.widget-reference.v1') {
throw new RuntimeException('Frozen widget reference baseline is invalid.');
}
$reference_root = rtrim((string) ($baseline['source_root'] ?? ''), '\\/');
$reference_hashes = [];
foreach ((array) ($baseline['files'] ?? []) as $file) {
$relative = (string) ($file['path'] ?? '');
$expected = (string) ($file['sha256'] ?? '');
$path = $reference_root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
if (!is_file($path) || hash_file('sha256', $path) !== $expected) {
throw new RuntimeException('Frozen reference changed or is missing: ' . $relative);
}
$reference_hashes[$relative] = $expected;
}
$group_sources = [
'general' => 'inc/widgets/widget-more.php',
'content' => 'inc/widgets/widget-posts.php',
'knowledge_product' => 'inc/widgets/widget-produck.php',
'slider' => 'inc/widgets/widget-slider.php',
'user' => 'inc/widgets/widget-user.php',
];
$viewports = [
['name' => 'desktop', 'width' => 1440, 'height' => 1100],
['name' => 'tablet', 'width' => 1024, 'height' => 1000],
['name' => 'mobile', 'width' => 390, 'height' => 844],
];
$modes = ['light', 'dark'];
$images = [
'https://fixtures.aksrt.test/media/course-cover-01.jpg',
'https://fixtures.aksrt.test/media/course-cover-02.jpg',
'https://fixtures.aksrt.test/media/course-cover-03.jpg',
];
function fixture_text(string $id, string $slug, int $index): string {
$labels = [
'question' => '购买后可以长期学习吗?',
'answer' => '可以,已购买内容会永久保存在你的学习中心。',
'name' => 'AKSRT 学员 ' . ($index + 1),
'author' => 'AKSRT 学员 ' . ($index + 1),
'role' => '知识产品体验官',
'price' => '¥99',
'value' => (string) (($index + 1) * 1280) . '+',
'label' => '累计学习人数',
'heading' => '从入门到精通的系统学习路径',
'description' => '固定内容用于组件 DOM、响应式和视觉差异验收。',
'content' => '结构清晰、案例完整,并持续更新配套资料。',
'title' => 'AKSRT 固定示例 ' . ($index + 1),
'text' => '保持好奇,持续创造。',
];
if (isset($labels[$id])) {
return $labels[$id];
}
if (str_contains($id, 'video')) {
return 'https://fixtures.aksrt.test/media/lesson.m3u8';
}
if (str_contains($id, 'url')) {
return 'https://fixtures.aksrt.test/' . $slug . '/' . ($index + 1);
}
if (str_contains($id, 'ids')) {
return str_contains($id, 'term') || str_contains($id, 'category') ? '101,102,103' : '';
}
return 'AKSRT ' . str_replace('_', ' ', $id) . ' ' . ($index + 1);
}
function fixture_value(array $field, string $slug, int $index, array $images) {
$type = (string) ($field['type'] ?? 'text');
$id = (string) ($field['id'] ?? '');
$default = $field['default'] ?? null;
if ($id === 'title') return 'AKSRT · ' . ucfirst(str_replace('_', ' ', $slug));
if ($id === 'subtitle') return '固定内容与交互状态视觉验收';
if ($id === 'title_highlight') return 'AKSRT';
if ($id === 'show_type') return 'all';
if ($id === 'show_id_type' || $id === 'show_ids') return '';
if ($id === 'layout_bg') {
return [
'light' => ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
'dark' => ['color' => '#1d1e24', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'],
];
}
if ($type === 'group') {
$rows = [];
$count = min(3, max(1, (int) ($field['max_items'] ?? 3)));
for ($row = 0; $row < $count; $row++) {
$item = [];
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
if ($child_id !== '') $item[$child_id] = fixture_value($child, $slug, $row, $images);
}
$rows[] = $item;
}
return $rows;
}
if (in_array($type, ['fieldset', 'accordion'], true)) {
$value = [];
foreach ((array) ($field['fields'] ?? []) as $child) {
$child_id = (string) ($child['id'] ?? '');
if ($child_id !== '') $value[$child_id] = fixture_value($child, $slug, $index, $images);
}
return $value;
}
if ($type === 'link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看详情', 'target' => ''];
if ($type === 'upload') return $images[$index % count($images)];
if ($type === 'icon') return 'fas fa-graduation-cap';
if ($type === 'color') return '#667eea';
if ($type === 'spacing') return ['top' => 0, 'right' => 0, 'bottom' => 20, 'left' => 0];
if ($type === 'background') return ['color' => '#ffffff', 'image' => '', 'position' => 'center center', 'repeat' => 'no-repeat', 'attachment' => 'scroll', 'size' => 'cover'];
if ($type === 'switcher') return $default === null ? true : (bool) $default;
if (in_array($type, ['number', 'spinner', 'slider'], true)) return $default ?? ($field['min'] ?? 1);
if (in_array($type, ['radio', 'button_set', 'select', 'palette'], true)) return $default ?? (array_key_first((array) ($field['options'] ?? [])) ?? '');
if ($type === 'checkbox') return $default ?? [];
if ($id === 'title_link') return ['url' => 'https://fixtures.aksrt.test/' . $slug, 'text' => '查看更多', 'target' => ''];
if ($default !== null && $default !== '') return $default;
return fixture_text($id, $slug, $index);
}
$dynamic = ['posts', 'tab_posts', 'oneline_posts', 'hot_posts'];
$expandable = ['posts_toc', 'faq'];
$tabbed = ['tab_posts', 'feature_intro'];
$carousel = ['partners', 'notice', 'oneline_posts', 'reviews', 'slider', 'slider_media_combo'];
$components = [];
$matrix = [];
foreach (WidgetRegistry::all() as $slug => $definition) {
$settings = [];
foreach ($definition->fields() as $field) {
$id = (string) ($field['id'] ?? '');
$type = (string) ($field['type'] ?? 'text');
if ($id === '' || in_array($type, ['content', 'subheading', 'submessage'], true)) continue;
$settings[$id] = fixture_value($field, $slug, 0, $images);
}
$settings = $definition->normalize($settings);
$states = ['default', 'hover', 'focus'];
if (in_array($slug, $expandable, true)) $states[] = 'expanded';
if (in_array($slug, $tabbed, true)) $states[] = 'alternate-tab';
if (in_array($slug, $carousel, true)) $states[] = 'next-slide';
if (in_array($slug, $dynamic, true)) $states = array_merge($states, ['loading', 'empty', 'error']);
if ($slug === 'post_pay') $states = array_merge($states, ['guest-locked', 'member-locked', 'authorized', 'unconfigured']);
if ($slug === 'user_card') $states = array_merge($states, ['guest', 'signed-in']);
$source = $group_sources[$definition->group()] ?? 'inc/widgets/widget-index.php';
$components[$slug] = [
'id_base' => $definition->id_base(),
'name' => $definition->name(),
'group' => $definition->group(),
'size' => $definition->size(),
'reference' => ['file' => $source, 'sha256' => $reference_hashes[$source] ?? ''],
'field_ids' => array_values(array_filter(array_map(static fn(array $field): string => (string) ($field['id'] ?? ''), $definition->fields()))),
'settings' => $settings,
'root_selector' => '[data-aksrt-widget="' . $slug . '"]',
'states' => array_values(array_unique($states)),
];
foreach ($viewports as $viewport) {
foreach ($modes as $mode) {
$id = $slug . ':' . $viewport['name'] . ':' . $mode;
$matrix[$id] = [
'widget' => $slug,
'viewport' => $viewport,
'mode' => $mode,
'path' => 'tests/visual-results/' . $slug . '/' . $viewport['name'] . '-' . $mode . '.png',
];
}
}
}
$fixture = [
'schema' => 'aksrt.widget-golden.v1',
'captured_at' => (string) ($baseline['captured_at'] ?? ''),
'reference' => [
'source_root' => $reference_root,
'fingerprint' => hash('sha256', implode('', $reference_hashes)),
'files' => $reference_hashes,
'policy' => $baseline['source_policy'] ?? [],
],
'acceptance' => [
'dimension_delta_px_max' => 2,
'pixel_difference_percent_max' => 1.5,
'console_errors_max' => 0,
'unexpected_network_failures_max' => 0,
'a11y_critical_max' => 0,
'a11y_serious_max' => 0,
'reduced_motion_required' => true,
],
'dataset' => [
'site' => ['name' => 'AKSRT 知识库', 'url' => 'https://fixtures.aksrt.test'],
'terms' => [
['id' => 101, 'taxonomy' => 'category', 'name' => '产品设计', 'count' => 24],
['id' => 102, 'taxonomy' => 'category', 'name' => '前端开发', 'count' => 36],
['id' => 103, 'taxonomy' => 'post_tag', 'name' => '系统课程', 'count' => 18],
],
'users' => [
['id' => 201, 'name' => '林知夏', 'role' => '讲师', 'points' => 9680, 'followers' => 3280],
['id' => 202, 'name' => '周屿', 'role' => '作者', 'points' => 7420, 'followers' => 2160],
['id' => 203, 'name' => '陈墨', 'role' => '学员', 'points' => 5360, 'followers' => 980],
],
'posts' => [
['id' => 301, 'title' => '从零构建知识产品体系', 'pay_type' => 'reading', 'author' => 201, 'category' => 101],
['id' => 302, 'title' => '现代 WordPress 主题工程实践', 'pay_type' => 'video', 'author' => 202, 'category' => 102],
['id' => 303, 'title' => '高质量课程资料包', 'pay_type' => 'download', 'author' => 201, 'category' => 102],
['id' => 304, 'title' => '视觉案例图库', 'pay_type' => 'gallery', 'author' => 202, 'category' => 101],
['id' => 305, 'title' => '会员专属进阶课', 'pay_type' => 'vip', 'author' => 201, 'category' => 101],
['id' => 306, 'title' => '公开学习指南', 'pay_type' => 'no', 'author' => 203, 'category' => 102],
],
'comments' => [
['id' => 401, 'user' => 203, 'post' => 301, 'content' => '课程结构清晰,案例非常实用。'],
['id' => 402, 'user' => 202, 'post' => 302, 'content' => '对主题工程化讲解得很完整。'],
],
'payment_states' => ['guest-locked', 'member-locked', 'free', 'purchased', 'vip-authorized', 'unconfigured'],
'media' => ['images' => $images, 'video' => 'https://fixtures.aksrt.test/media/lesson.m3u8'],
],
'components' => $components,
'screenshot_matrix' => $matrix,
];
$output = $root . '/tests/fixtures/widget-golden-v1.json';
file_put_contents($output, json_encode($fixture, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
echo 'Generated widget golden fixture: ' . count($components) . ' components, ' . count($matrix) . " screenshots.\n";
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/tests/widget-system/bootstrap.php';
use AKSRT\WidgetSystem\WidgetRegistry;
$root = dirname(__DIR__);
$fixture_path = $root . '/tests/fixtures/widget-golden-v1.json';
$fixture = json_decode((string) file_get_contents($fixture_path), true, 512, JSON_THROW_ON_ERROR);
$errors = [];
if (($fixture['schema'] ?? '') !== 'aksrt.widget-golden.v1') $errors[] = 'Invalid golden fixture schema.';
$components = (array) ($fixture['components'] ?? []);
$matrix = (array) ($fixture['screenshot_matrix'] ?? []);
if (array_keys($components) !== array_keys(WidgetRegistry::all()) || count($components) !== 37) $errors[] = 'Golden fixture must contain the exact 37 registry widgets.';
if (count($matrix) !== 222) $errors[] = 'Screenshot matrix must contain 37 × 3 × 2 = 222 entries.';
foreach ($components as $slug => $component) {
$definition = WidgetRegistry::get((string) $slug);
if ($definition === null) continue;
if (($component['id_base'] ?? '') !== $definition->id_base()) $errors[] = $slug . ': id_base mismatch.';
if (($component['settings'] ?? []) !== $definition->normalize((array) ($component['settings'] ?? []))) $errors[] = $slug . ': settings are not normalized.';
foreach (['default', 'hover', 'focus'] as $state) {
if (!in_array($state, (array) ($component['states'] ?? []), true)) $errors[] = $slug . ': missing state ' . $state . '.';
}
}
foreach ((array) ($fixture['reference']['files'] ?? []) as $relative => $expected) {
$path = rtrim((string) ($fixture['reference']['source_root'] ?? ''), '\\/') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $relative);
if (!is_file($path) || hash_file('sha256', $path) !== $expected) $errors[] = 'Reference drift: ' . $relative;
}
$require_results = in_array('--require-results', $argv, true);
if ($require_results) {
$result_path = $root . '/tests/visual-results/manifest.json';
if (!is_file($result_path)) {
$errors[] = 'Visual result manifest is missing.';
} else {
$result = json_decode((string) file_get_contents($result_path), true, 512, JSON_THROW_ON_ERROR);
if (($result['schema'] ?? '') !== 'aksrt.widget-visual-results.v1') $errors[] = 'Invalid visual result schema.';
$screenshots = (array) ($result['screenshots'] ?? []);
foreach ($matrix as $id => $expected) {
$actual = (array) ($screenshots[$id] ?? []);
$path = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) ($expected['path'] ?? ''));
if (!$actual || !is_file($path)) $errors[] = $id . ': screenshot result is missing.';
}
foreach ($components as $slug => $component) {
$states = (array) ($result['components'][$slug]['states'] ?? []);
foreach ((array) ($component['states'] ?? []) as $state) {
if (($states[$state] ?? '') !== 'pass') $errors[] = $slug . ': state not passed: ' . $state . '.';
}
}
$acceptance = (array) ($fixture['acceptance'] ?? []);
$metrics = (array) ($result['metrics'] ?? []);
if ((float) ($metrics['dimension_delta_px_max'] ?? INF) > (float) $acceptance['dimension_delta_px_max']) $errors[] = 'Dimension delta exceeds threshold.';
if ((float) ($metrics['pixel_difference_percent_max'] ?? INF) > (float) $acceptance['pixel_difference_percent_max']) $errors[] = 'Pixel difference exceeds threshold.';
if ((int) ($result['console']['errors'] ?? PHP_INT_MAX) > (int) $acceptance['console_errors_max']) $errors[] = 'Console contains errors.';
if ((int) ($result['network']['unexpected_failures'] ?? PHP_INT_MAX) > (int) $acceptance['unexpected_network_failures_max']) $errors[] = 'Network contains unexpected failures.';
if ((int) ($result['a11y']['critical'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_critical_max']) $errors[] = 'Accessibility critical findings remain.';
if ((int) ($result['a11y']['serious'] ?? PHP_INT_MAX) > (int) $acceptance['a11y_serious_max']) $errors[] = 'Accessibility serious findings remain.';
if (empty($result['reduced_motion']['passed'])) $errors[] = 'Reduced-motion behavior was not verified.';
}
}
if ($errors) {
fwrite(STDERR, "Widget golden validation failed:\n- " . implode("\n- ", array_slice($errors, 0, 50)) . "\n");
exit(1);
}
echo 'Widget golden fixture passed: 37 components, 222 screenshot targets, frozen reference intact';
echo $require_results ? ", runtime results passed.\n" : ", runtime results pending.\n";