Files
kate522 5201f2ab6f fix(ban): hide scrollbars entirely in ban modals
申诉弹窗等内容区的细滚动条(mini-scrollbar 悬停显现的 3px 滚动条)也不予保留:
三个禁封弹窗(禁封/解封 max-vh7、申诉 max-vh5、禁封记录 max-vh5)的滚动容器
统一改用 no-scrollbar(scrollbar-width:none + ::-webkit-scrollbar{display:none}),
滚动能力保留但任何状态下都不显示滚动条。契约测试锁定:ban 相关helper 不得
再出现 mini-scrollbar。
2026-09-22 19:53:03 +08:00

549 lines
38 KiB
JavaScript
Raw Permalink 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 root = path.resolve(__dirname, '..', '..')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
const userController = read('src/Admin/UserController.php')
const reportOps = read('src/Admin/UserControllerReportOperations.php')
const restrictionOps = read('src/Admin/UserControllerRestrictionOperations.php')
const restriction = read('src/User/Restriction.php')
const reportCenter = read('src/Messages/ReportCenter.php')
const usersMenus = read('src/Admin/Native/UsersMenus.php')
const mailNotifications = read('src/Settings/MailNotifications.php')
const home = read('src/User/Home.php')
const commentItem = read('inc/helpers/comments/item.php')
const functions = read('functions.php')
const uninstall = read('uninstall.php')
const userBanHelper = read('inc/helpers/user-ban.php')
const userBanJs = read('assets/js/user-ban.js')
const authorHero = read('templates/parts/user/author-hero.php')
const banBlock = read('templates/parts/user/ban-block.php')
const ucHeader = read('inc/helpers/user-center-renderers/profile-header.php')
const commentsHelper = read('inc/helpers/aksrt-comments.php')
const identityHelper = read('inc/helpers/aksrt-identity.php')
const avatarHelper = read('inc/helpers/aksrt-avatar.php')
const singleComponents = read('inc/helpers/aksrt-single-components.php')
const banAppeal = read('src/User/BanAppeal.php')
const mainCss = read('assets/css/main.css')
const mainMinCss = read('assets/css/main.min.css')
const userRuntime = read('src/Settings/UserRuntime.php')
const phoneOps = read('src/Admin/UserControllerAuthPhoneOperations.php')
const authService = read('src/Auth/Service.php')
const oauthAccountService = read('src/Auth/OAuthAccountService.php')
const weixinGzh = read('src/Auth/WeixinGzh.php')
const oauthFlow = read('src/Auth/OAuth.php')
const oauthProxy = read('src/Auth/OAuthProxyClient.php')
const authJs = read('assets/js/auth.js')
const userCenterJs = read('assets/js/routes/user-center.js')
const modalRuntimeCss = read('assets/css/frontend/modal-runtime.css')
const templatesMap = read('src/Core/Templates.php')
const scriptsPart1b = read('src/Core/ScriptsPart1b.php')
test('report REST surface is canonical: submit + users/reports only', () => {
assert.match(userController, /'\/report\/submit'/)
assert.match(userController, /'\/users\/reports'/)
assert.match(userController, /'\/users\/reports\/\(\?P<id>\\d\+\)'/)
assert.match(userController, /'\/users\/reports\/\(\?P<id>\\d\+\)\/process'/)
for (const dead of ["'/report/list'", "'/report/update-status'", "'/report/counts'"]) {
assert.ok(!userController.includes(dead), `alias route should be removed: ${dead}`)
}
assert.ok(!reportOps.includes('public function update_report_status'), 'dead update_report_status method')
assert.ok(!reportOps.includes('public function get_report_counts'), 'dead get_report_counts method')
})
test('report center has no legacy ajax handlers or option migration', () => {
assert.ok(!reportCenter.includes('ajax_update_status'))
assert.ok(!reportCenter.includes('ajax_list'))
assert.ok(!reportCenter.includes('ajax_counts'))
assert.ok(!reportCenter.includes('migrate_from_option'))
assert.ok(!reportCenter.includes('maybe_migrate'))
assert.ok(!reportCenter.includes("get_option('aksrt_reports'"))
assert.ok(!functions.includes('ReportCenter::get_instance()->init()'), 'ReportCenter init() is gone')
assert.match(reportCenter, /public function has_pending_report/)
assert.match(reportOps, /has_pending_report\(\$reporter_id, \$target_type, \$target_id\)/)
})
test('report processing delegates actions to the shared ReportCenter entry', () => {
assert.match(reportOps, /ReportCenter::get_instance\(\)->apply_action\(\$target_type, \$target_id, \$action_taken, \$reason\)/)
assert.match(reportCenter, /public function apply_action\(/)
assert.match(reportCenter, /->restrict\(\s*\$target_id,\s*\$action_taken,/)
assert.match(reportCenter, /lift_user_restriction\(\$target_id\)/)
assert.match(reportCenter, /in_array\(\$action_taken, \\AKSRT\\User\\Restriction::TYPES, true\)/)
assert.ok(!reportCenter.includes('aksrt_user_ban_state'), 'dual-track ban state meta must not be written or read')
for (const deadAction of ["'value' => 'warned'", "'value' => 'muted'", "'value' => 'restricted'", "'value' => 'banned'"]) {
assert.ok(!reportOps.includes(deadAction), `action value must map to Restriction types: ${deadAction}`)
}
for (const action of ["'value' => 'warn'", "'value' => 'mute'", "'value' => 'suspend'", "'value' => 'ban'", "'value' => 'clear'"]) {
assert.ok(reportOps.includes(action), `missing action option: ${action}`)
}
assert.match(reportOps, /get_user_restriction_state/)
assert.ok(!usersMenus.includes('$svc->apply_action('), 'native page follows the reference theme flow (status-only update)')
})
test('restriction class supports lifting and kicks sessions on ban', () => {
assert.match(restriction, /public function lift_user_restriction\(int \$user_id/)
assert.match(restriction, /public function lift_user_restriction\(int \$user_id, array \$types = \[self::TYPE_BAN, self::TYPE_SUSPEND, self::TYPE_MUTE\]\): int/)
assert.match(restriction, /WP_Session_Tokens::get_instance\(\$user_id\)->destroy_all\(\)/)
assert.ok(!restriction.includes('sync_user_restriction_meta'), 'write-only meta sync is dead code')
assert.ok(!restriction.includes('_aksrt_is_banned'))
})
test('ban admin REST endpoints stay in sync with the SPA client', () => {
assert.match(userController, /'\/users\/\(\?P<user_id>\\d\+\)\/restrictions'/)
assert.match(userController, /'\/users\/\(\?P<user_id>\\d\+\)\/restrict'/)
// The self-built SPA ban-manager page is removed: ban enforcement lives on
// the native reference-theme page, and the SPA hosts the settings schema.
assert.ok(!fs.existsSync(path.join(root, 'assets/admin/src/pages/users/UsersBan.vue')), 'self-built SPA ban page must stay deleted')
assert.ok(!fs.existsSync(path.join(root, 'assets/admin/src/pages/users/UsersReport.vue')), 'self-built SPA report queue must stay deleted')
})
test('ban/report settings page mirrors the reference theme schema', () => {
const schemaPhp = read('src/Admin/Schema/SchemaUsers.php')
const schemaJs = read('assets/admin/src/schema/sections/users/report.js')
const sections = read('assets/admin/src/constants/sections/users.js')
// SPA 侧边栏“用户互动 → 禁封/举报”挂载为 schema 设置页(非跳转页)
assert.match(sections, /schemaPage\(\{\s*\n\s*key: 'users-ban',/)
assert.match(sections, /label: '禁封\/举报'/)
assert.match(sections, /section: 'report'/)
assert.ok(!sections.includes("component: 'NativeOpsRedirect'"), 'ban page must not be a redirect stub')
// 字段键 1:1 对齐参考主题:禁封/举报开关、预置原因、申诉说明、申诉资料、图片举报、举报说明
for (const key of ['user_ban_s', 'user_report_s', 'ban_preset_reason', 'ban_appeal_desc', 'ban_appeal_keys', 'user_report_img_s', 'user_report_desc']) {
assert.ok(schemaPhp.includes(`'${key}'`), `PHP schema missing reference key: ${key}`)
assert.ok(schemaJs.includes(key), `SPA schema missing reference key: ${key}`)
}
// 参考主题文案与 group 结构
for (const label of ['禁封功能', '举报功能', '预置原因', '申诉说明', '申诉所需资料', '举报可上传图片', '举报说明']) {
assert.ok(schemaJs.includes(`label: '${label}'`), `SPA schema missing reference label: ${label}`)
}
assert.match(schemaJs, /type: 'group'/)
assert.match(schemaJs, /添加原因/)
assert.match(schemaJs, /添加申诉资料/)
// 运行时读取兼容 group 数组 [{t}] 并保留旧格式回退
const runtime = read('src/Settings/UserRuntime.php')
assert.match(runtime, /ban_settings\(\): array/)
assert.match(runtime, /report_settings\(\): array/)
assert.match(runtime, /user_report_s/)
// 缺键回退 schema 默认值(参考主题 _pz 语义):运行时经 read_domain 读取,
// 裸 get_all() 会让未保存过分区的站点拿不到 ban_preset_reason 默认值。
assert.match(runtime, /read_domain\('users', 'report'\)/)
// 预置原因 slug 不得由 sanitize_key(标签) 直接派生:中文标签会被清洗成
// 空串,前端按 slug 非空过滤后整张原因列表会被丢弃。ASCII 键优先,
// 否则用标签内容的稳定 md5 短哈希。
assert.match(runtime, /md5\(\$text\)/)
// 死链清理:user_report_categories 从不是真实存储键,解析器必须删除。
assert.ok(!runtime.includes('parse_report_categories'), 'dead parse_report_categories must stay removed')
assert.ok(!runtime.includes('user_report_categories'), 'dead user_report_categories key must stay removed')
const appeal = read('src/User/BanAppeal.php')
assert.match(appeal, /ban_appeal_keys/)
assert.match(appeal, /ban_appeal_desc/)
})
test('ban appeal approval actually lifts restrictions', () => {
assert.match(usersMenus, /lift_user_restriction\(\$user_id\)/)
assert.ok(!usersMenus.includes("SET status = 'lifted'"), 'restriction table has no status column')
assert.ok(!usersMenus.includes('clear_user_bans'))
assert.ok(!usersMenus.includes('lift_ban'))
})
test('ban identity badge occupies the public profile meta row', () => {
// 公开主页:禁封状态占据身份行元信息位(参考主题 author_header_identity),
// 不再是资料卡下方提示框(notice 链已移除)。
assert.match(userBanHelper, /function aksrt_get_user_ban_identity_badge/)
assert.match(authorHero, /aksrt_get_user_ban_identity_badge\(\$user_id\)/)
assert.match(authorHero, /aksrt-author-hero__identity/)
assert.ok(!authorHero.includes('aksrt-author-hero__notice'), 'profile notice box must stay removed')
assert.ok(!authorHero.includes('profile_notice'), 'dashboard must not carry profile_notice anymore')
assert.ok(!home.includes('get_profile_notice'), 'Home::get_profile_notice must stay removed')
assert.ok(!mainCss.includes('aksrt-author-hero__notice'), 'notice box CSS must stay removed')
// 徽章字段(文案/颜色/图标)由 Restriction::state_badges() 统一管理。
assert.match(userBanHelper, /state_badges\(\)/)
// 死配置清理:公开提示开关与提示文案键不再进入运行时。
assert.ok(!userRuntime.includes('public_notice'), 'dead public_notice setting must stay removed')
assert.ok(!userRuntime.includes('ban_notice_text'), 'dead ban_notice_text setting must stay removed')
// 用户中心元信息入口:but 按钮(徽章 + 箭头)→ 查看禁封状态弹窗。
assert.match(userBanHelper, /function aksrt_get_user_ban_info_link/)
assert.match(userBanHelper, /data-aksrt-ban-info=/)
assert.match(ucHeader, /aksrt_get_user_ban_info_link/)
})
test('governance display state is a single source shared by every prompt surface', () => {
// 展示态与徽章字段集中在 Restriction(身份行/用户中心/评论拒绝态/昵称徽章共用)。
for (const state of ['STATE_BANNED', 'STATE_RESTRICTED', 'STATE_MUTED']) {
assert.match(restriction, new RegExp(`const ${state}\\s+=`), `missing display state: ${state}`)
}
assert.match(restriction, /public function get_display_state\(int \$user_id\): string/)
assert.match(restriction, /public static function state_badges\(\): array/)
// 颜色统一管理:封号=红、小黑屋=黄、禁言=灰。
assert.match(restriction, /'label' => __\('违规已封号', 'aksrt'\),\s*'tone' => 'c-red'/)
assert.match(restriction, /'label' => __\('小黑屋禁封中', 'aksrt'\),\s*'tone' => 'c-yellow'/)
assert.match(restriction, /'label' => __\('禁言中', 'aksrt'\),\s*'tone' => 'c-gray'/)
// 禁封信息行单一数据源(查看禁封状态弹窗/申诉弹窗/禁封页/管理端弹窗共用)。
assert.match(restriction, /public function get_ban_info\(int \$user_id\): array/)
assert.match(restriction, /'status'\s*=>/)
assert.match(restriction, /'no_appeal_desc'/)
assert.match(restriction, /is_banned\(\$user_id\)[\s\S]*?is_suspended\(\$user_id\)[\s\S]*?is_restricted\(\$user_id, self::TYPE_MUTE\)/)
// 请求级缓存:评论列表逐条渲染昵称徽章不得反复查库。
assert.match(userBanHelper, /static \$cache = \[\]/)
})
test('ban prompt surface mirrors the reference theme modals and block page', () => {
// 查看禁封状态弹窗(1:1 zib_get_ban_info_modal:多彩头 + 信息行 + 申诉按钮)。
assert.match(userBanHelper, /function aksrt_get_user_ban_info_modal/)
assert.match(userBanHelper, /aksrt-user-ban-info-modal/)
assert.match(userBanHelper, /您已解封,请刷新页面/)
assert.match(userBanHelper, /您当前已无法进行申诉/)
// 禁封申诉弹窗(1:1 zib_get_user_ban_appeal_modal:edit-header + 信息框 +
// 资料项 inputs + 审核中态 + 禁止申诉态;仅本人)。
assert.match(userBanHelper, /function aksrt_get_user_ban_appeal_modal/)
assert.match(userBanHelper, /aksrt-user-ban-appeal-modal/)
assert.match(userBanHelper, /function aksrt_get_user_ban_appeal_link/)
assert.match(userBanHelper, /data-aksrt-ban-appeal=/)
assert.match(userBanHelper, /抱歉!您当前已无法进行申诉/)
assert.match(userBanHelper, /您的申诉正在审核中,请耐心等待/)
assert.match(userBanHelper, /name="data\[/)
assert.match(userBanHelper, /详细的说明更容易通过审核喔/)
// 申诉弹窗滚动容器:scroll-y + no-scrollbar(完全隐藏滚动条,含细滚动条)+ max-vh5;
// 说明框保持 rows=3 自然高度(禁封弹窗留言框才有 autoHeight/maxHeight)。
assert.match(userBanHelper, /scroll-y no-scrollbar max-vh5/)
assert.ok(!userBanHelper.includes('mini-scrollbar'), 'ban modals must not show any scrollbar (thin included)')
const appealModalFn = userBanHelper.slice(userBanHelper.indexOf('function aksrt_get_user_ban_appeal_modal'))
const appealModalBody = appealModalFn.slice(0, appealModalFn.indexOf('\n}'))
const appealTextarea = (appealModalBody.match(/<textarea[^>]*>/) || [''])[0]
assert.ok(appealTextarea !== '' && appealTextarea.includes('rows="3"'), 'appeal textarea keeps natural rows=3 height')
assert.ok(!appealTextarea.includes('autoHeight'), 'appeal textarea must not opt into autoHeight')
assert.ok(!appealTextarea.includes('maxHeight'), 'appeal textarea must not carry maxHeight (bindAutoHeight would squash it to 0)')
// JS:autoHeight 仅对显式声明的文本框生效。
assert.match(userBanJs, /querySelectorAll\('textarea\[autoHeight\], textarea\[maxHeight\]'\)/)
// 用户中心元信息按钮:外层 but 不重复状态色,颜色仅由内层 badg 承载。
const infoLinkFn = userBanHelper.slice(userBanHelper.indexOf('function aksrt_get_user_ban_info_link'))
const infoLinkBody = infoLinkFn.slice(0, infoLinkFn.indexOf('\n}'))
assert.match(infoLinkBody, /\$classes = trim\('but p2-10'\s*\./)
assert.ok(!infoLinkBody.includes("'but p2-10 ' . (string) \$state['tone']"), 'outer but must not repeat the badge tone')
// 申诉表单 data[key] 结构与后端兼容。
assert.match(banAppeal, /is_array\(\$_POST\['data'\]\)/)
assert.match(banAppeal, /申诉说明/)
// 禁封记录弹窗 + 记录链接(1:1 zib_get_ban_log_modal / zib_get_user_ban_log_link)。
assert.match(userBanHelper, /function aksrt_get_user_ban_log_modal/)
assert.match(userBanHelper, /function aksrt_get_user_ban_log_link/)
assert.match(userBanHelper, /data-aksrt-ban-log=/)
assert.match(userBanHelper, /当前用户已有%d次禁封记录,请谨慎操作/)
assert.match(userBanHelper, /该用户共有%d次禁封记录/)
// 登录后禁封状态页(1:1 zib_user_ban_restrict_login / zib_die_page)。
assert.match(userBanHelper, /function aksrt_render_user_ban_block_page/)
assert.match(banBlock, /抱歉,您的账号当前处于禁封状态,暂时无法登录/)
assert.match(banBlock, /退出登录/)
assert.match(banBlock, /aksrt_get_user_ban_appeal_link/)
for (const row of ['用户名', '状态', '开始时间', '结束时间', '原因', '说明', '申诉权限']) {
assert.ok(banBlock.includes(`__('${row}', 'aksrt')`), `ban block page missing row: ${row}`)
}
// Restriction 以 template_redirect 收口(不禁登录)。
assert.match(restriction, /add_action\('template_redirect', \[\$this, 'restrict_frontend_access'\], 1\)/)
assert.match(restriction, /public function restrict_frontend_access\(\): void/)
assert.ok(!restriction.includes('enforce_login_ban'), 'login-time ban block must stay removed')
// 评论拒绝态(参考主题 zib_get_user_ban_nocan_info)。
assert.match(userBanHelper, /function aksrt_get_user_ban_nocan_html/)
assert.match(commentsHelper, /aksrt_get_user_ban_nocan_html/)
assert.match(commentsHelper, /暂时无法发表评论/)
// 昵称徽章 + 头像角标(参考主题 user_name_badge / user_avatar_badge)。
assert.match(userBanHelper, /function aksrt_get_user_ban_name_badge/)
assert.match(userBanHelper, /function aksrt_get_user_ban_avatar_badge/)
assert.match(identityHelper, /aksrt_get_user_ban_name_badge\(\$user_id\)/)
assert.match(avatarHelper, /aksrt_get_user_ban_avatar_badge\(\$user_id\)/)
// 三个弹窗 REST 路由(modal-mini 几何 / 每次刷新)。
for (const route of ['/user-ban/info-modal', '/user-ban/appeal-modal', '/user-ban/log-modal']) {
assert.ok(userController.includes(`'${route}'`), `missing REST route: ${route}`)
}
// 前端委托与 admin-ajax 提交。
for (const attr of ['data-aksrt-ban-info', 'data-aksrt-ban-appeal', 'data-aksrt-ban-log']) {
assert.ok(userBanJs.includes(attr), `missing JS delegation: ${attr}`)
}
assert.match(userBanJs, /aksrt_ban_appeal_submit/)
assert.match(userBanJs, /new FormData\(form\)/)
// modal-mini 几何(400px)与头部样式覆盖三个新弹窗类。
for (const cls of ['aksrt-user-ban-info-modal', 'aksrt-user-ban-appeal-modal', 'aksrt-user-ban-log-modal']) {
assert.ok(modalRuntimeCss.includes(cls), `missing modal geometry for ${cls}`)
}
// 头像角标样式:源文件与入队的 min 产物同步。
for (const css of [mainCss, mainMinCss]) {
assert.ok(css.includes('aksrt-avatar-ban-badge'), 'ban avatar badge CSS must exist in both main.css and main.min.css')
}
// 申诉页面模板与专属资产已移除(死代码清理)。
assert.ok(!templatesMap.includes('page-ban-appeal'), 'ban appeal page template registration must stay removed')
assert.ok(!scriptsPart1b.includes('page-ban-appeal'), 'ban appeal page assets must stay removed')
})
test('report entry hides targets already banned or suspended', () => {
// 参考主题 zib_get_report_link:已封禁/小黑屋用户不展示举报入口(禁言不隐藏)。
assert.match(userBanHelper, /function aksrt_is_user_ban_restricted/)
assert.match(commentItem, /aksrt_is_user_ban_restricted\(\$author_id\)/)
assert.match(singleComponents, /aksrt_is_user_ban_restricted\(\$post_author_id\)/)
assert.match(authorHero, /aksrt_is_user_ban_restricted\(\$user_id\)/)
})
test('banned users may log in and meet the frontend block page', () => {
// 对齐参考主题:封号用户可正常登录(不禁登录),登录后前台任何页面展示禁封页。
assert.ok(!restriction.includes("add_filter('authenticate'"), 'login-time ban block must stay removed')
assert.ok(!authService.includes('user_banned'), 'dead user_banned error branch must stay removed')
assert.ok(!authService.includes('get_error_code'), 'friendly_login_error must not carry the dead code param')
assert.ok(!oauthAccountService.includes('is_banned'), 'complete_login must not gate login')
const completeLoginFn = oauthAccountService.slice(oauthAccountService.indexOf('function complete_login'))
const completeLoginBody = completeLoginFn.slice(0, completeLoginFn.indexOf('\n}'))
assert.ok(!completeLoginBody.includes('WP_Error'), 'complete_login must stay void')
assert.ok(!phoneOps.includes('login_ban_message'), 'SMS path must not carry the removed ban gate')
assert.ok(!weixinGzh.includes('account_banned'), 'weixin gzh must not carry the removed ban error code')
assert.ok(!oauthFlow.includes('account_banned'), 'oauth flow must not carry the removed ban error code')
assert.ok(!oauthProxy.includes('account_banned'), 'oauth proxy must not carry the removed ban error code')
for (const js of [authJs, userCenterJs]) {
assert.ok(!js.includes('account_banned'), 'removed account_banned JS message must stay gone')
}
// Toast 透传(一般性修复,保留):REST 层不得把失败原因硬编码为凭证错误。
assert.match(phoneOps, /\$result\['error'\]/)
assert.ok(
phoneOps.includes("$login_error !== '' ? $login_error : __('账号或密码错误', 'aksrt')"),
'auth_login must pass through the real failure reason'
)
})
test('admin report notification links to the real page', () => {
assert.match(mailNotifications, /users\.php\?page=aksrt_user_ban'/)
assert.ok(!mailNotifications.includes('page=aksrt_reports&'), 'dead admin page link')
assert.ok(!mailNotifications.includes('tab=reports'), 'merged ticket page has no reports tab')
// 处理说明必须随 aksrt_report_status_changed 传入邮件载荷(此前硬编码空串,
// 举报人收到的“说明:”永远为空)。
assert.match(reportCenter, /do_action\('aksrt_report_status_changed', \$report_id[\s\S]*?new_status, \$note_clean\)/)
assert.match(mailNotifications, /function report_status_changed\(int \$report_id, int \$reporter_id, string \$status, string \$note = ''\)/)
assert.match(mailNotifications, /'note'\s*=>\s*\$note !== '' \? \$note :/)
// 举报通知必须入站内消息系统:提交→管理员(直达工单列表)、
// 处理结果→举报人(与结果邮件同一时机、同一说明)。
assert.match(reportCenter, /notify_admins_new_report\(/)
assert.match(reportCenter, /notify_reporter_status_changed\(/)
assert.match(reportCenter, /Message::get_instance\(\)->send\(/)
assert.match(reportCenter, /users\.php\?page=aksrt_user_ban&type=report&status=pending/)
})
test('native ban page mirrors the reference theme layout', () => {
const tickets = read('src/Admin/Native/UsersMenusTickets.php')
// 菜单与页面标题对齐参考主题(page title=处理举报及禁封申诉,menu title=举报&禁封申诉)
assert.ok(usersMenus.includes(`__('处理举报及禁封申诉', 'aksrt'),\n __('举报&禁封申诉', 'aksrt')`), 'submenu titles should match the reference theme')
assert.ok(usersMenus.includes('用户禁封申诉及用户举报管理'), 'page heading should match the reference theme')
// 类型筛选按钮组 + 状态 subsubsub + 搜索
for (const label of [`__('禁封申诉', 'aksrt')`, `__('用户举报', 'aksrt')`, `__('待处理', 'aksrt')`, `__('已批准', 'aksrt')`, `__('已拒绝', 'aksrt')`, `__('搜索记录', 'aksrt')`]) {
assert.ok(tickets.includes(label), `missing reference-theme label: ${label}`)
}
// 表头列与参考主题一致:状态/类型/提交用户/详情/提交时间/更新时间(带排序)
for (const head of ['状态', '类型', '提交用户', '详情', '提交时间', '更新时间']) {
assert.ok(tickets.includes(`__('${head}', 'aksrt')`), `missing table head: ${head}`)
}
assert.match(tickets, /orderby-but/)
assert.match(tickets, /dashicons-arrow-up/)
// 行渲染与处理表单对齐参考主题
for (const label of [`__('立即处理', 'aksrt')`, `__('正在处理', 'aksrt')`, `__('被举报用户', 'aksrt')`, `__('被举报用户禁封信息', 'aksrt')`, `__('申诉用户', 'aksrt')`, `__('禁封信息', 'aksrt')`, `__('申诉信息', 'aksrt')`, `__('处理方式', 'aksrt')`, `__('处理留言', 'aksrt')`, `__('申诉通过(解封该用户)', 'aksrt')`, `__('申诉拒绝(保持原禁封状态)', 'aksrt')`, `__('此处内容会反馈给举报人', 'aksrt')`, `__('确认提交', 'aksrt')`, `__('用户状态正常', 'aksrt')`, `__('当前用户已解封', 'aksrt')`, `__('此用户已解封', 'aksrt')`, `__('暂无申请记录', 'aksrt')`]) {
assert.ok(tickets.includes(label), `missing process form label: ${label}`)
}
// 处理表单必须携带隐藏 action 字段:admin-post.php 靠它触发
// admin_post_{action} 钩子,缺失时 WordPress 不输出任何内容(白屏)。
// value 按工单类型分发(report → update_report,appeal → process_appeal)。
assert.match(tickets, /name="action" value="' \. esc_attr\(\$kind === 'report' \? 'aksrt_users_update_report' : 'aksrt_users_process_appeal'\)/)
assert.match(usersMenus, /admin_post_aksrt_users_update_report/)
assert.match(usersMenus, /admin_post_aksrt_users_process_appeal/)
// 内联处理视图(参考主题方向):表单以合法的 colspan=6 单元格随待处理行
// 预渲染;展开/取消走 JS 行↔表单互换(无整页刷新),信息只出现一次,
// 不再裸嵌 tbody 被 foster-parent 错位。
assert.match(tickets, /<td colspan="6">';\s*\$this->render_ticket_process_form/)
assert.match(tickets, /data-aksrt-ticket-toggle="/)
assert.match(tickets, /data-aksrt-ticket-cancel="/)
assert.match(tickets, /data-aksrt-ticket-row="/)
assert.match(tickets, /e\.preventDefault\(\);/)
// 展开的处理表单提供“取消”按钮(确认提交右侧)。
assert.match(tickets, /class="button button-primary process-submit"/)
assert.match(tickets, /<a class="button" data-aksrt-ticket-cancel="' \. esc_attr\(\$row\['kind'\] \. '-' \. \(string\) \$row\['id'\]\) . '" href="' \. esc_url\(remove_query_arg\(\['action', 'id'\]\)\) . '">' \. esc_html__\('取消', 'aksrt'\)/)
// 分页对齐参考主题 zibpay_admin_pagenavi(每页 20 条)
assert.match(tickets, /TICKETS_PER_PAGE = 20/)
assert.match(tickets, /ticket_pagenavi\(int \$total_count, int \$per_page\)/)
for (const label of ['前往第一页', '前往上一页', '前往下一页', '前往最后一页', 'displaying-num', 'paging-input', 'total-pages']) {
assert.ok(tickets.includes(label), `missing pagenavi part: ${label}`)
}
// 旧的三 tab 结构已收敛;举报处理表单不包含处罚下拉(对齐参考主题)
assert.ok(!usersMenus.includes('tab=reports'), 'old reports tab is gone')
assert.ok(!usersMenus.includes('tab=appeals'), 'old appeals tab is gone')
// 禁封限制 Tab 已移除(参考主题无此后台 Tab):禁封改走前台封禁弹窗。
assert.ok(!usersMenus.includes('tab=restrictions'), 'restrictions tab is gone')
assert.ok(!usersMenus.includes('render_restrictions_tab'), 'restrictions tab renderer is gone')
assert.ok(!usersMenus.includes('handle_add_restriction'), 'backend ban form handler is gone')
assert.ok(!usersMenus.includes('handle_remove_restriction'), 'backend unban form handler is gone')
assert.ok(!usersMenus.includes('render_reports_tab'))
assert.ok(!usersMenus.includes('render_appeals_tab'))
assert.match(usersMenus, /use UsersMenusTickets;/)
assert.ok(!tickets.includes('name="action_taken"'), 'report process form should be status-only like the reference theme')
// 工单处理页「点击此处」跳转前台用户公开主页(封面三点菜单 → 封禁弹窗)。
assert.match(tickets, /aksrt_get_user_home_url\(\$target_id\)/)
assert.ok(!tickets.includes('tab=restrictions'), 'ticket ban link must not point at the removed backend tab')
})
test('report modal mirrors the reference theme: preset reasons, image evidence, notice, geometry', () => {
const scripts = read('src/Core/ScriptsPart1c.php')
// 预置原因真实数据源是 ban_preset_reason(UserRuntime::ban_settings),
// 不再读取 report_settings 中不存在的 categories 键(短路已修)。
assert.match(scripts, /UserRuntime::ban_settings\(\)\['reasons'\]/)
assert.ok(!scripts.includes("$report['categories']"), 'dead report.categories source must be removed')
// 图片举证开关与举报说明随 aksrtData 注入。
assert.match(scripts, /imgEnabled/)
assert.match(scripts, /report_settings\(\)/)
const reportJs = read('assets/js/report.js')
// Zibll 同构标记:edit-header 警示头、原因徽章 active 焦点色、其它原因、
// 图片举证入口(后台开启时)、举报说明注入、but-average modal-buts 底栏。
for (const part of ['edit-header', 'toggle-radius mr10 c-red', 'data-aksrt-report-reason', 'form-but-radio', '其它原因', '违规链接', '详细描述', '添加图片举证', 'but-average modal-buts']) {
assert.ok(reportJs.includes(part), `report.js missing reference-theme part: ${part}`)
}
assert.match(reportJs, /report\/upload-image/)
assert.ok(!reportJs.includes('is-active'), 'chips must toggle the reference .active class, not a custom one')
// 举报图片上传端点存在,复用评论上传回调、独立权限(登录 + 举报 + 图片开关)。
assert.match(userController, /'\/report\/upload-image'/)
// 几何 1:1:桌面端 600px(参考主题 Bootstrap 默认模态宽)。
const css = read('assets/css/frontend/modal-runtime.css')
assert.match(css, /\.aksrt-report-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /600px/)
// 加号图标单一来源:main.css 的 .preview .add:before/:after(inset:0 + margin:auto),
// modal-runtime 不得重复定义(否则过约束冲突,加号偏移只剩“L”形)。
assert.ok(!/form-upload \.preview \.add::?(before|after)/.test(css), 'duplicated add plus rules must stay out of modal-runtime.css')
})
test('comments expose a report entry for other users when reporting is enabled', () => {
// 举报位于“回复”右侧三点下拉菜单内(参考主题同位置),载荷走 wp_json_encode。
assert.match(commentItem, /data-aksrt-report="/)
assert.match(commentItem, /function aksrt_get_comment_report_html/)
assert.match(commentItem, /comment-report-link/)
assert.match(commentItem, /report'\]\['enabled'\]/)
assert.match(commentItem, /\$author_id === \$reporter_id/)
// 下拉菜单(置顶/编辑/批准/删除 + 禁封或举报)末项走禁封优先、否则举报。
assert.match(commentItem, /function aksrt_get_comments_action_dropdown_html[\s\S]*?\$lists \.= aksrt_get_comment_ban_or_report_item/)
// 禁封项(管理员可见):参考主题 comments_action_lists 钩子语义,渲染于 user-ban.php。
assert.match(userBanHelper, /comment-ban-link/)
assert.match(userBanHelper, /data-aksrt-ban="/)
// 举报不作为 comt-meta 独立按钮直接渲染。
assert.doesNotMatch(commentItem, /\$report\s*=\s*aksrt_get_comment_report_html\(\$comment\)/)
})
test('frontend ban modal mirrors the reference theme zib_get_set_user_ban_modal 1:1', () => {
// 禁封方式/禁封时间/预置原因徽章/其它原因/禁封说明/删除全部内容/禁止用户申诉,
// 以及已禁封用户的状态信息与解封留言表单(弹窗经 REST /user-ban/modal 输出)。
for (const label of ['拉入小黑屋', '禁封用户', '禁封方式', '禁封时间', '选择禁封原因', '其它原因', '禁封说明', '删除全部内容', '禁止用户申诉', '请填写禁止申诉说明', '解封用户', '移出小黑屋', '给用户的留言', '开始时间', '结束时间', '原因', '说明', '禁封记录', '确认禁封']) {
assert.ok(userBanHelper.includes(label), `ban modal missing reference label: ${label}`)
}
// 状态文案由 Restriction::get_ban_info() 单一数据源产出(各提示面共用)。
for (const label of ['禁封中(禁止登录)', '小黑屋禁封中']) {
assert.ok(restriction.includes(label), `ban status label missing from Restriction: ${label}`)
}
// 时长选项 1/7/14/30/100/永久(默认 7 天);类型映射:小黑屋→suspend,禁封→ban。
assert.match(userBanHelper, /1 => __\('1天', 'aksrt'\)/)
assert.match(userBanHelper, /7 => __\('7天', 'aksrt'\)/)
assert.match(userBanHelper, /100 => __\('100天', 'aksrt'\)/)
assert.match(userBanHelper, /7 === \$days \? ' checked="checked"' : ''/)
// 预置原因来自 UserRuntime::ban_settings(与后台同一数据源)。
assert.match(userBanHelper, /UserRuntime::ban_settings\(\)\['reasons'\]/)
// 勾选禁止申诉必须填写说明(参考主题同校验),落用户 meta 供申诉入口拦截。
assert.match(userBanHelper, /_aksrt_no_appeal|no_appeal/)
const appeal = read('src/User/BanAppeal.php')
assert.match(appeal, /_aksrt_no_appeal/)
// 弹窗入口按钮契约:[data-aksrt-ban] 委托(封面按钮 / 评论菜单项 / 文章作者菜单项)。
assert.match(userBanHelper, /data-aksrt-ban="/)
assert.match(userBanHelper, /function aksrt_get_user_cover_menu_action_html/)
assert.match(userBanHelper, /function aksrt_get_comment_ban_or_report_item/)
assert.match(userBanHelper, /function aksrt_get_post_author_ban_item/)
// 权限模型(参考主题 zib_current_user_can('set_user_ban') 语义):
// manage_options + 非本人 + 非超级管理员 + 功能开关。
assert.match(userBanHelper, /current_user_can\('manage_options'\)/)
assert.match(userBanHelper, /\$current_id === \$user_id/)
assert.match(userBanHelper, /is_super_admin\(\$user_id\)/)
assert.match(userBanHelper, /user_ban_s/)
// REST 路由:modal 拉取 + set 提交(permission_callback 校验)。
assert.match(userController, /'\/user-ban\/modal'/)
assert.match(userController, /'\/user-ban\/set'/)
// 解封视图多彩横幅(参考主题 zib_get_modal_colorful_header('c-yellow', ...))。
assert.match(userBanHelper, /modal-colorful-header colorful-bg c-yellow/)
// 提交后整页刷新(禁封 reload:true)/ 仅关闭弹窗(解封 hide_modal:true)。
assert.match(userBanJs, /window\.location\.reload\(\)/)
assert.match(userBanJs, /AksrtModalRuntime\.close\('submit'\)/)
assert.match(userBanJs, /data-aksrt-ban-submit/)
assert.match(userBanJs, /data-aksrt-unban-submit/)
// 桌面 600px(参考主题未传 data-class → Bootstrap .modal-dialog 默认宽)
// + min-height 360px(data-height=360);移动端底部抽屉。
assert.match(userBanJs, /MODAL_HEIGHT = 360/)
assert.match(userBanJs, /mobileBottom: true/)
const css = read('assets/css/frontend/modal-runtime.css')
assert.match(css, /\.aksrt-user-ban-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /600px/)
// textarea autoHeight 1:1(参考主题 maxHeight 锁高 + 边框补偿)。
assert.match(userBanJs, /getAttribute\('maxHeight'\)/)
// 三个入口模板接线:封面三点菜单 / 评论区三点菜单 / 文章作者 metabox 三点菜单。
const authorHero = read('templates/parts/user/author-hero.php')
assert.match(authorHero, /aksrt_get_user_cover_menu_action_html\(\$user_id\)/)
assert.match(commentItem, /aksrt_get_comment_ban_or_report_item\(\$comment\)/)
const singleComponents = read('inc/helpers/aksrt-single-components.php')
assert.match(singleComponents, /aksrt_get_post_author_ban_item\(/)
// 脚本入队(singular + profile,与 report.js 同组)。
const scripts = read('src/Core/ScriptsPart1b.php')
assert.match(scripts, /'aksrt-user-ban'/)
})
test('uninstall cleans up every ban/report meta key written by the theme', () => {
for (const key of ['_aksrt_active_restrictions', '_aksrt_is_banned', '_aksrt_no_appeal', 'aksrt_user_ban_state']) {
assert.ok(uninstall.includes(`'${key}'`), `missing uninstall cleanup: ${key}`)
}
})
test('restriction REST operations pass expiry through to the service', () => {
assert.match(restrictionOps, /\$params\['expires_at'\] \?\? ''/)
assert.match(restrictionOps, /Restriction::get_instance\(\)->restrict\(/)
})
test('suspend (小黑屋) enforcement blocks publishing end to end', () => {
// 服务层提供 is_suspended / get_active_suspend(对齐参考主题:小黑屋
// 失去所有发布权限——发帖、评论等;此前 suspend 只在公开主页显示提示)。
assert.match(restriction, /public function is_suspended\(int \$user_id\): bool/)
assert.match(restriction, /public function get_active_suspend\(int \$user_id\): \?array/)
// 前台发帖入口(FrontSubmitSave::persist)拦截发帖/草稿/编辑。
const frontSave = read('src/Core/FrontSubmitSave.php')
assert.match(frontSave, /Restriction::get_instance\(\)->is_suspended\(\$user_id\)/)
assert.match(frontSave, /您已被关入小黑屋/)
// 评论提交(pre_comment_on_post)同时覆盖禁言(mute)与小黑屋(suspend)。
assert.match(restriction, /enforce_comment_mute[\s\S]*?is_suspended\(\$user_id\)/)
assert.match(restriction, /您已被关入小黑屋,暂时无法评论/)
})
test('report modal close icon and textarea autoHeight match the reference theme', () => {
const reportJs = read('assets/js/report.js')
// 关闭按钮走 SVG 雪碧图 icon-close(与参考主题 .ic-close 同构,fa-times 退役)。
assert.match(reportJs, /<svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"><\/use><\/svg>/)
assert.ok(!reportJs.includes('fa-times'), 'close icon must use the SVG sprite, not FontAwesome')
// textarea 自动增高(参考主题 autoHeight 行为),隐藏中的“其它原因”跳过首次计算。
assert.match(reportJs, /function bindAutoHeight\(/)
assert.match(reportJs, /querySelectorAll\('textarea\.form-control'\)\.forEach\(bindAutoHeight\)/)
assert.match(reportJs, /offsetHeight > 0\) grow\(\)/)
})
test('wechat template messages cover report and account restriction types', () => {
const wechat = read('src/Payment/WeChatNotify.php')
assert.match(wechat, /const NOTIFY_TYPES = \['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'report', 'system'\]/)
assert.match(wechat, /in_array\(\$type, self::NOTIFY_TYPES, true\)/)
// 订阅白名单与状态输出复用同一常量,不允许再出现硬编码子集。
assert.ok(!wechat.includes("['comment_reply', 'system_notice', 'order_paid', 'credits_change']"), 'type list must come from NOTIFY_TYPES')
// 站内通知缺省模板字段时用 title/description 兜底合成。
assert.match(wechat, /\$data\['description'\] \?\? ''/)
})