Files
kate522andClaude Opus 5 427968a721 chore(branding): remove leftover upstream theme identifiers
The theme was renamed Zibll/子比 → AKSRT → Jio, but upstream branding
survived the sweeps in CSS classes, the SVG sprite prefix, a persisted
widget field ID, JS events, admin Vue class names, test infrastructure
and docblocks.

Renames:
- zib-widget / zib-widget-wrap / zib-slider → jio-widget-box /
  jio-widget-box-wrap / jio-slider-box (jio-widget is taken by the modern
  widget shell, jio-box-* by the card primitives)
- zib-partners-scroll + zps-* → jio-partners-scroll + jps-*
- zib-combo-info-card + zci-* → jio-combo-info-card + jci-*
- zibsvg- → jiosvg-, including the exported picker helpers and the
  fa-zibsvg- family-prefix guard
- zibpay_type → pay_type; the dual-read fallback in PostQuery collapses
  to a single key
- [zibajax="submit"] dropped (no PHP emitter); zib_ajax.success →
  jio_ajax.success
- aks-* admin Vue classes → jio-*
- zib_palette() → palette()

User-facing vendor traces are gone: the floating "more" button default no
longer ships a zibll.com link or 子比主题 marketing copy, the notice
widget ships no preset promo entries, and the four 官网教程 links are
removed from the post-list admin schema.

Test infrastructure renamed to neutral names (docker-compose.reference.yml,
Caddyfile, article-baseline.json, modern-widgets-contract.test.cjs) with
their DB/volume/env/slug identifiers following.

Kept intentionally, documented in tests/fixtures/README.md: the golden
fixture reference fingerprint (a SHA-256 record of an external directory,
read by validate-widget-golden / generate-widget-golden-fixture /
audit-widget-system), the doesNotMatch guards that prove the old tokens are
gone, and the reference theme's own meta key in the visual fixtures.

No backward-compat or data migration: saved widget configs holding old
icon keys or the old field ID silently lose them, per the project's
rewrite-over-compatibility rule.

Verified: 132/132 node contract tests, php tests/widget-system/run.php
(146 assertions), audit-admin-contracts, audit-psr4-paths and
validate-widget-golden all green; tracked bundles regenerated and in sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 00:06:54 +08:00

556 lines
36 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/jio-comments.php')
const identityHelper = read('inc/helpers/jio-identity.php')
const avatarHelper = read('inc/helpers/jio-avatar.php')
const singleComponents = read('inc/helpers/jio-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 submit validates required category text and optional content before sanitizing', () => {
const start = userController.indexOf("'/report/submit'")
const route = userController.slice(start, userController.indexOf(']);', start) + 3)
assert.match(route, /'category'\s*=>\s*\[\s*'type' => 'string', 'required' => true/)
assert.match(route, /'reason'\s*=>\s*\[\s*'type' => 'string', 'default' => ''/)
assert.equal((route.match(/'validate_callback' => 'rest_validate_request_arg'/g) || []).length, 2)
})
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('jio_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, \\Jio\\User\\Restriction::TYPES, true\)/)
assert.ok(!reportCenter.includes('jio_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('_jio_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, /添加申诉资料/)
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'\)/)
assert.match(runtime, /\$reasons\[\] = \['name' => \$text\]/)
assert.doesNotMatch(runtime, /reason_slug|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', () => {
const banActions = read('src/Admin/Native/UsersMenusBanActions.php')
assert.match(banActions, /lift_user_restriction\(\$user_id\)/)
for (const source of [usersMenus, banActions]) {
assert.ok(!source.includes("SET status = 'lifted'"), 'restriction table has no status column')
assert.ok(!source.includes('clear_user_bans'))
assert.ok(!source.includes('lift_ban'))
}
})
test('ban identity badge occupies the public profile meta row', () => {
// 公开主页:禁封状态占据身份行元信息位(参考主题 author_header_identity),
// 不再是资料卡下方提示框(notice 链已移除)。
assert.match(userBanHelper, /function jio_get_user_ban_identity_badge/)
assert.match(authorHero, /jio_get_user_ban_identity_badge\(\$user_id\)/)
assert.match(authorHero, /jio-author-hero__identity/)
assert.ok(!authorHero.includes('jio-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('jio-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 jio_get_user_ban_info_link/)
assert.match(userBanHelper, /data-jio-ban-info=/)
assert.match(ucHeader, /jio_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' => __\('违规已封号', 'jio'\),\s*'tone' => 'c-red'/)
assert.match(restriction, /'label' => __\('小黑屋禁封中', 'jio'\),\s*'tone' => 'c-yellow'/)
assert.match(restriction, /'label' => __\('禁言中', 'jio'\),\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', () => {
// 查看禁封状态弹窗:多彩头 + 信息行 + 申诉按钮。
assert.match(userBanHelper, /function jio_get_user_ban_info_modal/)
assert.match(userBanHelper, /jio-user-ban-info-modal/)
assert.match(userBanHelper, /您已解封,请刷新页面/)
assert.match(userBanHelper, /您当前已无法进行申诉/)
// 禁封申诉弹窗:edit-header + 信息框 +
// 资料项 inputs + 审核中态 + 禁止申诉态;仅本人)。
assert.match(userBanHelper, /function jio_get_user_ban_appeal_modal/)
assert.match(userBanHelper, /jio-user-ban-appeal-modal/)
assert.match(userBanHelper, /function jio_get_user_ban_appeal_link/)
assert.match(userBanHelper, /data-jio-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 jio_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 jio_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, /申诉说明/)
// 禁封记录弹窗 + 记录链接。
assert.match(userBanHelper, /function jio_get_user_ban_log_modal/)
assert.match(userBanHelper, /function jio_get_user_ban_log_link/)
assert.match(userBanHelper, /data-jio-ban-log=/)
assert.match(userBanHelper, /当前用户已有%d次禁封记录,请谨慎操作/)
assert.match(userBanHelper, /该用户共有%d次禁封记录/)
// 登录后禁封状态页。
assert.match(userBanHelper, /function jio_render_user_ban_block_page/)
assert.match(banBlock, /抱歉,您的账号当前处于禁封状态,暂时无法登录/)
assert.match(banBlock, /退出登录/)
assert.match(banBlock, /jio_get_user_ban_appeal_link/)
for (const row of ['用户名', '状态', '开始时间', '结束时间', '原因', '说明', '申诉权限']) {
assert.ok(banBlock.includes(`__('${row}', 'jio')`), `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')
// 评论拒绝态。
assert.match(userBanHelper, /function jio_get_user_ban_nocan_html/)
assert.match(commentsHelper, /jio_get_user_ban_nocan_html/)
assert.match(commentsHelper, /暂时无法发表评论/)
// 昵称徽章 + 头像角标(参考主题 user_name_badge / user_avatar_badge)。
assert.match(userBanHelper, /function jio_get_user_ban_name_badge/)
assert.match(userBanHelper, /function jio_get_user_ban_avatar_badge/)
assert.match(identityHelper, /jio_get_user_ban_name_badge\(\$user_id\)/)
assert.match(avatarHelper, /jio_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-jio-ban-info', 'data-jio-ban-appeal', 'data-jio-ban-log']) {
assert.ok(userBanJs.includes(attr), `missing JS delegation: ${attr}`)
}
assert.match(userBanJs, /jio_ban_appeal_submit/)
assert.match(userBanJs, /new FormData\(form\)/)
// modal-mini 几何(400px)与头部样式覆盖三个新弹窗类。
for (const cls of ['jio-user-ban-info-modal', 'jio-user-ban-appeal-modal', 'jio-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('jio-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', () => {
// 已封禁/小黑屋用户不展示举报入口(禁言不隐藏)。
assert.match(userBanHelper, /function jio_is_user_ban_restricted/)
assert.match(commentItem, /jio_is_user_ban_restricted\(\$author_id\)/)
assert.match(singleComponents, /jio_is_user_ban_restricted\(\$post_author_id\)/)
assert.match(authorHero, /jio_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 : __('账号或密码错误', 'jio')"),
'auth_login must pass through the real failure reason'
)
})
test('admin report notification links to the real page', () => {
assert.match(mailNotifications, /users\.php\?page=jio_user_ban'/)
assert.ok(!mailNotifications.includes('page=jio_reports&'), 'dead admin page link')
assert.ok(!mailNotifications.includes('tab=reports'), 'merged ticket page has no reports tab')
// 处理说明必须随 jio_report_status_changed 传入邮件载荷(此前硬编码空串,
// 举报人收到的“说明:”永远为空)。
assert.match(reportCenter, /do_action\('jio_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=jio_user_ban&type=report&status=pending/)
})
test('native ban page uses the scoped template-and-dialog admin flow', () => {
const banPage = read('src/Admin/Native/UsersMenusBan.php');
const banQuery = read('src/Admin/Native/UsersMenusBanQuery.php');
const banActions = read('src/Admin/Native/UsersMenusBanActions.php');
const banDialogs = read('src/Admin/Native/UsersMenusBanDialogs.php');
const banJs = read('assets/js/admin-user-ban.js');
const banCss = read('assets/css/admin/user-ban.css');
assert.match(usersMenus, /use UsersMenusBan;/);
assert.match(usersMenus, /admin_post_jio_users_update_report/);
assert.match(usersMenus, /admin_post_jio_users_process_appeal/);
assert.doesNotMatch(usersMenus, /use UsersMenusTickets;/, 'ban review must not use the deleted ticket renderer');
assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTickets.php')));
for (const key of ['type', 'status', 's', 'send_user', 'orderby', 'order']) {
assert.ok(banQuery.includes(`'${key}'`), `missing ban filter: ${key}`);
}
assert.match(banQuery, /private const BAN_PER_PAGE = 20/);
assert.match(banQuery, /number'\s*=>\s*-1/);
assert.match(banQuery, /array_slice\(\$filtered, \(\$page - 1\) \* self::BAN_PER_PAGE, self::BAN_PER_PAGE\)/);
assert.match(banQuery, /'counts'\s*=>\s*\$counts/);
assert.match(banPage, /if \(\$hook !== 'users_page_jio_user_ban'\) return/);
assert.match(banPage, /wp_enqueue_style\('jio-admin-user-ban'.*NativeUi::STYLE_HANDLE/);
assert.match(banPage, /wp_enqueue_script\('jio-admin-user-ban'/);
assert.match(banPage, /<dialog id="jio-ban-dialog"/);
assert.match(banDialogs, /<template id="jio-ban-detail-/);
assert.match(banPage, /data-jio-ban-view=/);
assert.doesNotMatch(banPage, /update_user_meta|update_status|do_action\(/);
assert.doesNotMatch(banPage, /data-jio-ticket-toggle|data-jio-ticket-cancel|data-jio-ban-toggle/);
assert.match(banDialogs, /if \(\$pending\) \{[\s\S]*?<form method="post"/);
assert.match(banDialogs, /else \{[\s\S]*?<div class="jio-ban-dialog__frame">/);
assert.match(banDialogs, /'action'\s*=>\s*\$action/);
assert.match(banDialogs, /record_version/);
assert.match(banDialogs, /wp_nonce_field\(\$action \. '_' \. \(int\) \$row\['id'\]\)/);
for (const name of ['filter_type', 'filter_status', 's', 'send_user', 'orderby', 'order', 'paged']) {
assert.ok(banDialogs.includes(`'${name}'`), `review form must preserve ${name}`);
}
assert.match(banDialogs, /esc_url\(\$part, \['http', 'https'\]\)/);
assert.match(banDialogs, /esc_html\(\$value !== '' \? \$value : '—'\)/);
assert.match(banDialogs, /target="_blank" rel="noopener noreferrer"/);
assert.match(banDialogs, /data-required-error/);
for (const method of ['handle_update_report', 'handle_process_appeal']) {
assert.match(banActions, new RegExp(`public function ${method}\\(\\): void`));
}
assert.match(banActions, /check_admin_referer\('jio_users_update_report_'/);
assert.match(banActions, /check_admin_referer\('jio_users_process_appeal_'/);
assert.match(banActions, /hash_equals\(\$this->ban_record_version\(\$row\), \$submitted\)/);
assert.match(banActions, /\$this->ban_request_is_current\(\$row, 'pending'\)/);
assert.match(banActions, /\$new_status === 'dismissed' && \$note === ''/);
assert.match(banActions, /\$decision === 'reject' && \$note === ''/);
assert.match(banActions, /lift_user_restriction\(\$user_id\)/);
assert.match(banActions, /'paged'\s*=>/);
assert.match(banJs, /window\.addEventListener\('pageshow'/);
assert.match(banJs, /previousOpener\.focus\(\)/);
assert.match(banJs, /if \(!submitting\) dialog\.close\(\)/);
assert.match(banCss, /html\.jio-ban-modal-open \{ overflow: hidden/);
});
test('report modal uses accessible reasons, automatic target association and optional evidence', () => {
const scripts = read('src/Core/ScriptsPart1c.php')
assert.match(scripts, /UserRuntime::ban_settings\(\)\['reasons'\]/)
assert.ok(!scripts.includes("$report['categories']"), 'dead report.categories source must be removed')
assert.match(scripts, /imgEnabled/)
assert.match(scripts, /report_settings\(\)/)
const reportJs = read('assets/js/report.js')
for (const part of ['jio-report-target', 'name="report_reason"', 'type="radio"', '其他原因', '补充说明', '图片举证', '提交举报', '关联对象:']) {
assert.ok(reportJs.includes(part), `report.js missing form contract: ${part}`)
}
assert.doesNotMatch(reportJs, /report_url|违规链接|data-jio-report-reason=|bindAutoHeight/)
for (const source of [reportJs, commentItem, authorHero]) assert.doesNotMatch(source, /target_avatar/)
assert.match(reportJs, /description\) content\.push\(description\)/)
assert.doesNotMatch(reportJs, /category\.slug|reason = category/)
assert.doesNotMatch(reportOps, /sanitize_key\(\(string\) (?:\$request->get_param\('category'\)|\$row\['category'\])/)
assert.match(reportCenter, /'category'\s*=>\s*\$category/)
assert.doesNotMatch(reportCenter, /sanitize_key\(\$category\)/)
assert.match(reportJs, /report\/upload-image/)
assert.match(reportJs, /window\.JioModalRuntime\.getActive\(\) === shell/)
assert.match(userController, /'\/report\/upload-image'/)
const css = read('assets/css/frontend/report.css')
assert.match(css, /\.jio-report-modal:not\(\.jio-modal-runtime--bottom\) \.jio-modal-runtime__dialog/)
assert.match(css, /600px/)
assert.match(css, /:focus-visible/)
assert.match(css, /\.jio-report-reason:has\(input:checked\)/)
assert.match(css, /clip-path: inset\(50%\)/)
assert.match(css, /text-overflow: ellipsis/)
assert.doesNotMatch(reportJs, /jio-report-target__icon|无需填写链接|正在提交举报,请稍候/)
assert.match(css, /safe-area-inset-bottom/)
assert.match(css, /\[hidden\] \{ display: none !important;/)
assert.doesNotMatch(modalRuntimeCss, /jio-report-modal/, 'retired report styles must not remain in the shared stylesheet')
assert.match(scriptsPart1b, /assets\/css\/frontend\/report\.css/)
})
test('comments expose a report entry for other users when reporting is enabled', () => {
// 举报位于“回复”右侧三点下拉菜单内(参考主题同位置),载荷走 wp_json_encode。
assert.match(commentItem, /data-jio-report="/)
assert.match(commentItem, /function jio_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 jio_get_comments_action_dropdown_html[\s\S]*?\$lists \.= jio_get_comment_ban_or_report_item/)
// 禁封项(管理员可见):参考主题 comments_action_lists 钩子语义,渲染于 user-ban.php。
assert.match(userBanHelper, /comment-ban-link/)
assert.match(userBanHelper, /data-jio-ban="/)
// 举报不作为 comt-meta 独立按钮直接渲染。
assert.doesNotMatch(commentItem, /\$report\s*=\s*jio_get_comment_report_html\(\$comment\)/)
})
test('frontend ban modal mirrors the reference 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天', 'jio'\)/)
assert.match(userBanHelper, /7 => __\('7天', 'jio'\)/)
assert.match(userBanHelper, /100 => __\('100天', 'jio'\)/)
assert.match(userBanHelper, /7 === \$days \? ' checked="checked"' : ''/)
// 预置原因来自 UserRuntime::ban_settings(与后台同一数据源)。
assert.match(userBanHelper, /UserRuntime::ban_settings\(\)\['reasons'\]/)
// 勾选禁止申诉必须填写说明(参考主题同校验),落用户 meta 供申诉入口拦截。
assert.match(userBanHelper, /_jio_no_appeal|no_appeal/)
const appeal = read('src/User/BanAppeal.php')
assert.match(appeal, /_jio_no_appeal/)
// 弹窗入口按钮契约:[data-jio-ban] 委托(封面按钮 / 评论菜单项 / 文章作者菜单项)。
assert.match(userBanHelper, /data-jio-ban="/)
assert.match(userBanHelper, /function jio_get_user_cover_menu_action_html/)
assert.match(userBanHelper, /function jio_get_comment_ban_or_report_item/)
assert.match(userBanHelper, /function jio_get_post_author_ban_item/)
// 权限模型:
// 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'/)
// 解封视图多彩横幅。
assert.match(userBanHelper, /modal-colorful-header colorful-bg c-yellow/)
// 提交后整页刷新(禁封 reload:true)/ 仅关闭弹窗(解封 hide_modal:true)。
assert.match(userBanJs, /window\.location\.reload\(\)/)
assert.match(userBanJs, /JioModalRuntime\.close\('submit'\)/)
assert.match(userBanJs, /data-jio-ban-submit/)
assert.match(userBanJs, /data-jio-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, /\.jio-user-ban-modal:not\(\.jio-modal-runtime--bottom\) \.jio-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, /jio_get_user_cover_menu_action_html\(\$user_id\)/)
assert.match(commentItem, /jio_get_comment_ban_or_report_item\(\$comment\)/)
const singleComponents = read('inc/helpers/jio-single-components.php')
assert.match(singleComponents, /jio_get_post_author_ban_item\(/)
// 脚本入队(singular + profile,与 report.js 同组)。
const scripts = read('src/Core/ScriptsPart1b.php')
assert.match(scripts, /'jio-user-ban'/)
})
test('uninstall cleans up every ban/report meta key written by the theme', () => {
for (const key of ['_jio_active_restrictions', '_jio_is_banned', '_jio_no_appeal', 'jio_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 delegates its labelled title, close button and focus return to the shared runtime', () => {
const reportJs = read('assets/js/report.js')
assert.match(reportJs, /title: TARGETS\[data\.target_type\]\.title/)
assert.match(reportJs, /mobileBottom: true, focusReturn: opener/)
assert.match(reportJs, /<label class="jio-report-label" for=/)
assert.match(reportJs, /data-jio-report-reason-error role="alert"/)
assert.doesNotMatch(reportJs, /class="close"|style="display:|setTimeout/)
})
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'\] \?\? ''/)
})