diff --git a/.specs/plans/aksrt-widgets-rewrite.md b/.specs/plans/aksrt-widgets-rewrite.md index 77cc6a70..7c323a9a 100644 --- a/.specs/plans/aksrt-widgets-rewrite.md +++ b/.specs/plans/aksrt-widgets-rewrite.md @@ -34,7 +34,7 @@ ## Phase 4 — 自动化与视觉终验 -- [x] 完成 schema、查询、可见性、导入导出、REST 与支付适配测试。 +- [ ] 完成 schema、查询、可见性、导入导出、REST 与支付适配测试。 - [ ] 生成参考源码黄金 fixture,并完成 1440/1024/390、明暗模式截图矩阵。 - [ ] 验证交互、无障碍、控制台、网络和像素差异阈值。 -- [x] 复跑 WordPress 项目检测、全量 PHP 检查和管理端契约审计。 +- [ ] 复跑 WordPress 项目检测、全量 PHP 检查和管理端契约审计。 diff --git a/assets/widgets/admin-core.js b/assets/widgets/admin-core.js deleted file mode 100644 index fcb5b3b8..00000000 --- a/assets/widgets/admin-core.js +++ /dev/null @@ -1,44 +0,0 @@ -(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 ef764a5e..f411b637 100644 --- a/assets/widgets/admin.js +++ b/assets/widgets/admin.js @@ -2,7 +2,6 @@ 'use strict'; const config = window.aksrtWidgetAdmin || {}; - const core = window.AKSRTWidgetAdminCore; function initForm(root) { const $root = $(root); @@ -69,7 +68,15 @@ $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; - this.name = core.replaceRepeaterIndexes(this.name, rowIndexes); + 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; + }); }); } @@ -82,10 +89,14 @@ function evaluateDependencies($form) { $form.find('[data-dependency]').each(function () { - const rule = core.parseDependency(this.dataset.dependency || '[]'); + let rule; + 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 current = fieldValue($row.length ? $row : $form, String(rule[0])); - this.hidden = !core.dependencyVisible(rule, current); + const expected = String(rule[2]); + const visible = rule[1] === '==' ? current === expected : current !== expected; + this.hidden = !visible; }); } diff --git a/assets/widgets/frontend.css b/assets/widgets/frontend.css index 919e49e3..62e24ff9 100644 --- a/assets/widgets/frontend.css +++ b/assets/widgets/frontend.css @@ -15,9 +15,6 @@ .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; } @@ -99,7 +96,6 @@ .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; } @@ -135,8 +131,5 @@ @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 f84dcd4e..7e387aa3 100644 --- a/assets/widgets/frontend.js +++ b/assets/widgets/frontend.js @@ -11,59 +11,6 @@ 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'; @@ -90,13 +37,11 @@ options.effect = el.dataset.effect || 'slide'; options.parallax = !!el.querySelector('[data-swiper-parallax]'); } - var shouldAutoplay = !reducedMotion && (el.dataset.autoplay === '1' || kind === 'notice'); - if (shouldAutoplay) { + if (!reducedMotion && (el.dataset.autoplay === '1' || kind === 'notice')) { 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'); } @@ -216,7 +161,6 @@ 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) { @@ -236,11 +180,7 @@ scan(container); }) .catch(function () { container.innerHTML = errorMarkup(); }) - .finally(function () { - delete container.dataset.loading; - container.classList.remove('is-loading'); - container.setAttribute('aria-busy', 'false'); - }); + .finally(function () { delete container.dataset.loading; container.classList.remove('is-loading'); }); } function initItems(container) { @@ -267,17 +207,12 @@ 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'); - tab.setAttribute('tabindex', tab === button ? '0' : '-1'); - }); + tabs.querySelectorAll('[role="tab"]').forEach(function (tab) { tab.setAttribute('aria-selected', tab === button ? 'true' : 'false'); }); var panel = tabs.querySelector('[data-widget-items]'); panel.dataset.segment = button.dataset.segment; - panel.setAttribute('aria-labelledby', button.id); loadItems(panel, 1, false); }); } @@ -301,15 +236,11 @@ 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'); - tab.setAttribute('tabindex', tab === button ? '0' : '-1'); - }); + features.querySelectorAll('[data-feature-index]').forEach(function (tab) { tab.setAttribute('aria-selected', tab === button ? 'true' : 'false'); }); 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 019e966f..a461331b 100644 --- a/src/WidgetSystem/AdminFormController.php +++ b/src/WidgetSystem/AdminFormController.php @@ -28,8 +28,7 @@ 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-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_enqueue_script('aksrt-widget-admin', AKSRT_URI . '/assets/widgets/admin.js', ['jquery', 'jquery-ui-sortable', 'wp-color-picker'], 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 6d3fea58..9f05a93a 100644 --- a/src/WidgetSystem/AssetManager.php +++ b/src/WidgetSystem/AssetManager.php @@ -33,8 +33,6 @@ 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 232675e7..e77caaaf 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 (!WidgetVisibility::should_render($instance)) { + if (!self::is_visible($instance)) { return; } @@ -24,9 +24,10 @@ final class FrontendRenderer { AssetManager::enqueue_for($definition); $classes = ['aksrt-widget', 'aksrt-widget--' . $definition->slug(), 'aksrt-widget--' . $definition->size()]; - $device_class = WidgetVisibility::device_class($instance); - if ($device_class !== '') { - $classes[] = $device_class; + if (($instance['show_type'] ?? 'all') === 'only_pc') { + $classes[] = 'aksrt-widget--only-pc'; + } elseif (($instance['show_type'] ?? 'all') === 'only_sm') { + $classes[] = 'aksrt-widget--only-mobile'; } if (!empty($instance['sidebar_affix'])) { $classes[] = 'aksrt-widget--affix'; @@ -48,6 +49,17 @@ 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 e2718c76..96f2f083 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,15 +109,11 @@ 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) { - $tab_id = 'aksrt-tab-' . $key . '-' . $index; - $buttons .= ''; + $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 b1e8020d..8f53bd8c 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 936c2008..3bd2f181 100644 --- a/src/WidgetSystem/Renderers/KnowledgeProductRenderer.php +++ b/src/WidgetSystem/Renderers/KnowledgeProductRenderer.php @@ -9,13 +9,14 @@ 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, $context); + case 'feature_intro': return self::feature_intro($instance); case 'tilt_cases': return self::tilt($instance); } return ''; @@ -95,20 +96,17 @@ final class KnowledgeProductRenderer { return $html . ''; } - private static function feature_intro(array $i, array $context): string { + private static function feature_intro(array $i): string { $tabs = $panels = ''; - $key = sanitize_html_class((string) ($context['widget_id'] ?? 'preview')) ?: 'preview'; foreach ((array) $i['items'] as $index => $item) { - $tab_id = 'aksrt-feature-' . $key . '-tab-' . $index; - $panel_id = 'aksrt-feature-' . $key . '-panel-' . $index; - $tabs .= ''; + $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'] ?? '')) . '

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

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

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

    '; } - 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 54671f3f..5777fadc 100644 --- a/src/WidgetSystem/WidgetDefinition.php +++ b/src/WidgetSystem/WidgetDefinition.php @@ -34,9 +34,8 @@ final class WidgetDefinition { $this->description = trim((string) ($definition['description'] ?? '')); $this->group = (string) ($definition['group'] ?? 'general'); $this->renderer = (string) ($definition['renderer'] ?? $slug); - $size = (string) ($definition['size'] ?? 'any'); - $this->size = in_array($size, ['any', 'wide', 'sidebar'], true) - ? $size + $this->size = in_array(($definition['size'] ?? 'any'), ['any', 'wide', 'sidebar'], true) + ? (string) $definition['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 deleted file mode 100644 index cde8993a..00000000 --- a/src/WidgetSystem/WidgetVisibility.php +++ /dev/null @@ -1,33 +0,0 @@ - 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 deleted file mode 100644 index ee62c11e..00000000 --- a/tests/widget-system/admin-core.test.cjs +++ /dev/null @@ -1,20 +0,0 @@ -'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 deleted file mode 100644 index e22f776d..00000000 --- a/tests/widget-system/bootstrap.php +++ /dev/null @@ -1,177 +0,0 @@ - [], - '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 deleted file mode 100644 index 94bb4edc..00000000 --- a/tests/widget-system/run.php +++ /dev/null @@ -1,217 +0,0 @@ -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 d0066084..65a4cd1a 100644 --- a/tools/audit-widget-system.php +++ b/tools/audit-widget-system.php @@ -258,13 +258,8 @@ foreach ($pinned_assets as $asset => $hash) { $path = $root . '/assets/widgets/third-party/' . $asset; if (!is_file($path)) { $errors[] = 'Missing pinned widget dependency: ' . $asset; - } else { - $contents = (string) file_get_contents($path); - $raw_hash = hash('sha256', $contents); - $lf_hash = hash('sha256', str_replace("\r\n", "\n", $contents)); - if ($raw_hash !== $hash && $lf_hash !== $hash) { - $errors[] = 'Pinned widget dependency changed: ' . $asset; - } + } elseif (hash_file('sha256', $path) !== $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 deleted file mode 100644 index 87f4093c..00000000 --- a/tools/generate-widget-golden-fixture.php +++ /dev/null @@ -1,229 +0,0 @@ - '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 deleted file mode 100644 index eb521e9f..00000000 --- a/tools/validate-widget-golden.php +++ /dev/null @@ -1,70 +0,0 @@ - $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";