' . esc_html((string) ($item['title'] ?? '')) . '
' . esc_html((string) ($item['description'] ?? '')) . '
- ' . $features . '
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 ''; + return ''; } 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 ''; + return ''; } 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((string) ($item['description'] ?? '')) . '
' . esc_html((string) ($item['description'] ?? '')) . '
'; }
+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