const { test } = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const vm = require('node:vm') const ts = require('typescript') const vue = require('vue') const { parse, compileTemplate } = require('vue/compiler-sfc') const root = path.join(__dirname, '../src/views/chat') const plain = (value) => JSON.parse(JSON.stringify(value)) const transpile = (source) => ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText function loadModule( file, require = (name) => { if (name === 'vue') return vue throw new Error(name) } ) { const context = { exports: {}, require, URL, Intl, Date } vm.runInNewContext(transpile(fs.readFileSync(path.join(root, file), 'utf8')), context) return context.exports } const utils = loadModule('list/chat-utils.ts') const stateModule = () => loadModule('components/message-moderation.ts') const message = (id = 100, overrides = {}) => ({ id, sendId: 1, recvId: 2, content: '一条聊天消息', type: 0, status: 3, sendTime: '2026-11-03 14:20:00', ...overrides }) const success = (id = 100, overrides = {}) => ({ messageId: id, status: 2, recallMessageId: 101, alreadyHidden: false, notificationSent: true, ...overrides }) const deferred = () => { let resolve, reject const promise = new Promise((yes, no) => { resolve = yes reject = no }) return { promise, resolve, reject } } function harness(initialMessage = message(), options = {}) { const shared = options.shared || stateModule() const permission = vue.ref(options.permitted !== false) const props = vue.reactive({ message: initialMessage, senderName: '张三', receiverName: '李四' }) const calls = [], notices = [], lifecycle = {} const context = { exports: {}, defineProps: () => props, require(name) { if (name === 'vue') return { ...vue, onBeforeUnmount: (fn) => { lifecycle.unmount = fn }, onDeactivated: (fn) => { lifecycle.deactivate = fn } } if (name === 'element-plus') return { ElMessage: { success: (text) => notices.push(['success', text]), warning: (text) => notices.push(['warning', text]) } } if (name === '@/utils/permission') return { checkPermi: () => permission.value } if (name === '@/api/chat/private-msg') return { moderateMessage: async (params) => { calls.push(plain(params)) return options.api ? options.api(params) : success(params.messageId) } } if (name.endsWith('chat-utils')) return utils if (name.endsWith('message-moderation')) return shared throw new Error(name) } } const source = fs.readFileSync(path.join(root, 'components/MessageModerationActions.vue'), 'utf8') const { descriptor } = parse(source) const scope = vue.effectScope() scope.run(() => vm.runInNewContext( transpile(descriptor.scriptSetup.content) + '\nglobalThis.state = { open, submit, handleClosed, beforeClose, visible, submitting, reason, errorMessage, target, permitted, busy, disabledReason, isHidden };', context ) ) const unmount = lifecycle.unmount lifecycle.unmount = () => { unmount?.() scope.stop() } return { state: context.state, calls, notices, props, permission, lifecycle, shared } } test('number message IDs must be safe integers while string IDs retain full precision', () => { const shared = stateModule() for (const id of [Number.MAX_SAFE_INTEGER + 1, NaN, Infinity, 1.5, 0, -1, '', '0', '1e4', '12x']) assert.equal(shared.messageIdentity(id), undefined) assert.deepEqual(plain(shared.messageIdentity('9223372036854775807')), { messageId: '9223372036854775807', key: '9223372036854775807' }) assert.deepEqual(plain(shared.messageIdentity('000123')), { messageId: '000123', key: '123' }) const current = harness(message(Number.MAX_SAFE_INTEGER + 1)) current.state.open() assert.equal(current.state.visible.value, false) assert.match(current.notices[0][1], /刷新/) assert.equal(current.calls.length, 0) current.lifecycle.unmount() }) test('normal and call messages are eligible; control records and missing permissions cannot open an action', () => { const shared = stateModule() for (const type of [0, 1, 2, 3, 4, 9, 40, 41]) assert.equal(shared.canModerateMessageType(type), true) for (const type of [-1, 10, 11, 21, 30, 100, 200]) assert.equal(shared.canModerateMessageType(type), false) const control = harness(message(100, { type: 10 })) control.state.open() assert.equal(control.state.visible.value, false) const denied = harness(message(), { permitted: false }) denied.state.open() assert.equal(denied.state.visible.value, false) assert.equal(denied.calls.length, 0) control.lifecycle.unmount() denied.lifecycle.unmount() }) test('the same message across two views shares one confirmation and submitting lock', async () => { const shared = stateModule(), pending = deferred() const first = harness(message(100), { shared, api: () => pending.promise }) const second = harness(message('100'), { shared }) first.state.open() second.state.open() assert.equal(second.state.visible.value, false) assert.equal(second.state.busy.value, true) const request = first.state.submit() await first.state.submit() assert.equal(first.calls.length, 1) let closed = false first.state.beforeClose(() => { closed = true }) assert.equal(closed, false) pending.resolve(success()) await request first.state.handleClosed() assert.equal(second.state.busy.value, false) first.lifecycle.unmount() second.lifecycle.unmount() }) test('cancel releases the lock without sending a request or modifying shared revision', () => { const current = harness() current.state.open() current.state.visible.value = false current.state.handleClosed() assert.equal(current.calls.length, 0) assert.equal(current.shared.messageModerationRevision.value, 0) assert.equal(current.shared.isMessageModerationBusy('100'), false) current.lifecycle.unmount() }) test('confirmation snapshots the original message, preserves string IDs, and trims the optional reason', async () => { const id = '9223372036854775807' const current = harness(message(id), { api: () => success(Number(id)) }) current.state.open() current.props.message = message(200) current.state.reason.value = ' 违反聊天规范 ' await current.state.submit() assert.deepEqual(current.calls[0], { messageId: id, reason: '违反聊天规范' }) assert.equal(current.shared.lastModeratedMessageId.value, id) assert.equal(current.shared.messageModerationRevision.value, 1) current.lifecycle.unmount() }) test('reason length and permission changes are checked again before submission', async () => { const current = harness() current.state.open() current.state.reason.value = '字'.repeat(201) await current.state.submit() assert.equal(current.calls.length, 0) assert.match(current.state.errorMessage.value, /200/) current.state.reason.value = '有效原因' current.permission.value = false await current.state.submit() await vue.nextTick() assert.equal(current.calls.length, 0) assert.equal(current.shared.isMessageModerationBusy('100'), false) current.lifecycle.unmount() }) test('a failed request keeps the dialog and reason available for retry', async () => { let attempt = 0 const current = harness(message(), { api: () => { if (!attempt++) throw new Error('offline') return success() } }) current.state.open() current.state.reason.value = '保留原因' await current.state.submit() assert.equal(current.state.visible.value, true) assert.equal(current.state.submitting.value, false) assert.equal(current.state.reason.value, '保留原因') assert.match(current.state.errorMessage.value, /失败/) assert.equal(current.shared.messageModerationRevision.value, 0) await current.state.submit() assert.equal(current.calls.length, 2) assert.equal(current.state.visible.value, false) current.lifecycle.unmount() }) test('already-hidden messages retry synchronization without a new moderation reason', async () => { const current = harness(message(100, { status: 2 }), { api: () => success(100, { alreadyHidden: true }) }) assert.equal(current.state.isHidden.value, true) current.state.open() current.state.reason.value = '不应再提交的原因' await current.state.submit() assert.deepEqual(current.calls[0], { messageId: 100 }) assert.equal(current.notices[0][1], '已发送撤回同步通知') current.lifecycle.unmount() }) test('failed push notification still refreshes hidden status and offers resynchronization', async () => { const current = harness(message(), { api: () => success(100, { notificationSent: false }) }) current.state.open() await current.state.submit() assert.equal(current.shared.messageModerationRevision.value, 1) assert.equal(current.notices[0][0], 'warning') assert.match(current.notices[0][1], /重新同步/) assert.equal(current.state.visible.value, false) current.lifecycle.unmount() }) test('unmounting during a request keeps its lock until completion and still notifies other views', async () => { const pending = deferred(), current = harness(message(), { api: () => pending.promise }) current.state.open() const request = current.state.submit() current.lifecycle.unmount() assert.equal(current.shared.isMessageModerationBusy('100'), true) pending.resolve(success()) await request assert.equal(current.shared.isMessageModerationBusy('100'), false) assert.equal(current.shared.messageModerationRevision.value, 1) }) test('deactivation and permission changes cannot release an in-flight lock through dialog closed', async () => { for (const lifecycleChange of ['deactivate', 'permission']) { const pending = deferred() const current = harness(message(), { api: () => pending.promise }) current.state.open() const request = current.state.submit() if (lifecycleChange === 'deactivate') current.lifecycle.deactivate() else { current.permission.value = false await vue.nextTick() } current.state.handleClosed() assert.equal(current.shared.isMessageModerationBusy('100'), true, lifecycleChange) assert.equal(current.state.target.value.messageId, 100) pending.resolve(success()) await request assert.equal(current.shared.isMessageModerationBusy('100'), false, lifecycleChange) assert.equal(current.state.target.value, undefined) current.lifecycle.unmount() } }) test('moderation and the legacy table templates compile with text-only confirmation context', () => { for (const file of ['components/MessageModerationActions.vue', 'private-msg/index.vue']) { const filename = path.join(root, file) const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename }) assert.deepEqual(errors, []) const result = compileTemplate({ source: descriptor.template.content, filename, id: file }) assert.deepEqual(result.errors, []) if (file.includes('Actions')) assert.ok(!descriptor.template.content.includes('v-html')) } }) test('the legacy table refreshes the current query after moderation and ignores superseded reads', async () => { const shared = stateModule(), calls = [], pending = [] let hidden = false, unmount const context = { exports: {}, ref: vue.ref, reactive: vue.reactive, onMounted() {}, require(name) { if (name === 'vue') return { ...vue, onBeforeUnmount(fn) { unmount = fn } } if (name === '@/utils/dict') return { DICT_TYPE: {} } if (name === '@/utils/formatTime') return { dateFormatter() {} } if (name === '@/utils/permission') return { checkPermi: () => true } if (name === '@/api/chat/private-msg') return { async page(params) { calls.push(plain(params)) return pending.length ? pending.shift().promise : { list: [message(100, { status: hidden ? 2 : 3 })], total: 30 } } } if (name.endsWith('message-moderation')) return shared if (name.endsWith('chat-utils')) return utils if (name.endsWith('.vue')) return {} throw new Error(name) } } const { descriptor } = parse(fs.readFileSync(path.join(root, 'private-msg/index.vue'), 'utf8')) const scope = vue.effectScope() scope.run(() => vm.runInNewContext( transpile(descriptor.scriptSetup.content) + '\nglobalThis.state = { getList, list, queryParams };', context ) ) const state = context.state state.queryParams.pageNum = 2 state.queryParams.sendUserName = '张三' await state.getList() hidden = true shared.notifyMessageModerated(100) for (let index = 0; index < 5; index++) await vue.nextTick() assert.equal(calls.length, 2) assert.equal(calls[1].pageNum, 2) assert.equal(calls[1].sendUserName, '张三') assert.equal(state.list.value[0].status, 2) const first = deferred(), second = deferred() pending.push(first, second) const oldRequest = state.getList(), newRequest = state.getList() second.resolve({ list: [message(100, { status: 2 })], total: 30 }) await newRequest first.resolve({ list: [message(100, { status: 3 })], total: 30 }) await oldRequest assert.equal(state.list.value[0].status, 2) unmount() scope.stop() })