From 009b66326a400c985315e8745c20da80bf8093ab Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 17 Aug 2026 19:12:38 +0800 Subject: [PATCH] feat(widgets): add phase 4 validation and accessibility --- .specs/plans/aksrt-widgets-rewrite.md | 4 +- assets/widgets/admin-core.js | 44 + assets/widgets/admin.js | 19 +- assets/widgets/frontend.css | 7 + assets/widgets/frontend.js | 77 +- src/WidgetSystem/AdminFormController.php | 3 +- src/WidgetSystem/AssetManager.php | 2 + src/WidgetSystem/FrontendRenderer.php | 20 +- .../Renderers/ContentRenderer.php | 12 +- .../Renderers/GeneralRenderer.php | 2 +- .../Renderers/KnowledgeProductRenderer.php | 14 +- src/WidgetSystem/WidgetDefinition.php | 5 +- src/WidgetSystem/WidgetVisibility.php | 33 + tests/fixtures/widget-golden-v1.json | 7090 +++++++++++++++++ tests/visual-results/README.md | 11 + tests/visual-results/static-a11y-report.md | 28 + tests/widget-system/a11y-contract.test.cjs | 38 + tests/widget-system/admin-core.test.cjs | 20 + tests/widget-system/bootstrap.php | 177 + tests/widget-system/run.php | 217 + tools/audit-widget-system.php | 9 +- tools/generate-widget-golden-fixture.php | 229 + tools/validate-widget-golden.php | 70 + 23 files changed, 8078 insertions(+), 53 deletions(-) create mode 100644 assets/widgets/admin-core.js create mode 100644 src/WidgetSystem/WidgetVisibility.php create mode 100644 tests/fixtures/widget-golden-v1.json create mode 100644 tests/visual-results/README.md create mode 100644 tests/visual-results/static-a11y-report.md create mode 100644 tests/widget-system/a11y-contract.test.cjs create mode 100644 tests/widget-system/admin-core.test.cjs create mode 100644 tests/widget-system/bootstrap.php create mode 100644 tests/widget-system/run.php create mode 100644 tools/generate-widget-golden-fixture.php create mode 100644 tools/validate-widget-golden.php diff --git a/.specs/plans/aksrt-widgets-rewrite.md b/.specs/plans/aksrt-widgets-rewrite.md index 7c323a9a..77cc6a70 100644 --- a/.specs/plans/aksrt-widgets-rewrite.md +++ b/.specs/plans/aksrt-widgets-rewrite.md @@ -34,7 +34,7 @@ ## Phase 4 — 自动化与视觉终验 -- [ ] 完成 schema、查询、可见性、导入导出、REST 与支付适配测试。 +- [x] 完成 schema、查询、可见性、导入导出、REST 与支付适配测试。 - [ ] 生成参考源码黄金 fixture,并完成 1440/1024/390、明暗模式截图矩阵。 - [ ] 验证交互、无障碍、控制台、网络和像素差异阈值。 -- [ ] 复跑 WordPress 项目检测、全量 PHP 检查和管理端契约审计。 +- [x] 复跑 WordPress 项目检测、全量 PHP 检查和管理端契约审计。 diff --git a/assets/widgets/admin-core.js b/assets/widgets/admin-core.js new file mode 100644 index 00000000..fcb5b3b8 --- /dev/null +++ b/assets/widgets/admin-core.js @@ -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 + }; +}); diff --git a/assets/widgets/admin.js b/assets/widgets/admin.js index f411b637..ef764a5e 100644 --- a/assets/widgets/admin.js +++ b/assets/widgets/admin.js @@ -2,6 +2,7 @@ 'use strict'; const config = window.aksrtWidgetAdmin || {}; + const core = window.AKSRTWidgetAdminCore; function initForm(root) { const $root = $(root); @@ -68,15 +69,7 @@ $form.find('[name]').each(function () { const rowIndexes = $(this).parents('.aksrt-widget-repeater__row').get().reverse().map((row) => Number(row.dataset.rowIndex || 0)); if (!rowIndexes.length) return; - let foundWidgetNumber = false; - 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; - }); + this.name = core.replaceRepeaterIndexes(this.name, rowIndexes); }); } @@ -89,14 +82,10 @@ function evaluateDependencies($form) { $form.find('[data-dependency]').each(function () { - let rule; - try { rule = JSON.parse(this.dataset.dependency || '[]'); } catch (_error) { return; } - if (!Array.isArray(rule) || rule.length < 3) return; + const rule = core.parseDependency(this.dataset.dependency || '[]'); const $row = $(this).closest('.aksrt-widget-repeater__row'); const current = fieldValue($row.length ? $row : $form, String(rule[0])); - const expected = String(rule[2]); - const visible = rule[1] === '==' ? current === expected : current !== expected; - this.hidden = !visible; + this.hidden = !core.dependencyVisible(rule, current); }); } diff --git a/assets/widgets/frontend.css b/assets/widgets/frontend.css index 62e24ff9..919e49e3 100644 --- a/assets/widgets/frontend.css +++ b/assets/widgets/frontend.css @@ -15,6 +15,9 @@ .aksrt-widget img, .aksrt-widget video { max-width: 100%; } .aksrt-widget a { color: inherit; text-decoration: none; } .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--big { display: block; text-align: center; margin-bottom: 30px; } .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-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-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-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) { .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; } @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); } } diff --git a/assets/widgets/frontend.js b/assets/widgets/frontend.js index 7e387aa3..f84dcd4e 100644 --- a/assets/widgets/frontend.js +++ b/assets/widgets/frontend.js @@ -11,6 +11,59 @@ 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) { if (!once(el, 'Swiper') || typeof window.Swiper !== 'function') return; var kind = el.dataset.aksrtSwiper || 'cards'; @@ -37,11 +90,13 @@ options.effect = el.dataset.effect || 'slide'; 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 }; } try { el.aksrtSwiper = new window.Swiper(el, options); + if (shouldAutoplay) initSwiperPause(el, el.aksrtSwiper); } catch (error) { el.classList.add('is-fallback'); } @@ -161,6 +216,7 @@ if (!widget || !instance || !config.restUrl || container.dataset.loading === '1') return; container.dataset.loading = '1'; container.classList.add('is-loading'); + container.setAttribute('aria-busy', 'true'); 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 (data) { @@ -180,7 +236,11 @@ scan(container); }) .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) { @@ -207,12 +267,17 @@ function initTabs(tabs) { if (!once(tabs, 'Tabs')) return; + tabs.addEventListener('keydown', function (event) { moveTabFocus(event, tabs); }); tabs.addEventListener('click', function (event) { var button = event.target.closest('[data-segment]'); 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]'); panel.dataset.segment = button.dataset.segment; + panel.setAttribute('aria-labelledby', button.id); loadItems(panel, 1, false); }); } @@ -236,11 +301,15 @@ function initFeatures(features) { if (!once(features, 'Features')) return; + features.addEventListener('keydown', function (event) { moveTabFocus(event, features); }); features.addEventListener('click', function (event) { var button = event.target.closest('[data-feature-index]'); if (!button) return; 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; }); }); } diff --git a/src/WidgetSystem/AdminFormController.php b/src/WidgetSystem/AdminFormController.php index a461331b..019e966f 100644 --- a/src/WidgetSystem/AdminFormController.php +++ b/src/WidgetSystem/AdminFormController.php @@ -28,7 +28,8 @@ final class AdminFormController { 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-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', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'action' => self::AJAX_ACTION, diff --git a/src/WidgetSystem/AssetManager.php b/src/WidgetSystem/AssetManager.php index 9f05a93a..6d3fea58 100644 --- a/src/WidgetSystem/AssetManager.php +++ b/src/WidgetSystem/AssetManager.php @@ -33,6 +33,8 @@ final class AssetManager { 'loading' => __('正在加载…', 'aksrt'), 'error' => __('加载失败,请重试。', 'aksrt'), 'retry' => __('重试', 'aksrt'), + 'pause' => __('暂停自动播放', 'aksrt'), + 'resume' => __('继续自动播放', 'aksrt'), ], ]; wp_add_inline_script('aksrt-widgets', 'window.aksrtWidgets=' . wp_json_encode($config) . ';', 'before'); diff --git a/src/WidgetSystem/FrontendRenderer.php b/src/WidgetSystem/FrontendRenderer.php index e77caaaf..232675e7 100644 --- a/src/WidgetSystem/FrontendRenderer.php +++ b/src/WidgetSystem/FrontendRenderer.php @@ -9,7 +9,7 @@ defined('ABSPATH') || exit; */ final class FrontendRenderer { public static function render(WidgetDefinition $definition, array $args, array $instance, SchemaWidget $widget): void { - if (!self::is_visible($instance)) { + if (!WidgetVisibility::should_render($instance)) { return; } @@ -24,10 +24,9 @@ final class FrontendRenderer { AssetManager::enqueue_for($definition); $classes = ['aksrt-widget', 'aksrt-widget--' . $definition->slug(), 'aksrt-widget--' . $definition->size()]; - if (($instance['show_type'] ?? 'all') === 'only_pc') { - $classes[] = 'aksrt-widget--only-pc'; - } elseif (($instance['show_type'] ?? 'all') === 'only_sm') { - $classes[] = 'aksrt-widget--only-mobile'; + $device_class = WidgetVisibility::device_class($instance); + if ($device_class !== '') { + $classes[] = $device_class; } if (!empty($instance['sidebar_affix'])) { $classes[] = 'aksrt-widget--affix'; @@ -49,17 +48,6 @@ final class FrontendRenderer { echo (string) ($args['after_widget'] ?? ''); // 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 { $title = trim((string) ($instance['title'] ?? '')); if ($title === '') { diff --git a/src/WidgetSystem/Renderers/ContentRenderer.php b/src/WidgetSystem/Renderers/ContentRenderer.php index 96f2f083..e2718c76 100644 --- a/src/WidgetSystem/Renderers/ContentRenderer.php +++ b/src/WidgetSystem/Renderers/ContentRenderer.php @@ -97,10 +97,10 @@ final class ContentRenderer { private static function posts(string $slug, array $instance, array $context): string { $widget_id = (string) ($context['widget_id'] ?? ''); if (($instance['load_mode'] ?? 'sync') === 'ajax') { - return '
'; + return '
'; } $payload = PostRenderer::payload($instance, $slug, 1); - return '
' . $payload['html'] . $payload['paginate_html'] . '
'; + return '
' . $payload['html'] . $payload['paginate_html'] . '
'; } private static function tab_posts(array $instance, array $context): string { @@ -109,11 +109,15 @@ final class ContentRenderer { return RendererHelpers::empty_state(__('请配置文章栏目', 'aksrt')); } $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) { - $buttons .= ''; + $tab_id = 'aksrt-tab-' . $key . '-' . $index; + $buttons .= ''; } $payload = PostRenderer::payload($instance, 'tab_posts', 1, '0'); - return '
' . $buttons . '
' . $payload['html'] . $payload['paginate_html'] . '
'; + return '
' . $buttons . '
' . $payload['html'] . $payload['paginate_html'] . '
'; } private static function post_pay(array $instance): string { diff --git a/src/WidgetSystem/Renderers/GeneralRenderer.php b/src/WidgetSystem/Renderers/GeneralRenderer.php index 8f53bd8c..b1e8020d 100644 --- a/src/WidgetSystem/Renderers/GeneralRenderer.php +++ b/src/WidgetSystem/Renderers/GeneralRenderer.php @@ -67,7 +67,7 @@ final class GeneralRenderer { return RendererHelpers::empty_state(__('请配置视频地址', 'aksrt')); } $ratio = (string) $i['ratio']; - return '
'; + return '
'; } private static function render_embed(array $i): string { diff --git a/src/WidgetSystem/Renderers/KnowledgeProductRenderer.php b/src/WidgetSystem/Renderers/KnowledgeProductRenderer.php index 3bd2f181..936c2008 100644 --- a/src/WidgetSystem/Renderers/KnowledgeProductRenderer.php +++ b/src/WidgetSystem/Renderers/KnowledgeProductRenderer.php @@ -9,14 +9,13 @@ defined('ABSPATH') || exit; /** Render the seven knowledge-product presentation widgets. */ final class KnowledgeProductRenderer { public static function render(string $slug, array $instance, array $context = []): string { - unset($context); switch ($slug) { case 'pricing_plans': return self::pricing($instance); case 'reviews': return self::reviews($instance); case 'faq': return self::faq($instance); case 'stats': return self::stats($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); } return ''; @@ -96,17 +95,20 @@ final class KnowledgeProductRenderer { return $html . ''; } - private static function feature_intro(array $i): string { + private static function feature_intro(array $i, array $context): string { $tabs = $panels = ''; + $key = sanitize_html_class((string) ($context['widget_id'] ?? 'preview')) ?: 'preview'; foreach ((array) $i['items'] as $index => $item) { - $tabs .= ''; + $tab_id = 'aksrt-feature-' . $key . '-tab-' . $index; + $panel_id = 'aksrt-feature-' . $key . '-panel-' . $index; + $tabs .= ''; $features = ''; foreach (RendererHelpers::lines((string) ($item['features'] ?? '')) as $feature) { $features .= '
  • ' . esc_html($feature) . '
  • '; } - $panels .= '
    ' . RendererHelpers::card_media((array) $item) . '

    ' . esc_html((string) ($item['title'] ?? '')) . '

    ' . esc_html((string) ($item['description'] ?? '')) . '

      ' . $features . '
    '; + $panels .= '
    ' . RendererHelpers::card_media((array) $item) . '

    ' . esc_html((string) ($item['title'] ?? '')) . '

    ' . esc_html((string) ($item['description'] ?? '')) . '

      ' . $features . '
    '; } - return '
    ' . $tabs . '
    ' . $panels . '
    '; + return '
    ' . $tabs . '
    ' . $panels . '
    '; } private static function tilt(array $i): string { diff --git a/src/WidgetSystem/WidgetDefinition.php b/src/WidgetSystem/WidgetDefinition.php index 5777fadc..54671f3f 100644 --- a/src/WidgetSystem/WidgetDefinition.php +++ b/src/WidgetSystem/WidgetDefinition.php @@ -34,8 +34,9 @@ final class WidgetDefinition { $this->description = trim((string) ($definition['description'] ?? '')); $this->group = (string) ($definition['group'] ?? 'general'); $this->renderer = (string) ($definition['renderer'] ?? $slug); - $this->size = in_array(($definition['size'] ?? 'any'), ['any', 'wide', 'sidebar'], true) - ? (string) $definition['size'] + $size = (string) ($definition['size'] ?? 'any'); + $this->size = in_array($size, ['any', 'wide', 'sidebar'], true) + ? $size : 'any'; $this->fields = array_values((array) ($definition['fields'] ?? [])); $this->common = array_merge( diff --git a/src/WidgetSystem/WidgetVisibility.php b/src/WidgetSystem/WidgetVisibility.php new file mode 100644 index 00000000..cde8993a --- /dev/null +++ b/src/WidgetSystem/WidgetVisibility.php @@ -0,0 +1,33 @@ + 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.'); diff --git a/tests/widget-system/admin-core.test.cjs b/tests/widget-system/admin-core.test.cjs new file mode 100644 index 00000000..ee62c11e --- /dev/null +++ b/tests/widget-system/admin-core.test.cjs @@ -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.'); diff --git a/tests/widget-system/bootstrap.php b/tests/widget-system/bootstrap.php new file mode 100644 index 00000000..e22f776d --- /dev/null +++ b/tests/widget-system/bootstrap.php @@ -0,0 +1,177 @@ + [], + '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, '

      1. '); } +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 '
        ' . esc_html($message) . '
        '; } +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 '
        PAYWALL
        '; +} +function aksrt_get_pay_attributes_html($post, string $class): string { unset($post); return '
          '; } +function aksrt_get_demo_link_html($post, string $class): string { unset($post); return '
          DEMO'; } +function is_user_logged_in(): bool { return (bool) $GLOBALS['aksrt_test_state']['logged_in']; } + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; diff --git a/tests/widget-system/run.php b/tests/widget-system/run.php new file mode 100644 index 00000000..94bb4edc --- /dev/null +++ b/tests/widget-system/run.php @@ -0,0 +1,217 @@ +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' => '课程', 'target' => 'popup'], + 'layout' => ['enabled' => 'yes', 'gap' => -9, 'unknown' => 'drop'], + 'rows' => [ + ['title' => '第一项', 'tone' => 'red'], + ['title' => '第二项', 'tone' => 'invalid'], + ['title' => '第三项'], + ], + ]); + $this->same('cards', $normalized['mode'], 'invalid enum falls back'); + $this->same(8, $normalized['count'], 'numeric value clamps to maximum'); + $this->same('2,7,4', $normalized['ids'], 'ID list is unique and normalized'); + $this->same(['url' => '', 'text' => '课程', 'target' => ''], $normalized['link'], 'link parts sanitize independently'); + $this->same(['enabled' => true, 'gap' => 0], $normalized['layout'], 'nested values clamp and drop unknown keys'); + $this->same(2, count($normalized['rows']), 'group max_items is enforced'); + $this->same('blue', $normalized['rows'][1]['tone'], 'nested enum fallback is enforced'); + + $definition = new WidgetDefinition([ + 'slug' => 'dependency_test', 'name' => 'Dependency Test', + 'common' => ['title' => false, 'affix' => false, 'visibility' => false, 'animation' => false, 'layout' => false], + 'fields' => [ + ['id' => 'controller', 'type' => 'select', 'default' => 'off', 'options' => ['off' => 'Off', 'on' => 'On']], + ['id' => 'dependent', 'type' => 'text', 'dependency' => ['controller', '==', 'on']], + ], + ]); + $widget = new SchemaWidget($definition); + ob_start(); + $widget->render_full_form(['controller' => 'on', 'dependent' => 'visible']); + $html = (string) ob_get_clean(); + $this->contains('data-dependency=', $html, 'form exports dependency rule'); + $this->contains('"controller"', $html, 'dependency JSON is safely escaped'); + } + + private function query_contract(): void { + $args = PostQuery::arguments([ + 'count' => 99, 'category_ids' => '2,5', 'category_id' => 9, 'tag_ids' => '8,13', + 'limit_day' => 14, 'orderby' => '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' => '课程列表', '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[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(); diff --git a/tools/audit-widget-system.php b/tools/audit-widget-system.php index 65a4cd1a..d0066084 100644 --- a/tools/audit-widget-system.php +++ b/tools/audit-widget-system.php @@ -258,8 +258,13 @@ foreach ($pinned_assets as $asset => $hash) { $path = $root . '/assets/widgets/third-party/' . $asset; if (!is_file($path)) { $errors[] = 'Missing pinned widget dependency: ' . $asset; - } elseif (hash_file('sha256', $path) !== $hash) { - $errors[] = 'Pinned widget dependency changed: ' . $asset; + } else { + $contents = (string) file_get_contents($path); + $raw_hash = hash('sha256', $contents); + $lf_hash = hash('sha256', str_replace("\r\n", "\n", $contents)); + if ($raw_hash !== $hash && $lf_hash !== $hash) { + $errors[] = 'Pinned widget dependency changed: ' . $asset; + } } } if (!is_file($root . '/assets/widgets/third-party/THIRD_PARTY.md')) { diff --git a/tools/generate-widget-golden-fixture.php b/tools/generate-widget-golden-fixture.php new file mode 100644 index 00000000..87f4093c --- /dev/null +++ b/tools/generate-widget-golden-fixture.php @@ -0,0 +1,229 @@ + '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"; diff --git a/tools/validate-widget-golden.php b/tools/validate-widget-golden.php new file mode 100644 index 00000000..eb521e9f --- /dev/null +++ b/tools/validate-widget-golden.php @@ -0,0 +1,70 @@ + $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"; -- 2.54.0