The top-nav publish chip and open-membership chip carried an AKSRT-only class layer (navbar-cta / navbar-cta-text / navbar-newposts / navbar-vip-hover) plus a matching set of CSS overrides that replaced the legacy .but geometry: margin 0.4em -> 0, an extra min-height and flex gap, an FA5 pencil glyph that is not the reference pencil, and label text wrapped in a span. The VIP hover card also had its own positioning override instead of the reference static position. Drop that layer and emit the reference class combination verbatim so the already byte-identical legacy stylesheet owns the geometry again: - publish chip renders newadd-btns but nowave <skin> [radius] btn-newadd with the label echoed as saved (no injected icon, no span) - VIP chip renders pay-vip|signin-loader but jb-red radius payvip-icon with the vip_1 SVG at em12 mr3, inside span.hover-show.inline-block - user-card CTA uses the vip_1 SVG (em12 mr6) instead of fa-gem and drops vip-level, matching zib_get_header_payvip_icon() - both buttons now honour Settings::close_sign(), matching the reference zib_is_close_sign() gate and the floating publish button - header-userbox.css keeps one rule that restores the hover wrapper to a plain block container so .sub-vip-card takes its reference static position; every other override is gone - nav_newposts_text default drops the duplicated fas and uses the FA5 spelling of the reference pencil (fa-pencil-alt, not fa-pen) Dead code removed: aksrt_normalize_header_cta_label(), the unreachable multi-type dropdown branch of aksrt_render_new_add_btns() (only 'post' is supported and only ['post'] is ever passed), and the newadd-btns__toggle / newadd-btns__item / drop-newadd markup it emitted. ml10 is no longer set on the VIP anchor: .aksrt-navbar-actions already supplies the 10px gap the reference gets from ml10, and keeping both would double it to 20px. No migration for sites that already stored the old nav_newposts_text. Verified: node --test across tests/ (603 passing), php -l on the changed PHP files, and a stubbed render of every publish/VIP branch.
485 lines
30 KiB
JavaScript
485 lines
30 KiB
JavaScript
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" href="javascript:;">'/)
|
||
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 \.aksrt-navbar-actions > \.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, /aksrt-navbar-actions navbar-form navbar-right/)
|
||
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-actions\s*\{[^}]*display:\s*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.
|
||
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/)
|
||
assert.match(headerCss, /\.navbar-top \.aksrt-navbar-actions\s*\{[^}]*gap:\s*10px;/s)
|
||
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,
|
||
})
|
||
})
|