diff --git a/assets/widgets/frontend.css b/assets/widgets/frontend.css index 79768426..955c5a7f 100644 --- a/assets/widgets/frontend.css +++ b/assets/widgets/frontend.css @@ -68,9 +68,11 @@ .dark-theme .zib-widget-wrap, [data-theme="dark"] .zib-widget-wrap { background-color:var(--aksrt-widget-bg-dark,var(--aksrt-widget-bg-light,transparent)); background-image:var(--aksrt-widget-bg-image-dark,var(--aksrt-widget-bg-image-light,none)); } .zib-widget-wrap .widget-container { position:relative; } .aksrt-user-widget-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible, -.aksrt-tab-posts-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline:3px solid var(--focus-color,#ff5473) !important; outline-offset:3px; } +.aksrt-tab-posts-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible, +.aksrt-posts-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline:3px solid var(--focus-color,#ff5473) !important; outline-offset:3px; } .dark-theme :is(.aksrt-user-widget-wrap, .aksrt-tab-posts-wrap) :where(a, button, input, select, textarea, [tabindex]):focus-visible, -[data-theme="dark"] :is(.aksrt-user-widget-wrap, .aksrt-tab-posts-wrap) :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline-color:#ffd166 !important; } +.dark-theme .aksrt-posts-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible, +[data-theme="dark"] :is(.aksrt-user-widget-wrap, .aksrt-tab-posts-wrap, .aksrt-posts-wrap) :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline-color:#ffd166 !important; } .user-card.zib-widget { padding:0; overflow:hidden; } .user-card.widget .user-info > .text-center > p:not(.muted-color) { white-space:nowrap; } @@ -159,21 +161,21 @@ .widget-tab-post, .term-aggregation { line-height:1.42857143; } /* Public typography overrides Bootstrap headings. Match the reference widget * without changing article headings elsewhere on the page. */ -.aksrt-tab-posts-wrap { font-size:14px; line-height:1.42857143; color:var(--main-color,#4e5358); } -.widget-tab-post .item-heading { font-weight:500; color:inherit; } -.widget-tab-post .item-heading.font-bold { font-weight:700; } -.widget-tab-post .posts-mini { vertical-align:baseline; } -.widget-tab-post .posts-mini-con { flex:auto; } -.widget-tab-post .item-meta .meta-pay { padding:0 4px; cursor:auto; } +.aksrt-tab-posts-wrap, .aksrt-posts-wrap { font-size:14px; line-height:1.42857143; color:var(--main-color,#4e5358); } +.widget-tab-post .item-heading, .widget-main-post .item-heading { font-weight:500; color:inherit; } +.widget-tab-post .item-heading.font-bold, .widget-main-post .item-heading.font-bold { font-weight:700; } +.widget-tab-post .posts-mini, .widget-main-post .posts-mini { vertical-align:baseline; } +.widget-tab-post .posts-mini-con, .widget-main-post .posts-mini-con { flex:auto; } +.widget-tab-post .item-meta .meta-pay, .widget-main-post .item-meta .meta-pay { padding:0 4px; cursor:auto; } .widget-tab-post .post_ajax_loader { min-height: 40px; } /* Keep the reference's compact pagination after public pagination.css. */ -.widget-tab-post .pagenav { +.widget-tab-post .pagenav, .widget-main-post .pagenav { display:block; padding:20px 0; line-height:1.4; text-align:center; } -.widget-tab-post .pagenav .page-numbers { +.widget-tab-post .pagenav .page-numbers, .widget-main-post .pagenav .page-numbers { display:inline-block; min-width:31px; min-height:0; @@ -187,8 +189,8 @@ line-height:inherit; font-weight:400; } -.widget-tab-post .pagenav a.page-numbers:hover { background:var(--main-border-color); color:var(--muted-color); opacity:.7; } -.widget-tab-post .pagenav .page-numbers > span { +.widget-tab-post .pagenav a.page-numbers:hover, .widget-main-post .pagenav a.page-numbers:hover { background:var(--main-border-color); color:var(--muted-color); opacity:.7; } +.widget-tab-post .pagenav .page-numbers > span, .widget-main-post .pagenav .page-numbers > span { display:inline; min-width:0; min-height:0; @@ -201,12 +203,12 @@ font-size:inherit; line-height:inherit; } -.widget-tab-post .pagenav .page-numbers > .ml6 { margin-left:6px; } -.widget-tab-post .pagenav .page-numbers > .mr6 { margin-right:6px; } -.widget-tab-post .pagenav .prev i, -.widget-tab-post .pagenav .next i { font-size:1.2em; } -.widget-tab-post .pagenav .pag-jump { display:inline-flex; position:relative; width:auto; align-items:center; justify-content:normal; gap:0; } -.widget-tab-post .pagenav .pag-jump .jump-input { +.widget-tab-post .pagenav .page-numbers > .ml6, .widget-main-post .pagenav .page-numbers > .ml6 { margin-left:6px; } +.widget-tab-post .pagenav .page-numbers > .mr6, .widget-main-post .pagenav .page-numbers > .mr6 { margin-right:6px; } +.widget-tab-post .pagenav .prev i, .widget-main-post .pagenav .prev i, +.widget-tab-post .pagenav .next i, .widget-main-post .pagenav .next i { font-size:1.2em; } +.widget-tab-post .pagenav .pag-jump, .widget-main-post .pagenav .pag-jump { display:inline-flex; position:relative; width:auto; align-items:center; justify-content:normal; gap:0; } +.widget-tab-post .pagenav .pag-jump .jump-input, .widget-main-post .pagenav .pag-jump .jump-input { position:absolute; inset:0; width:100%; @@ -217,23 +219,22 @@ opacity:0; transition:.2s; } -.widget-tab-post .pagenav .jump-input:focus, -.widget-tab-post .pagenav .pag-jump:hover .jump-input { opacity:1; } -.sidebar .widget-tab-post .pagenav { padding:10px 5px; } -.sidebar .widget-tab-post .theme-pagination { margin:10px; } -.sidebar .widget-tab-post .pagenav .page-numbers { min-width:28px; margin:2px; font-size:12px; } +.widget-tab-post .pagenav .jump-input:focus, .widget-main-post .pagenav .jump-input:focus, +.widget-tab-post .pagenav .pag-jump:hover .jump-input, .widget-main-post .pagenav .pag-jump:hover .jump-input { opacity:1; } +.sidebar :is(.widget-tab-post, .widget-main-post) .pagenav { padding:10px 5px; } +.sidebar :is(.widget-tab-post, .widget-main-post) .theme-pagination { margin:10px; } +.sidebar :is(.widget-tab-post, .widget-main-post) .pagenav .page-numbers { min-width:28px; margin:2px; font-size:12px; } @media (max-width:640px) { - .widget-tab-post .pagenav a.page-numbers { display:none; } - .widget-tab-post .pagenav a.next, - .widget-tab-post .pagenav a.prev { display:inline-block; } - .widget-tab-post .pagenav .page-numbers { min-width:28px; margin:2px; font-size:12px; } + :is(.widget-tab-post, .widget-main-post) .pagenav a.page-numbers { display:none; } + :is(.widget-tab-post, .widget-main-post) .pagenav a.next, + :is(.widget-tab-post, .widget-main-post) .pagenav a.prev { display:inline-block; } + :is(.widget-tab-post, .widget-main-post) .pagenav .page-numbers { min-width:28px; margin:2px; font-size:12px; } } /* Widget pagination mirrors the reference .ajax-next rules while staying on the REST client. */ .theme-pagination { margin: 20px 0; } .theme-pagination .next-page a { border-radius: 30px; padding: 5px 15px; color: var(--muted-color, #8a8c98); opacity: .6; cursor: pointer; } .theme-pagination .next-page a:hover { background: var(--main-border-color, rgba(127,127,127,.18)); opacity: 1; } .pagenav .page-numbers { cursor: pointer; } -.widget-ajaxpager[lazyload-action="ias"] .post_ajax_trigger { display: none; } /* 组合模块 PC 端为左右两列;后台预览不加载 main.css 的 .flex 工具类,这里需自声明。 */ .widget-slider-graphic { display: flex; gap: 10px; align-items: stretch; } .widget-slider-graphic .slider-col { width: var(--layout-ratio, 60%); min-width: 0; } diff --git a/assets/widgets/frontend.js b/assets/widgets/frontend.js index 3d5ccc6e..ef408131 100644 --- a/assets/widgets/frontend.js +++ b/assets/widgets/frontend.js @@ -85,10 +85,17 @@ } function itemsUrl(widget, instance, page, segment) { - var base = String(config.restUrl || '').replace(/\/$/, ''); - var url = base + '/' + encodeURIComponent(widget) + '/items?instance=' + encodeURIComponent(instance) + '&page=' + encodeURIComponent(page); - if (segment !== undefined && segment !== '') url += '&segment=' + encodeURIComponent(segment); - return url; + var url = new URL(config.restUrl, window.location.href); + var path = '/' + encodeURIComponent(widget) + '/items'; + if (url.searchParams.has('rest_route')) { + url.searchParams.set('rest_route', url.searchParams.get('rest_route').replace(/\/$/, '') + path); + } else { + url.pathname = url.pathname.replace(/\/$/, '') + path; + } + url.searchParams.set('instance', instance); + url.searchParams.set('page', page); + if (segment !== undefined && segment !== '') url.searchParams.set('segment', segment); + return url.href; } function loaderOf(container) { @@ -192,6 +199,14 @@ function initItems(container) { if (!once(container, 'Items')) return; + if (container.hasAttribute('data-widget-lazy') && container.dataset.loaded !== '1') { + container.addEventListener('lazybeforeunveil', function (event) { + if (event.target === container && container.dataset.loaded !== '1') loadItems(container, 1, false); + }); + // main.js loads asynchronously through loader_js. Its lazySizes scan + // may finish before this client binds, so consume an unveiled pager. + if (!container.classList.contains('lazyload')) loadItems(container, 1, false); + } function jumpTo(input) { var page = Math.min(Number(input.max), Math.floor(Number(input.value))); if (!Number.isFinite(page) || page < 1 || page === Number(container.dataset.page || 1)) { @@ -229,11 +244,8 @@ return; } if (trigger) { - // data-widget-ias marks the hidden reveal trigger the runtime clicks - // when the lazy container scrolls into view; it reloads page one. event.preventDefault(); - if (trigger.dataset.widgetIas === '1') loadItems(container, 1, false); - else loadItems(container, Number(trigger.dataset.nextPage || 2), true); + loadItems(container, Number(trigger.dataset.nextPage || 2), true); return; } if (pageLink) { @@ -346,6 +358,7 @@ var scope = root || document; scope.querySelectorAll('.aksrt-widget-video[data-video-url]').forEach(initVideo); scope.querySelectorAll('.aksrt-widget-quote').forEach(initQuote); + if (scope.matches && scope.matches('[data-widget-items]')) initItems(scope); scope.querySelectorAll('[data-widget-items]').forEach(initItems); scope.querySelectorAll('.widget-tab-post').forEach(initTabs); scope.querySelectorAll('.aksrt-faq').forEach(initFaq); diff --git a/src/WidgetSystem/FrontendRenderer.php b/src/WidgetSystem/FrontendRenderer.php index 9094ff3d..1630d3ed 100644 --- a/src/WidgetSystem/FrontendRenderer.php +++ b/src/WidgetSystem/FrontendRenderer.php @@ -23,19 +23,18 @@ final class FrontendRenderer { } AssetManager::enqueue_for($definition); - if (in_array($definition->slug(), ['user_card', 'author_card', 'tab_posts'], true)) { + if (in_array($definition->slug(), ['user_card', 'author_card', 'tab_posts', 'posts'], true)) { self::render_reference_widget($definition, $instance, $widget, $content); return; } if (in_array($definition->slug(), [ - 'post_pay', 'posts', 'oneline_posts', 'hot_posts', + 'post_pay', 'oneline_posts', 'hot_posts', 'slider', 'slider_media_combo', 'icon_cards', 'icon_cover_cards', 'buttons', 'notice', 'term_cards', 'term_aggregation', 'media_cards', 'combo_info', 'partners', 'recent_comments', 'posts_toc', ], true)) { echo (string) ($args['before_widget'] ?? ''); if ($definition->slug() !== 'post_pay') echo self::reference_title($instance); - $animation = ''; $content_classes = ['widget-content']; $device_class = WidgetVisibility::device_class($instance); if ($device_class !== '') { @@ -107,7 +106,8 @@ final class FrontendRenderer { } $style = self::style($instance); - echo '
'; + $affix_attr = !empty($instance['sidebar_affix']) ? ' data-affix="true"' : ''; + echo '
'; echo '
'; echo self::reference_title($instance); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '
' . $content . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped diff --git a/src/WidgetSystem/PostQuery.php b/src/WidgetSystem/PostQuery.php index 32e8462b..b7fb7598 100644 --- a/src/WidgetSystem/PostQuery.php +++ b/src/WidgetSystem/PostQuery.php @@ -24,7 +24,7 @@ final class PostQuery { 'ignore_sticky_posts' => true, 'no_found_rows' => false, ]; - if (array_key_exists('paginate', $config) && in_array((string) $config['paginate'], ['', 'none'], true)) { + if (isset($config['paginate']) && $config['paginate'] === '') { $args['no_found_rows'] = true; $args['paged'] = 1; } @@ -33,13 +33,22 @@ final class PostQuery { // category_ids remains available only to the unrelated generic posts // widgets; a tab segment must never fall back to it. $is_reference_segment = array_key_exists('cat', $segment); - $cat_raw = array_key_exists('cat', $config) ? (string) $config['cat'] : ($is_reference_segment ? '' : (string) ($config['category_ids'] ?? '')); - $categories = self::signed_ids($cat_raw); - if (!empty($config['category_id'])) $categories = [(int) $config['category_id']]; - $cat_in = array_values(array_filter($categories, static fn(int $id): bool => $id > 0)); - $cat_not = array_values(array_map('abs', array_filter($categories, static fn(int $id): bool => $id < 0))); - if ($cat_in) $args['category__in'] = $cat_in; - if ($cat_not) $args['category__not_in'] = $cat_not; + if (array_key_exists('cat', $config)) { + // WP_Query's `cat` expands included parents to descendants and + // applies negative IDs with the same reference-theme semantics. + $categories = self::signed_ids((string) $config['cat']); + if ($categories) { + $args['cat'] = implode(',', $categories); + } + } else { + // Generic widgets retain their explicit category ID/list behavior. + $categories = self::signed_ids((string) ($config['category_ids'] ?? '')); + if (!empty($config['category_id'])) $categories = [(int) $config['category_id']]; + $cat_in = array_values(array_filter($categories, static fn(int $id): bool => $id > 0)); + $cat_not = array_values(array_map('abs', array_filter($categories, static fn(int $id): bool => $id < 0))); + if ($cat_in) $args['category__in'] = $cat_in; + if ($cat_not) $args['category__not_in'] = $cat_not; + } $tags = $is_reference_segment ? [] : RendererHelpers::ids((string) ($config['tag_ids'] ?? '')); if ($tags) { $args['tag__in'] = $tags; @@ -47,14 +56,15 @@ final class PostQuery { $days = max(0, (int) ($config['limit_day'] ?? 0)); if ($days > 0) { - $now = function_exists('current_time') ? current_time('timestamp') : time(); - $format = static function (int $timestamp): string { - return function_exists('wp_date') ? wp_date('Y-m-d H:i:s', $timestamp) : date('Y-m-d H:i:s', $timestamp); - }; - $args['date_query'] = [['after' => $format($now - ($days * DAY_IN_SECONDS)), 'before' => $format($now), 'inclusive' => true]]; + $now = current_datetime(); + $args['date_query'] = [[ + 'after' => $now->modify('-' . $days . ' days')->format('Y-m-d H:i:s'), + 'before' => $now->format('Y-m-d H:i:s'), + 'inclusive' => true, + ]]; } self::apply_order($args, (string) ($config['orderby'] ?? 'date'), (string) ($config['order'] ?? 'DESC')); - $pay = $is_reference_segment ? ($config['zibpay_type'] ?? []) : ($config['pay_type'] ?? ''); + $pay = array_key_exists('zibpay_type', $config) ? $config['zibpay_type'] : ($config['pay_type'] ?? ''); self::apply_pay_type($args, $pay); return $args; } diff --git a/src/WidgetSystem/PostRenderer.php b/src/WidgetSystem/PostRenderer.php index d0a92551..5af82f31 100644 --- a/src/WidgetSystem/PostRenderer.php +++ b/src/WidgetSystem/PostRenderer.php @@ -16,6 +16,10 @@ final class PostRenderer { } public static function payload(array $instance, string $slug, int $page = 1, string $segment = ''): array { + $page = max(1, $page); + if (($instance['paginate'] ?? '') === '') { + $page = 1; + } $segment_config = $slug === 'tab_posts' ? PostQuery::segment($instance, $segment) : []; $query = PostQuery::build($instance, $page, $segment_config); $html = self::items($query, $instance, $slug, $page); @@ -47,9 +51,7 @@ final class PostRenderer { 'is_tag' => false, 'is_author' => false, 'is_search' => false, - 'is_mult_thumb' => 'disable', - 'is_no_thumb' => 'disable', - 'show_number' => in_array('show_number', $mini_opt, true), + 'show_number' => $page === 1 && in_array('show_number', $mini_opt, true), 'show_thumb' => in_array('show_thumb', $mini_opt, true), 'show_meta' => in_array('show_meta', $mini_opt, true), 'number_offset' => ($page - 1) * max(1, (int) ($instance['count'] ?? 8)), @@ -60,6 +62,11 @@ final class PostRenderer { if ($mode === 'mini') { return aksrt_render_mini_list($query, array_merge($config, ['class' => 'ajax-item']), false); } + if ($mode === 'list') { + // Zibll suppresses archive-only thumb variants in widget list mode. + $config['is_mult_thumb'] = 'disable'; + $config['is_no_thumb'] = 'disable'; + } // The .widget-ajaxpager container rendered by the caller is the ajax // target, so item markup goes in without the page-level list wrapper. return aksrt_render_post_list(array_merge($config, ['skip_wrapper' => true]), $query, false); @@ -78,11 +85,12 @@ final class PostRenderer { 'show_number' => false, ])) : ''; } else { - $items .= aksrt_render_post(array_merge($config, [ - 'type' => $style === 'card' ? 'card' : 'list', - 'is_mult_thumb' => 'disable', - 'is_no_thumb' => 'disable', - ])); + $item_config = array_merge($config, ['type' => $style === 'card' ? 'card' : 'list']); + if ($style === 'list') { + $item_config['is_mult_thumb'] = 'disable'; + $item_config['is_no_thumb'] = 'disable'; + } + $items .= aksrt_render_post($item_config); } } wp_reset_postdata(); @@ -98,13 +106,13 @@ final class PostRenderer { */ private static function pagination(array $instance, int $page, int $max_pages): string { $mode = (string) ($instance['paginate'] ?? ''); - if ($mode === '' || $mode === 'none') { + if ($mode === '') { return '
'; } if ($max_pages <= 1) { return ''; } - if ($mode === 'number' || $mode === 'numbered') { + if ($mode === 'number') { $links = ''; $mobile = wp_is_mobile(); $mid_size = $mobile ? 0 : 2; diff --git a/src/WidgetSystem/Renderers/ContentRenderer.php b/src/WidgetSystem/Renderers/ContentRenderer.php index f4a3049b..2d69294b 100644 --- a/src/WidgetSystem/Renderers/ContentRenderer.php +++ b/src/WidgetSystem/Renderers/ContentRenderer.php @@ -47,13 +47,11 @@ final class ContentRenderer { if ($segment !== '') { $attrs .= ' data-segment="' . esc_attr($segment) . '"'; } - $loader = '
' . self::placeholder($instance) . '
'; - if (($instance['load_mode'] ?? '') === 'ajax') { - // The runtime (lazysizes + lazyload-action) reveals this container - // and clicks the single .ias-btn trigger; frontend.js then loads - // the first page through the REST client. - $trigger = ''; - return '
' . $trigger . $loader . '
'; + $lazy = ($instance['load_mode'] ?? '') === 'ajax'; + $loader = '
' . self::placeholder($instance) . '
'; + if ($lazy) { + // lazySizes reveals the pager; the widget client owns its request. + return '
' . $loader . '
'; } $payload = PostRenderer::payload($instance, $slug, $page, $segment); // The loader must live inside the pager: the REST client in frontend.js diff --git a/src/WidgetSystem/RestController.php b/src/WidgetSystem/RestController.php index a0d5de07..ef297655 100644 --- a/src/WidgetSystem/RestController.php +++ b/src/WidgetSystem/RestController.php @@ -34,7 +34,7 @@ final class RestController extends \WP_REST_Controller { * @return \WP_REST_Response|\WP_Error */ public function get_items($request) { - $unknown = array_diff(array_keys($request->get_params()), ['widget', 'instance', 'page', 'segment']); + $unknown = array_diff(array_keys($request->get_params()), ['widget', 'instance', 'page', 'segment', 'rest_route']); if ($unknown) { return new \WP_Error('aksrt_widget_unknown_parameter', __('请求包含不支持的参数。', 'aksrt'), ['status' => 400, 'parameters' => array_values($unknown)]); } diff --git a/src/WidgetSystem/WidgetCatalog.php b/src/WidgetSystem/WidgetCatalog.php index dee93d2c..9d37b858 100644 --- a/src/WidgetSystem/WidgetCatalog.php +++ b/src/WidgetSystem/WidgetCatalog.php @@ -71,7 +71,7 @@ final class WidgetCatalog { 'size' => $size, 'common' => [ 'title' => !in_array($slug, ['post_pay', 'layout_gap', 'slider', 'slider_media_combo', 'buttons', 'posts_toc'], true), - 'affix' => ($size === 'sidebar' && $slug !== 'posts_toc') || in_array($slug, ['buttons', 'notice'], true), + 'affix' => ($size === 'sidebar' && $slug !== 'posts_toc') || in_array($slug, ['buttons', 'notice', 'posts'], true), 'animation' => !in_array($slug, ['buttons', 'term_cards', 'term_aggregation', 'media_cards', 'combo_info', 'slider_media_combo'], true), ], 'fields' => WidgetSchemas::for($slug), diff --git a/tests/widget-system/discovery-reference-contract.test.cjs b/tests/widget-system/discovery-reference-contract.test.cjs index 6f556e25..5eaeea86 100644 --- a/tests/widget-system/discovery-reference-contract.test.cjs +++ b/tests/widget-system/discovery-reference-contract.test.cjs @@ -32,8 +32,8 @@ test('term aggregation renders the real SVG use element, total count, and config const firstQuery = fixture.aggregation_queries[0]; assert.equal(firstQuery.posts_per_page, 2); assert.equal(firstQuery.no_found_rows, false); - assert.deepEqual(firstQuery.category__in, [7]); - assert.equal(fixture.aggregation_queries[1].category__in[0], 8); + assert.equal(firstQuery.cat, '7'); + assert.equal(fixture.aggregation_queries[1].cat, '8'); }); test('category filters keep dropdown and inline terms in separate rendered groups on the ajax-option pipeline', () => { diff --git a/tests/widget-system/discovery-reference-harness.php b/tests/widget-system/discovery-reference-harness.php index 87ab398e..0d5e2892 100644 --- a/tests/widget-system/discovery-reference-harness.php +++ b/tests/widget-system/discovery-reference-harness.php @@ -19,7 +19,12 @@ class WP_Query { public function __construct(public array $query_args = []) { $GLOBALS['discovery_queries'][] = $query_args; $cats = array_map('intval', (array) ($query_args['category__in'] ?? [])); - $rows = array_values(array_filter($GLOBALS['discovery_posts'], static fn(array $post): bool => !$cats || in_array($post['cat'], $cats, true))); + $excluded = []; + foreach (array_filter(explode(',', (string) ($query_args['cat'] ?? ''))) as $cat) { + if ((int) $cat > 0) $cats[] = (int) $cat; + if ((int) $cat < 0) $excluded[] = abs((int) $cat); + } + $rows = array_values(array_filter($GLOBALS['discovery_posts'], static fn(array $post): bool => (!$cats || in_array($post['cat'], $cats, true)) && !in_array($post['cat'], $excluded, true))); $this->found_posts = count($rows); $per_page = max(1, (int) ($query_args['posts_per_page'] ?? 10)); $this->max_num_pages = (int) ceil($this->found_posts / $per_page); diff --git a/tests/widget-system/posts-render-behavior.test.cjs b/tests/widget-system/posts-render-behavior.test.cjs new file mode 100644 index 00000000..e2f43438 --- /dev/null +++ b/tests/widget-system/posts-render-behavior.test.cjs @@ -0,0 +1,35 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); +const test = require('node:test'); + +const cases = JSON.parse(execFileSync(process.env.PHP_BINARY || 'php', [ + path.join(__dirname, 'tab-posts-render-harness.php'), +], { encoding: 'utf8' })).cases; + +for (const style of ['list', 'card', 'mini']) { + test(`article ${style} widget renders its saved layout directly`, () => { + const html = cases[`posts_${style}_detail`]; + assert.match(html, new RegExp(`class="widget-main-post mb20 style-${style}"`)); + assert.match(html, /data-widget="posts" data-instance="aksrt_widget_posts-3"/); + assert.match(html, /data-loaded="1" aria-live="polite" aria-busy="false"/); + assert.equal((html.match(/

{ + const html = cases[`posts_${style}_ajax`]; + assert.match(html, new RegExp(`class="widget-main-post mb20 style-${style}"`)); + assert.match(html, /class="widget-ajaxpager lazyload" data-widget-lazy/); + assert.match(html, /data-widget="posts" data-instance="aksrt_widget_posts-3"/); + assert.match(html, /aria-live="polite" aria-busy="true"/); + assert.match(html, /class="post_ajax_loader is-loading" role="status"/); + assert.match(html, new RegExp(`data-placeholder="${style}:4"`)); + assert.doesNotMatch(html, /class="item-heading|ias-btn|data-widget-ias|post_ajax_trigger|lazyload-action=/); + }); +} diff --git a/tests/widget-system/posts-wrapper-contract.test.cjs b/tests/widget-system/posts-wrapper-contract.test.cjs new file mode 100644 index 00000000..5a3f248c --- /dev/null +++ b/tests/widget-system/posts-wrapper-contract.test.cjs @@ -0,0 +1,37 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('article list uses the reference widget container and keeps widget behavior classes', () => { + const renderer = read('src/WidgetSystem/FrontendRenderer.php'); + const catalog = read('src/WidgetSystem/WidgetCatalog.php'); + assert.match(renderer, /\['user_card', 'author_card', 'tab_posts', 'posts'\]/); + assert.match(renderer, /\$classes = \['zib-widget-wrap', 'aksrt-' \. str_replace/); + assert.match(renderer, /echo '
'/); + assert.match(renderer, /WidgetVisibility::device_class\(\$instance\)/); + assert.match(renderer, /!empty\(\$instance\['sidebar_affix'\]\)/); + assert.match(renderer, /data-affix="true"/); + assert.match(catalog, /in_array\(\$slug, \['buttons', 'notice', 'posts'\], true\)/); +}); + +test('article list pagination shares the reference widget rules and obsolete trigger CSS is removed', () => { + const css = read('assets/widgets/frontend.css'); + assert.match(css, /\.widget-tab-post \.pagenav, \.widget-main-post \.pagenav/); + assert.match(css, /\.sidebar :is\(\.widget-tab-post, \.widget-main-post\) \.pagenav/); + assert.match(css, /:is\(\.widget-tab-post, \.widget-main-post\) \.pagenav a\.page-numbers/); + assert.doesNotMatch(css, /\.post_ajax_trigger/); + for (const token of [ + '.widget-tab-post .item-heading, .widget-main-post .item-heading', + '.widget-tab-post .item-heading.font-bold, .widget-main-post .item-heading.font-bold', + '.widget-tab-post .posts-mini, .widget-main-post .posts-mini', + '.widget-tab-post .posts-mini-con, .widget-main-post .posts-mini-con', + '.widget-tab-post .item-meta .meta-pay, .widget-main-post .item-meta .meta-pay', + '.aksrt-posts-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible', + ]) assert.ok(css.includes(token), `missing shared widget rule: ${token}`); +}); diff --git a/tests/widget-system/run.php b/tests/widget-system/run.php index 1005325b..b0177278 100644 --- a/tests/widget-system/run.php +++ b/tests/widget-system/run.php @@ -18,6 +18,12 @@ use AKSRT\WidgetSystem\Renderers\ContentRenderer; use AKSRT\WidgetSystem\Renderers\GeneralRenderer; use AKSRT\WidgetSystem\Renderers\SliderRenderer; +if (!function_exists('current_datetime')) { + function current_datetime(): DateTimeImmutable { + return new DateTimeImmutable('2026-06-15 12:30:00', new DateTimeZone('Asia/Shanghai')); + } +} + final class WidgetSystemTest { private int $assertions = 0; private array $failures = []; @@ -105,15 +111,24 @@ final class WidgetSystemTest { 'count' => 6, 'cat' => '1, -2,3 bad', 'orderby' => 'views', 'order' => 'DESC', 'limit_day' => 7, 'paginate' => '', ], 3); - $this->same([1, 3], $reference['category__in'], 'reference cat includes positive IDs'); - $this->same([2], $reference['category__not_in'], 'reference cat excludes negative IDs'); + $this->same('1,-2,3', $reference['cat'], 'reference cat preserves cleaned signed IDs for WP_Query descendant semantics'); + $this->false(isset($reference['category__in']) || isset($reference['category__not_in']), 'reference cat does not narrow matches to direct categories'); $this->false(isset($reference['tax_query']), 'topic filters are removed with the topics feature'); $this->same('aksrt_post_views', $reference['meta_key'], 'reference view ordering uses numeric view meta'); $this->false(isset($reference['meta_query']), 'modern reference queries do not apply commerce filters by default'); $this->same(1, $reference['paged'], 'no-pagination queries stay on first page'); + $this->same('2026-06-08 12:30:00', $reference['date_query'][0]['after'], 'limit_day subtracts days in the WordPress site timezone'); + $this->same('2026-06-15 12:30:00', $reference['date_query'][0]['before'], 'limit_day uses local WordPress time without a second offset conversion'); + $generic_categories = PostQuery::arguments(['category_ids' => '2,5', 'category_id' => 9], 1); + $this->same([9], $generic_categories['category__in'], 'generic category_id continues to override category_ids'); + $this->false(isset($generic_categories['cat']), 'generic categories do not switch to modern cat syntax'); $paywalled = PostQuery::arguments(['cat' => '1', 'zibpay_type' => ['1', '6'], 'paginate' => ''], 1, ['cat' => '1']); $this->contains('reading', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters map to pay meta'); $this->contains('video', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters cover every selected type'); + $top_level_paywall = PostQuery::arguments(['cat' => '1', 'zibpay_type' => ['2'], 'paginate' => 'ajax'], 1); + $this->contains('download', json_encode($top_level_paywall['meta_query'] ?? []), 'top-level reference commerce filters map to pay meta'); + $generic_paywall = PostQuery::arguments(['category_ids' => '1', 'pay_type' => 'gallery'], 1); + $this->contains('gallery', json_encode($generic_paywall['meta_query'] ?? []), 'generic widget pay_type behavior remains available'); $invalid = PostQuery::arguments(['cat' => '', 'orderby' => 'drop table'], 1, ['cat' => '']); $this->false(isset($invalid['meta_query']), 'modern reference fields never add commerce filters'); $this->same('date', $invalid['orderby'], 'unknown orderby falls back'); @@ -298,7 +313,7 @@ final class WidgetSystemTest { $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]); + update_option('widget_aksrt_widget_posts', [1 => ['title' => 'Saved only', 'count' => 2, 'style' => 'list', 'paginate' => 'ajax'], '_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(); @@ -306,6 +321,8 @@ final class WidgetSystemTest { $this->same(2, $data['page'], 'REST page reaches saved-query pipeline'); $this->contains('暂无内容', $data['html'], 'REST returns deterministic empty state'); $this->same(4, end($GLOBALS['aksrt_test_state']['query_args'])['posts_per_page'], 'REST reloads saved normalized configuration'); + $response = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1, 'rest_route' => '/aksrt/v1/widgets/posts/items'])); + $this->true($response instanceof WP_REST_Response, 'plain permalink transport parameter is accepted'); $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'); } diff --git a/tests/widget-system/tab-posts-render-behavior.test.cjs b/tests/widget-system/tab-posts-render-behavior.test.cjs index d6352174..9075daf1 100644 --- a/tests/widget-system/tab-posts-render-behavior.test.cjs +++ b/tests/widget-system/tab-posts-render-behavior.test.cjs @@ -33,15 +33,15 @@ test('un-normalized missing and invalid style use one shared mini resolution in } }); -test('mini options render thumbnail, rank, author and meta on both pages with continuous numbering', () => { +test('mini options render thumbnail, rank, author and meta; rank is limited to the first page', () => { const current = render().mini_all_options; assert.equal((current.first.match(/class="posts-mini ajax-item"/g) || []).length, 4); assert.match(current.first, /class="mr10">
1<\/span>/); assert.match(current.first, /class="meta-author flex ac"/); assert.match(current.first, /class="item-meta muted-2-color flex jsb ac/); - assert.match(current.second, /badg badg-sm mr3 c-green">5<\/span>/); - assert.match(current.second, /真实文章 36/); + assert.doesNotMatch(current.second, /class="badg badg-sm/); + assert.match(current.second, /真实文章 32/); assert.doesNotMatch(current.first + current.second, /class="posts-row|class="posts-item list|class="posts-item card/); }); @@ -152,12 +152,14 @@ test('multi-column frontend output uses the reference widget wrapper and carries assert.doesNotMatch(wrapper, /aksrt-widget__inner container|