Files
wordpress-theme-aksrt/tests/widget-system/page-display-contract.test.cjs
T
kate522 5810693e01 fix(header): restore the payvip surface on the top-nav VIP chip
The 开通会员 chip rendered in the .jb-red orange gradient instead of the
legacy metallic .payvip-icon gradient. The markup always carried
payvip-icon, but core/buttons.css restates the legacy .but block and is
bundled into public.css, which loads after main.css. Same specificity,
later in the cascade, so its background / color / border / text-shadow
declarations outrank .payvip-icon and replace the payvip surface with
whatever .jb-* skin happens to be on the element.

The reference theme has no such compatibility layer, so there its
.payvip-icon declarations are the last word and the chip shows the
metallic gradient with #ece0e0 text, no border and no text shadow.

A previous fix restored the surface only for the user-card CTA, scoped to
.aksrt-header-userbox .pay-vip.but.payvip-icon, which left the top-nav
chip (and the guest CTA, and the user-card status bar) on the wrong
gradient. Re-assert the legacy .payvip-icon declarations unscoped in
header-userbox.css, which loads after core/buttons.css, and complete the
property set with border: none and text-shadow: none so the chip no
longer carries the 1px transparent border the .but block adds.

Verified with a cascade resolver that replays the real stylesheet load
order (bootstrap, main, public, header-userbox) against the reference
theme's own order: background, color, --this-color, border, text-shadow,
border-radius, padding, margin, font-size, display, overflow and position
now resolve identically for both the publish and the VIP chip.
2026-10-15 13:44:01 +08:00

493 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const root = path.resolve(__dirname, '../..')
const read = file => fs.readFileSync(path.join(root, file), 'utf8')
const schema = read('src/Admin/Schema/SchemaPageDisplay.php')
const paymentSchema = read('src/Admin/Schema/SchemaPayment.php')
const adminVipSchema = read('assets/admin/src/schema/sections/payment/vip.js')
const paymentRuntime = read('src/Settings/PaymentRuntime.php')
const routes = read('assets/admin/src/constants/sections/page-display.js')
const controller = read('src/Admin/PageDisplayController.php')
const controllerBase = read('src/Admin/ControllerBase.php')
const provider = read('src/Admin/SchemaProvider.php')
const settingsFacade = read('src/Settings/Settings.php')
const scriptsPart1c = read('src/Core/ScriptsPart1c.php')
const footerCss = read('assets/css/frontend/footer/shell.css')
const frontendBaseCss = read('assets/css/frontend/base.css')
const headerMenuButtons = read('inc/helpers/aksrt-header-menu-buttons.php')
const frontendRenderer = read('src/WidgetSystem/FrontendRenderer.php')
const userRenderer = read('src/WidgetSystem/Renderers/UserRenderer.php')
const widgetCss = read('assets/widgets/frontend.css')
const headerNavbar = read('inc/helpers/header/aksrt-header-navbar.php')
const headerMobile = read('inc/helpers/header/aksrt-header-mobile.php')
const headerRender = read('inc/helpers/header/aksrt-header-render.php')
const headerUserbox = read('inc/helpers/aksrt-header-userbox.php')
const headerSearch = read('inc/helpers/header/aksrt-header-menu-search.php')
const headerCss = read('assets/css/frontend/header-userbox.css')
const coreResetCss = read('assets/css/core/reset.css')
const vipCardRenderer = read('src/Content/VipServiceModal.php')
const vipModalCss = read('assets/css/frontend/vip-modal.css')
const payVipJs = read('assets/js/pay-vip.js')
const modalRuntimeJs = read('assets/js/core/modal-runtime.js')
const legacyMainCss = read('assets/css/main.css')
function activeRepositorySource() {
const roots = ['assets/admin/src', 'assets/js', 'inc', 'src', 'templates']
const rootFiles = ['404.php', 'archive.php', 'category.php', 'footer.php', 'functions.php', 'header.php', 'index.php', 'page.php', 'search.php', 'single.php', 'tag.php']
const files = [...rootFiles.filter(file => fs.existsSync(path.join(root, file)))]
const visit = relative => {
const absolute = path.join(root, relative)
for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
const child = path.join(relative, entry.name)
if (entry.isDirectory()) visit(child)
else if (/\.(?:php|js|vue|css)$/.test(entry.name) && !entry.name.endsWith('.disabled')) files.push(child)
}
}
roots.forEach(visit)
return files.map(file => read(file)).join('\n')
}
function functionBody(name) {
const start = schema.indexOf(`private static function ${name}`)
assert.notEqual(start, -1, `missing schema builder ${name}`)
const next = schema.indexOf('\n\tprivate static function ', start + 1)
return schema.slice(start, next === -1 ? schema.length : next)
}
function topLevelFieldIds(name) {
return [...functionBody(name).matchAll(/self::field\('([^']+)'/g)].map(match => match[1])
}
test('page display exposes exactly eight ordered routes', () => {
const expected = ['header-nav', 'header-components', 'footer', 'homepage', 'category', 'tag', 'user-home', 'other']
const schemaOrder = [...schema.matchAll(/^\t\t\t\t'([^']+)'\s+=> self::/gm)].map(match => match[1])
assert.deepEqual(schemaOrder, expected)
const routeOrder = [...routes.matchAll(/section:\s*'([^']+)'/g)].map(match => match[1])
assert.deepEqual(routeOrder, expected)
})
test('top-level field IDs match the legacy V9.0 eight-page contract', () => {
assert.deepEqual(topLevelFieldIds('header_nav'), [
'header_theme_custom', 'header_theme_bg_custom', 'header_theme_bg', 'header_theme_color_custom',
'header_layout', 'mobile_header_layout', 'mobile_navbar_align', 'nav_fixed', 'nav_scroll_down_hide',
'nav_newposts', 'nav_newposts_radius', 'nav_newposts_class', 'nav_newposts_text',
])
assert.deepEqual(topLevelFieldIds('header_components'), [
'header_slider_show', 'header_slider_show_type', 'header_slider_nav_color', 'header_slider',
'header_slider_option', 'header_slider_search_s', 'header_slider_search_option',
'header_slider_card_s', 'header_slider_card_option', 'header_slider_filter_blur',
])
assert.deepEqual(topLevelFieldIds('footer'), [
'footer_theme_custom', 'footer_theme_bg_custom', 'footer_theme_bg', 'footer_theme_color_custom',
'fcode_template', 'footer_t1_m_s', 'footer_t1_img', 'footer_t1_img_dark', 'footer_t1_t',
'fcode_t1_code', 'fcode_t2_code_1', 'fcode_t2_code_2', 'footer_contact_m_s',
'footer_contact_wechat_img', 'footer_contact_qq', 'footer_contact_weibo', 'footer_contact_email',
'footer_contact_more', 'desc', 'icon', 'link', 'img', 'footer_mini_img_m_s', 'footer_mini_img',
'footer_mini_img_2', 'fcode_customize_code',
])
assert.deepEqual(topLevelFieldIds('homepage'), [
'home_posts_list_s', 'home_list1_orderby_s', 'home_list1_orderby_option', 'index_list_title',
'home_exclude_posts', 'home_exclude_cats', 'home_lists', 'title', 'term_id', 'orderby',
])
assert.deepEqual(topLevelFieldIds('category'), [
'page_cover_cat_s', 'cat_post_count_s', 'cat_default_cover', 'cat_orderby_s', 'cat_orderby_option',
'ajax_list_cat_cat', 'ajax_list_option_cat_cat', 'ajax_list_cat_tag', 'ajax_list_option_cat_tag',
])
assert.deepEqual(topLevelFieldIds('tag'), [
'tag_post_count_s', 'page_cover_tag_s', 'tag_default_cover', 'tag_orderby_s', 'tag_orderby_option',
'ajax_list_tag_cat', 'ajax_list_option_tag_cat', 'ajax_list_tag_tag', 'ajax_list_option_tag_tag',
])
assert.deepEqual(topLevelFieldIds('user_home'), ['author_main_tab_opt', 'author_main_tab_swiper'])
assert.deepEqual(topLevelFieldIds('other'), ['page_header_style', 'page_header_cover_img'])
})
test('defaults, nested structures, dependencies, and excluded product families are fixed', () => {
assert.match(schema, /'author_main_tab_opt'.*?'post'.*?'favorite'.*?'comment'.*?'follow'/s)
assert.match(schema, /'home_posts_list_s', '首页文章', 'switcher', true/)
assert.match(schema, /'page_header_style', '页面标题', 'radio', ''/)
assert.match(schema, /'author' => '公开用户主页'/)
assert.match(schema, /'user_center' => '用户中心'/)
assert.match(schema, /'header_slider_search_s'.*?'dependsOn' => self::not_empty\('header_slider_show'\)/)
assert.match(schema, /'header_slider_card_s'.*?'dependsOn' => self::not_empty\('header_slider_show'\)/)
assert.match(schema, /'header_slider_filter_blur'.*?'dependsOn' => self::not_empty\('header_slider_show'\)/)
assert.match(schema, /'all' => \[self::enabled\('home_posts_list_s'\), self::enabled\('home_list1_orderby_s'\)\]/)
for (const dead of ['nav_newposts_btns', 'forum_home', 'forum_plate', 'forum_post', 'ajax_list_cat_topics', 'ajax_list_tag_topics']) {
assert.equal(schema.includes(dead), false, `legacy/excluded field remains: ${dead}`)
}
assert.equal(headerMenuButtons.includes('nav_newposts_btns'), false)
assert.match(headerMenuButtons, /aksrt_render_new_add_btns\(\['post'\], \$class, \$label\)/)
// FA5 spelling of the reference '<i class="fa fa-fw fa-pencil"></i>发布'
// default: fa-pencil does not exist in the bundled FA 5.15.4 free set.
assert.match(schema, /'nav_newposts_text', '按钮文案', 'textarea', '<i class="fa fas fa-fw fa-pencil-alt"><\/i>发布'/)
assert.doesNotMatch(schema, /fa fas fa-fw fas /)
})
test('VIP navigation purchase switches are saved and consumed end to end', () => {
for (const key of ['nav_pay_vip', 'nav_user_pay_vip']) {
assert.match(paymentSchema, new RegExp(`'key' => '${key}'[^\\n]+?'type' => 'switch'[^\\n]+?'default' => true`))
assert.match(adminVipSchema, new RegExp(`${key}: true`))
assert.match(adminVipSchema, new RegExp(`key: '${key}'[^\\n]+type: 'switch'[^\\n]+default: true`))
assert.match(paymentRuntime, new RegExp(`'${key}'[^\\n]+!isset\\(\\$settings\\['${key}'\\]\\)[^\\n]+!empty\\(\\$settings\\['${key}'\\]\\)`))
}
assert.match(headerMenuButtons, /!empty\(\$vs\['enabled'\]\) && !empty\(\$vs\['nav_pay_vip'\]\)/)
assert.match(headerUserbox, /empty\(\$vip\['nav_user_pay_vip'\]\)[\s\S]*?return '';/)
assert.match(headerNavbar, /!empty\(\$vs\['enabled'\]\) && !empty\(\$vs\['nav_user_pay_vip'\]\)/)
})
test('header VIP hover cards match the legacy markup and style contract', () => {
assert.match(headerMenuButtons, /<span class="hover-show inline-block">/)
assert.match(headerMenuButtons, /<div class="sub-menu hover-show-con sub-vip-card">/)
assert.match(headerMenuButtons, /'<a class="' \. esc_attr\(\$cls\) \. ' but jb-red radius payvip-icon ml10" href="javascript:;">'/)
// The payvip surface must survive the .but compatibility layer for the top-nav
// chip too, not just the user-card CTA.
assert.match(headerCss, /\.payvip-icon\s*\{[\s\S]*?background:\s*linear-gradient\(300deg,[\s\S]*?--this-color:\s*#ece0e0;/s)
assert.match(headerMenuButtons, /<svg class="em12 mr3" aria-hidden="true"><use xlink:href="#icon-vip_1"><\/use><\/svg>'/)
assert.match(headerMenuButtons, /'<a rel="nofollow" class="newadd-btns ' \. esc_attr\(\$class\) \. ' btn-newadd"/)
assert.equal((headerMenuButtons.match(/Settings::close_sign\(\)/g) || []).length, 2)
for (const dead of ['navbar-cta', 'navbar-cta-text', 'navbar-newposts', 'navbar-vip-hover', 'newadd-btns__', 'vip-level', 'fa-gem']) {
assert.equal(headerMenuButtons.includes(dead), false, `AKSRT-only header CTA token remains: ${dead}`)
}
assert.match(vipCardRenderer, /aksrt_get_lazy_attr\('lazy_other', \$icon_url, 'img-icon'\)/)
assert.match(vipCardRenderer, /<div class=\"abs-center vip-baicon\"[^>]*>/)
assert.match(vipCardRenderer, /<div class=\"vip-img abs-right\">/)
assert.match(vipCardRenderer, /<div class=\"vip-name mb10\"><span class=\"mr6\">/)
assert.match(vipCardRenderer, /<ul class=\"mb10 relative\">/)
assert.doesNotMatch(vipCardRenderer, /vip-name__(?:icon|label)/)
assert.doesNotMatch(vipCardRenderer, /aksrt_format_vip_equity_html\(\$equity, 'mb10 relative'\)/)
assert.doesNotMatch(vipModalCss, /Header hover VIP cards/)
assert.doesNotMatch(vipModalCss, /\.navbar-vip-hover\.hover-show/)
assert.doesNotMatch(vipModalCss, /\.sub-vip-card/)
assert.doesNotMatch(vipModalCss, /\.vip-card\.vip-theme[12]/)
assert.match(legacyMainCss, /\.sub-vip-card\s*\{[^}]*font-size:\s*12px;[^}]*padding:\s*0\s*!important;/s)
assert.match(legacyMainCss, /\.sub-vip-card \.vip-card\s*\{[^}]*display:\s*inline-block;[^}]*width:\s*240px;[^}]*margin:\s*5px;/s)
assert.match(legacyMainCss, /\.sub-vip-card \.vip-baicon\.abs-center\s*\{[^}]*font-size:\s*10em;[^}]*left:\s*55%;/s)
assert.match(legacyMainCss, /\.sub-vip-card \.vip-img\s*\{[^}]*display:\s*none;/s)
// The hover wrapper must stay a plain block container so the legacy static
// position of .sub-vip-card matches the reference theme.
assert.match(headerCss, /\.navbar-top \.navbar-but > \.hover-show\s*\{[^}]*display:\s*inline-block;/s)
assert.doesNotMatch(headerCss, /\.navbar-cta|\.navbar-vip-hover/)
assert.match(legacyMainCss, /\.navbar-top \.navbar-right \.sub-menu\s*\{[^}]*right:\s*0;/s)
})
test('user card VIP CTA matches the reference zib_get_header_payvip_icon()', () => {
assert.match(headerUserbox, /'<a class="pay-vip but radius4 payvip-icon btn-block" href="javascript:;">'/)
assert.match(headerUserbox, /<svg class="em12 mr6" aria-hidden="true"><use xlink:href="#icon-vip_1"><\/use><\/svg>'/)
assert.match(headerUserbox, /esc_html__\('开通会员 尊享会员权益', 'aksrt'\)/)
for (const dead of ['fa-gem', 'vip-level']) {
assert.equal(headerUserbox.includes(dead), false, `AKSRT-only user card CTA token remains: ${dead}`)
}
assert.doesNotMatch(headerUserbox, /<span>' \. esc_html__\('开通会员 尊享会员权益'/)
})
test('VIP purchase modal matches legacy desktop-center and mobile-bottom positioning', () => {
const purchaseModal = payVipJs.slice(payVipJs.indexOf('function vipPay('), payVipJs.indexOf('function onDocClick('))
assert.doesNotMatch(purchaseModal, /mobileBottom:\s*false/)
assert.match(modalRuntimeJs, /options\.mobileBottom !== false && isMobile\(\)/)
// 壳几何 1:1 对齐 Zibll modal_pay_uservip:桌面 600px(bootstrap3 ≥768px
// .modal-dialog)+ max-width calc(100% - 20px)(.modal.flex .modal-dialog);
// 高度/滚动 = .modal.flex .modal-content 的 max-height calc(100vh - 20px)
// + overflow-y auto(阈值放宽到整屏,正常内容不出现滚动条);
// 圆角 = .modal-content 的 var(--main-radius)(8px)。
assert.match(vipModalCss, /\.aksrt-vip-modal-shell \.aksrt-modal-runtime__dialog\s*\{[\s\S]*?width:\s*min\(600px, calc\(100vw - 20px\)\);[\s\S]*?max-height:\s*calc\(100vh - 20px\);[\s\S]*?overflow:\s*hidden;[\s\S]*?overflow-y:\s*auto;[\s\S]*?border-radius:\s*var\(--main-radius, 8px\);/)
// 内边距由 .box-body 15px 提供(参考主题无 .payvip-modal 专属 padding)。
assert.doesNotMatch(vipModalCss, /\.payvip-modal\s*\{[^}]*padding:/)
assert.match(vipModalCss, /@media \(max-width: 768px\)[\s\S]*?\.aksrt-vip-modal-shell\.aksrt-modal-runtime--bottom \.aksrt-modal-runtime__dialog\s*\{[\s\S]*?width:\s*100%;[\s\S]*?max-height:\s*calc\(100vh - 20px\);[\s\S]*?border-radius:\s*var\(--main-radius, 8px\) var\(--main-radius, 8px\) 0 0;/)
})
test('schema REST saves are privileged, section-whitelisted, and deeply sanitized', () => {
assert.match(controllerBase, /current_user_can\('manage_options'\)/)
assert.match(controllerBase, /sanitize_payload\(\$domain, \$payload, \$section\)/)
assert.match(provider, /sanitize_object_fields/)
assert.match(provider, /validate_nested_field/)
assert.match(provider, /wp_kses_post/)
assert.match(provider, /esc_url_raw/)
assert.match(provider, /has an invalid value/)
assert.match(controller, /search\/\(\?P<source>posts\|categories\|tags\)/)
assert.match(controller, /'permission_callback' => \[\$this, 'check_permission'\]/)
})
test('legacy routes, components, and storage paths are gone', () => {
const active = [
read('src/Admin/PageDisplayController.php'),
read('src/Admin/GlobalController.php'),
read('assets/admin/src/api/modules/page-display.js'),
read('assets/admin/src/constants/sections/page-display.js'),
read('assets/admin/src/router/component-loaders/core.js'),
].join('\n')
for (const dead of ['/banners', '/settings/homepage', "'/settings/footer'", 'BannerManager', 'HomepageSettings', 'FooterSettings']) {
assert.equal(active.includes(dead), false, `legacy route/component remains: ${dead}`)
}
assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/pages/content/BannerManager.vue')), false)
assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/pages/content/HomepageSettings.vue')), false)
assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/pages/content/FooterSettings.vue')), false)
assert.equal(fs.existsSync(path.join(root, 'src/Admin/HomepageSettingsRest.php')), false)
const repository = activeRepositorySource()
for (const dead of [
'aksrt_footer_settings', 'aksrt_banners', 'aksrt_banner', 'aksrt-banner', 'HomeModules', 'homepage_modules',
'/settings/homepage', 'home-modules', 'cms-home-modules', 'aksrt_preview', 'theme-background',
"templates/parts/home-modules",
]) {
assert.equal(repository.includes(dead), false, `active legacy implementation remains: ${dead}`)
}
assert.equal(/['"]\/settings\/footer['"]/.test(repository), false, 'standalone footer settings route remains')
for (const deleted of [
'src/Core/HomeModules.php',
'assets/js/cms-home-modules.js',
'assets/css/frontend/home-modules.css',
'assets/css/frontend/theme-background.css',
'assets/admin/src/utils/home-module-schema.js',
]) {
assert.equal(fs.existsSync(path.join(root, deleted)), false, `deleted implementation exists: ${deleted}`)
}
})
test('front-end page-display consumers read only the canonical section contracts', () => {
const category = read('category.php')
const tag = read('tag.php')
const termMeta = read('src/Core/TermMeta.php')
const footer = read('footer.php')
assert.equal(category.includes('get_post_filter_settings'), false)
assert.equal(category.includes('get_filter_config_for_category'), false)
assert.equal(tag.includes('get_post_filter_settings'), false)
assert.match(category, /ajax_category_enabled/)
assert.match(category, /orderby_enabled/)
assert.match(tag, /ajax_tag_enabled/)
assert.match(tag, /orderby_enabled/)
assert.match(termMeta, /get_category_page_settings/)
assert.match(termMeta, /get_tag_page_settings/)
assert.match(footer, /get_footer_template/)
assert.match(footer, /<ul class="flex ac gap20 footer-contact-box list-inline">/)
assert.match(footerCss, /\.footer \.list-inline\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;[^}]*gap:\s*20px;/s)
assert.doesNotMatch(footerCss, /\.footer \.footer-miniimg\s*\{[^}]*display:\s*inline-block;/s)
assert.match(footerCss, /\.footer \.footer-miniimg img\s*\{[^}]*width:\s*90px;[^}]*margin:\s*0 6px;/s)
assert.match(settingsFacade, /public static function get_footer_template\(\): string\s*\{\s*return HomeCms::get_footer_template\(\);\s*\}/)
assert.equal(scriptsPart1c.includes('cartUrl'), false)
assert.doesNotMatch(scriptsPart1c, /\$runtime_routes\[['"]cart['"]\]/)
})
test('footer brand icons keep the Brands font when legacy fa prefix is present', () => {
const footer = read('footer.php')
const icons = read('inc/helpers/aksrt-icons.php')
assert.match(footer, /class="fa fab fa-weixin"/)
assert.match(footer, /class="fa fab fa-qq"/)
assert.match(footer, /class="fa fab fa-weibo"/)
assert.match(frontendBaseCss, /\.fa\.fab\s*\{[^}]*font-family:\s*"Font Awesome 5 Brands"[^}]*font-weight:\s*400/s)
assert.match(icons, /'wechat'\s*=>\s*'fa fab fa-weixin'/)
})
test('header multifunction search keeps focus and submit alignment route-independent', () => {
assert.match(
frontendBaseCss,
/\.header-slider-search \.line-form-input:focus,[\s\S]*?\.header-slider-search \.line-form-input:focus-visible\s*\{[^}]*border:\s*0;[^}]*outline:\s*0;[^}]*box-shadow:\s*none;/,
)
assert.match(
frontendBaseCss,
/\.header-slider-search \.line-form \.abs-right\s*\{[^}]*position:\s*absolute;[^}]*right:\s*0\.7em;[^}]*top:\s*50%;[^}]*transform:\s*translateY\(-50%\);[^}]*margin-top:\s*0;/,
)
})
test('user profile and author widgets render the reference user-card contract', () => {
assert.match(frontendRenderer, /in_array\(\$definition->slug\(\), \['user_card', 'author_card'\], true\)/)
assert.match(frontendRenderer, /self::render_user_widget\(\$definition, \$instance, \$widget, \$content\)/)
assert.match(frontendRenderer, /\$classes = \['zib-widget-wrap', 'aksrt-user-widget-wrap'/)
assert.match(frontendRenderer, /echo '<div class="widget-container"><div>';/)
assert.match(frontendRenderer, /\$content_classes = \['widget-content'\]/)
// 登录卡片与 reference zib_get_user_card_box 同构:封面、头像框、名字行、统计徽章、简介、按钮。
assert.match(userRenderer, /<div class="mb20">/)
assert.match(userRenderer, /user-card zib-widget widget/)
assert.match(userRenderer, /user-cover graphic" style="padding-bottom: 50%;"/)
assert.match(userRenderer, /avatar-img avatar-lg/)
assert.match(userRenderer, /user-name flex jc/)
assert.match(userRenderer, /author-tag mt10 mini-scrollbar/)
assert.match(userRenderer, /user-desc mt10 muted-2-color em09/)
assert.match(userRenderer, /user-btns mt20/)
assert.match(userRenderer, /aksrt_get_user_badges\(\$user_id\)/)
assert.match(userRenderer, /'follow' => true,/)
// 用户个人信息小工具不显示文章模块,显示签到徽章。
assert.match(userRenderer, /'show_posts' => false,/)
assert.match(userRenderer, /'show_checkin' => true,/)
assert.match(userRenderer, /aksrt_header_user_checkin_btn\(\$user_id, 'img-badge jb-yellow'\)/)
// 文章作者信息小工具显示文章模块,并走同一 widget 变体。
assert.match(userRenderer, /'show_posts' => !isset\(\$i\['show_posts'\]\) \|\| !empty\(\$i\['show_posts'\]\),/)
assert.match(userRenderer, /'post_type' => 'post',/)
assert.match(userRenderer, /self::card_box\(\(int\) \$user->ID,[\s\S]*?\) \. '<\/div>';/)
assert.match(userRenderer, /aksrt_render_user_card_posts\(/)
assert.match(userRenderer, /'post_type' => \(string\) \$args\['post_type'\],/)
// 未登录卡片使用主题认证真值和 reference 登录/社交 DOM。
assert.match(userRenderer, /Settings::close_sign\(\)/)
assert.match(userRenderer, /Settings::registration_enabled\(\)/)
assert.match(userRenderer, /fa-sign-in-alt/)
assert.doesNotMatch(userRenderer, /fa-sign-in"/)
assert.match(userRenderer, /signin-loader but jb-blue padding-lg/)
assert.match(userRenderer, /signup-loader ml10 but jb-yellow padding-lg/)
assert.match(userRenderer, /social-separator separator muted-3-color em09 mt20 mb20/)
assert.match(userRenderer, /social_loginbar/)
assert.match(userRenderer, /social-login-item /)
assert.match(userRenderer, /loged_title/)
assert.doesNotMatch(userRenderer, /user-card__auth-btns|render_social_buttons\(/)
// 后加载的 widget bundle 明确保留单层卡片并让封面贴边。
assert.match(widgetCss, /\.user-card\.zib-widget\s*\{[^}]*padding:\s*0;[^}]*overflow:\s*hidden;/s)
assert.match(legacyMainCss, /\.user-card\.widget \.user-cover \+ \.card-content\s*\{[^}]*margin-top:\s*-55px;/s)
// 参考主题契约:.widget-content 是无样式结构层,小工具卡片自带内边距;
// 任何样式层都不得给 .widget-content 加 padding,否则小工具与内容列宽之间出现左右空隙。
assert.doesNotMatch(widgetCss, /\.widget-content[^{,]*\{[^}]*padding/s)
assert.doesNotMatch(read('assets/css/frontend/sidebar.css'), /\.widget-content[^{,]*\{[^}]*padding/s)
assert.doesNotMatch(widgetCss, /user-card__auth-btns/)
assert.doesNotMatch(widgetCss, /\.aksrt-user-card\s*\{/)
assert.doesNotMatch(userRenderer, /aksrt-widget-button is-ghost/)
})
test('desktop header actions share one row without generic button masks', () => {
assert.match(headerNavbar, /navbar-form navbar-right navbar-but/)
assert.match(headerNavbar, /navbar-form navbar-right flex ac/)
assert.match(headerNavbar, /\$fold \. \$button \. \$right_but/)
assert.match(headerNavbar, /class="aksrt-nav-icon nav-search-btn main-search-btn"/)
assert.doesNotMatch(headerNavbar, /class="btn nav-search-btn main-search-btn"/)
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-account\s*\{[^}]*display:\s*inline-flex;[^}]*align-items:\s*center;/s)
assert.match(headerCss, /\.navbar-top \.aksrt-nav-icon,[\s\S]*?background:\s*transparent !important;/)
// The publish and VIP chips ride the legacy .but geometry verbatim; nothing in
// the header module may restate their padding, margin, radius or icon size.
// Descendant, not child: the VIP chip lives inside the hover-show wrapper.
assert.match(legacyMainCss, /\.navbar-top \.navbar-but \.but\s*\{[^}]*font-size:\s*0\.8em;[^}]*padding:\s*0\.3em 1em !important;[^}]*margin:\s*0\.4em;/s)
assert.doesNotMatch(headerCss, /\.navbar-cta|\.navbar-vip-hover/)
// No merged flex container: the three groups keep their own .navbar-form
// padding, which is what produces the reference inter-cluster spacing.
assert.doesNotMatch(headerCss, /\.aksrt-navbar-actions/)
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-account > li\s*\{[^}]*position:\s*relative;/s)
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-account > li > \.sub-menu\s*\{[^}]*top:\s*calc\(100% \+ 5px\);[^}]*right:\s*0;/s)
})
test('mobile search stays inside the scroll-hiding top header', () => {
assert.match(headerNavbar, /class="navbar-toggle main-search-btn"/)
assert.doesNotMatch(headerNavbar, /mobile_layout/)
assert.doesNotMatch(headerMobile, /main-search-btn|\$m_layout|aksrt_navbar_header/)
assert.match(headerMobile, /aksrt_nav_mobile_content\(\$ctx\)/)
assert.doesNotMatch(headerRender, /'fixed'\s*=>/)
assert.doesNotMatch(legacyMainCss, /\.mobile-navbar \.navbar-(?:header|brand)/)
assert.doesNotMatch(coreResetCss, /img,\s*picture,\s*video,\s*canvas,\s*svg\s*\{/)
assert.match(legacyMainCss, /svg\s*\{[^}]*width:\s*1em;[^}]*height:\s*1em;/s)
const topHeaderSearch = headerRender.indexOf('aksrt_navbar_header($ctx);')
const topHeaderClose = headerRender.indexOf("echo '</div></div></nav></header>';")
const mobileDrawer = headerRender.indexOf('aksrt_nav_mobile_shell($ctx);')
assert.ok(topHeaderSearch > -1 && topHeaderSearch < topHeaderClose)
assert.ok(topHeaderClose < mobileDrawer)
assert.match(legacyMainCss, /\.scroll-down\.nav-fixed \.header\.scroll-down-hide\s*\{[^}]*transform:\s*translate3d\(0, -105%, 0\);/s)
})
test('search dropdown clears the fixed header and closes from the panel lower-right', () => {
assert.match(headerCss, /\.navbar-search\s*\{[^}]*z-index:\s*1001;[^}]*transform:\s*translate3d\(0, -24px, 0\);[^}]*transition:\s*transform \.24s ease, opacity \.18s ease, visibility 0s linear \.24s;/s)
assert.match(headerCss, /\.navbar-search\.show\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*translate3d\(0, 0, 0\);/s)
assert.doesNotMatch(headerCss, /\.navbar-search\s*\{[^}]*height:\s*100%/s)
assert.doesNotMatch(headerCss, /\.navbar-search > \.container\s*\{[^}]*overflow-y:\s*auto/s)
assert.match(headerCss, /\.navbar-search \.line-form-input:focus,[\s\S]*?\.navbar-search \.line-form-input:focus-visible\s*\{[^}]*outline:\s*0;[^}]*box-shadow:\s*none;/s)
assert.match(headerCss, /\.navbar-search \.search-close-box\s*\{[^}]*display:\s*flex;[^}]*justify-content:\s*flex-end;[^}]*margin:\s*16px auto 0;/s)
assert.doesNotMatch(headerCss, /\.navbar-search \.search-close-box\s*\{[^}]*position:\s*fixed/s)
assert.ok(headerSearch.indexOf('echo $remote') < headerSearch.indexOf('search-close-box'))
})
test('header user-card publish controls are removed from configuration and runtime', () => {
assert.doesNotMatch(schema, /nav_user_newposts_btn/)
assert.match(headerNavbar, /nav_user_newposts_btn/)
assert.match(headerNavbar, /nav_user_newposts_btn_text/)
})
test('header user-card membership stays on one row with a date-only expiry', () => {
assert.match(headerUserbox, /wp_date\('Y年m月d日', \$expires\)/)
assert.doesNotMatch(headerUserbox, /wp_date\('Y-m-d H:i', \$expires\)/)
// Reference VIP bar: legacy payvip-icon geometry, the untouched level-icon
// contract, and the yellow expiry pill.
assert.match(headerUserbox, /radius4 payvip-icon btn-block text-center/)
assert.match(headerUserbox, /aksrt_get_vip_icon\(\$user_id, 'em12 mr6', false\)/)
assert.match(headerUserbox, /class="ml10 badg jb-yellow vip-expdate-tag"/)
assert.match(legacyMainCss, /\.sub-user-box \.payvip-icon\s*\{[^}]*padding:\s*8px;/s)
assert.match(legacyMainCss, /\.vip-expdate-tag\s*\{[^}]*font-size:\s*0\.8em;[^}]*padding:\s*0\.1em 6px;[^}]*vertical-align:\s*0\.1em;/s)
assert.doesNotMatch(vipModalCss, /\.vip-expdate-tag\s*\{/)
assert.doesNotMatch(headerCss, /aksrt-header-payvip/)
// The CTA wrapper is the reference padding wrapper, not a bespoke hook.
assert.match(headerUserbox, /'<div class="mt10 em09" style="padding:2px;">'/)
assert.doesNotMatch(headerUserbox, /aksrt-header-payvip/)
// Expiry badge follows zib_get_user_vip_exp_date_text(): an expiry that has
// passed reads 会员已过期, a live one reads the bare date, 永久会员 is reserved
// for an explicit permanent membership, and no expiry data renders no badge.
assert.match(headerUserbox, /\$expires <= time\(\)/)
assert.match(headerUserbox, /empty\(\$state\['is_permanent'\]\)/)
assert.doesNotMatch(headerUserbox, /aksrt_get_vip_info/)
})
test('save contract rejects invalid input and round-trips canonical nested values', () => {
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'page-display-save-harness.php')], {
cwd: root,
encoding: 'utf8',
})
assert.equal(result.status, 0, result.stderr || result.stdout)
const data = JSON.parse(result.stdout)
assert.equal(data.permission_denied, true)
assert.equal(data.permission_allowed, true)
assert.equal(data.unknown_section, true)
assert.equal(data.invalid_enum_rejected, true)
assert.equal(data.nested_multiselect_valid, true)
assert.equal(data.footer_valid, true)
assert.equal(data.vip_switches_valid, true)
assert.deepEqual(data.vip_switches_clean, {
nav_pay_vip: false,
nav_user_pay_vip: true,
})
assert.deepEqual(data.vip_switches_readback, {
nav_pay_vip: false,
nav_user_pay_vip: true,
})
assert.equal(data.homepage_defaults.home_list1_orderby_option, '')
assert.equal(data.homepage_defaults.home_exclude_posts, '')
assert.equal(data.homepage_defaults.home_exclude_cats, '')
assert.equal(Object.hasOwn(data.footer_defaults.footer_mini_img[0], 'link'), false)
const slide = data.nested.header_slider[0]
assert.equal(slide.background, '')
assert.equal(Object.hasOwn(slide, 'unknown'), false)
assert.deepEqual(slide.link, { url: 'https://example.test/slide', target: '_blank', text: 'Go' })
assert.equal(slide.text.title, '<strong>Safe</strong>')
assert.equal(Object.hasOwn(slide.text, 'unknown'), false)
assert.equal(data.footer_clean.fcode_t1_code, '<strong>Allowed</strong>')
assert.equal(data.footer_clean.footer_contact_weibo, '')
assert.equal(Object.hasOwn(data.footer_clean, 'unknown_top_level'), false)
assert.deepEqual(data.footer_clean.footer_mini_img[0], {
text: 'QR',
image: 'https://example.test/qr.png',
link: { url: 'https://example.test/go', target: '_blank', text: 'Open' },
})
assert.equal(data.footer_readback.fcode_t1_code, '<strong>Allowed</strong>')
assert.deepEqual(data.footer_readback.footer_mini_img, data.footer_clean.footer_mini_img)
assert.deepEqual(data.visibility, {
disabled: false,
disabled_with_page_override: false,
page_enabled: true,
public_user_home: true,
user_center: true,
legacy_author_public_home: true,
generic_page_excludes_home: false,
generic_page_excludes_center: false,
templated_public_user_home: true,
templated_user_center: true,
home_excludes_page: false,
message_uses_other_page: true,
message_excludes_home: false,
home_excludes_public_home: false,
home_excludes_user_center: false,
})
})