Files
WechatExplorer/tests/component/group-exit-monitor-workspace.test.tsx
T
电摇小子 4d5c6459fe feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
2026-09-28 17:11:50 +07:00

453 lines
18 KiB
TypeScript

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
GroupExitMonitorWorkspace,
type GroupExitMonitorOpenViewRequest
} from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
import type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor'
import { formatGroupExitMonitorTime } from '../../src/shared/group-exit-monitor'
import type { Contact } from '../../src/shared/types'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
const state: GroupExitMonitorState = {
events: [
{
id: 'event-1',
contactId: 'group-md5',
roomId: '研发群@chatroom',
groupName: '研发群',
memberWxid: 'wxid_alice',
memberName: '小艾',
wechatName: '小艾微信名',
groupRemark: '小艾群备注',
contactRemark: '小艾通讯录备注',
previousCount: 240,
currentCount: 239,
delta: -1,
message: '小艾退出了研发群',
detectedAt: 1_756_600_000_000
},
{
id: 'event-2',
contactId: 'group-design-md5',
roomId: '设计群@chatroom',
groupName: '设计群',
memberWxid: 'wxid_bob',
memberName: '小博',
wechatName: '小博微信名',
groupRemark: '',
previousCount: 80,
currentCount: 79,
delta: -1,
message: '小博退出了设计群',
detectedAt: 1_756_600_100_000
}
],
enabled: true,
running: true,
nativeMonitorActive: true,
monitoredGroupCount: 3,
monitorSelectionConfigured: true,
monitoredRoomIds: ['研发群@chatroom', '设计群@chatroom'],
lastCheckedAt: 1_756_600_000_000,
lastReadAt: 0,
unreadCount: 1
}
const groups: Contact[] = [
{
m_nsUsrName: '研发群@chatroom',
m_nsNickName: '研发群',
md5: 'group-md5',
type: 'group'
},
{
m_nsUsrName: '设计群@chatroom',
m_nsNickName: '设计群',
md5: 'group-design-md5',
type: 'group'
}
]
describe('GroupExitMonitorWorkspace', () => {
const renderWorkspace = (element: React.ReactElement): void => {
render(<TooltipProvider>{element}</TooltipProvider>)
}
beforeEach(() => {
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue({ ...state, events: [] })
} as typeof window.api
})
it('renders a semantic exit notification and member count delta', async () => {
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
await waitFor(() => expect(screen.getByText('小艾退出了研发群')).toBeVisible())
expect(screen.getByText(/240 人 → 239 人/)).toBeVisible()
expect(screen.getByText('小艾微信名')).toBeVisible()
expect(screen.getByText('小艾群备注')).toBeVisible()
expect(screen.getByText('小艾通讯录备注')).toBeVisible()
expect(screen.getByText('wxid_alice')).toBeVisible()
expect(screen.getByText('实时监听已启用')).toBeVisible()
})
it('persists the master switch and shows the paused state', async () => {
const user = userEvent.setup()
const pausedState = { ...state, enabled: false, running: false }
const setEnabled = vi.fn().mockResolvedValue(pausedState)
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
setGroupExitMonitorEnabled: setEnabled,
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
const masterSwitch = await screen.findByRole('switch', { name: '退群监控总开关' })
expect(masterSwitch).toBeChecked()
await user.click(masterSwitch)
await waitFor(() => expect(setEnabled).toHaveBeenCalledWith(false))
expect(screen.getByText('监控已暂停')).toBeVisible()
expect(screen.getByRole('button', { name: '立即检查' })).toBeDisabled()
})
it('filters to groups that have exit events and clears the history', async () => {
const user = userEvent.setup()
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
await waitFor(() => expect(screen.getByText('小博退出了设计群')).toBeVisible())
await user.click(screen.getByRole('combobox', { name: '筛选退群群聊' }))
await user.click(await screen.findByRole('option', { name: /研发群/ }))
expect(screen.getByText('小艾退出了研发群')).toBeVisible()
expect(screen.queryByText('小博退出了设计群')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '清空记录' }))
await waitFor(() => expect(screen.queryByText('小艾退出了研发群')).not.toBeInTheDocument())
expect(screen.getByText('暂无退群记录')).toBeVisible()
})
it('opens the management view, selects a group, and saves the monitoring scope', async () => {
const user = userEvent.setup()
const configuredState: GroupExitMonitorState = {
...state,
events: [],
monitorSelectionConfigured: true,
monitoredRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
const savedState: GroupExitMonitorState = {
...configuredState,
monitoredRoomIds: ['研发群@chatroom'],
monitoredGroupCount: 1
}
const setGroups = vi.fn().mockResolvedValue(savedState)
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(configuredState),
onGroupExitMonitorState: vi.fn(() => () => undefined),
setGroupExitMonitorGroups: setGroups,
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(savedState),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(savedState)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await waitFor(() => expect(screen.getByText('还没有设置监控群聊')).toBeVisible())
await user.click(screen.getByRole('button', { name: '选择群聊' }))
expect(screen.getByRole('heading', { name: '管理群聊' })).toBeVisible()
await user.click(screen.getByRole('checkbox', { name: '监控研发群' }))
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom']))
expect(screen.getByRole('heading', { name: '退群监控' })).toBeVisible()
expect(screen.getByText('已监控群聊')).toBeVisible()
})
it('filters group rows by selection and saves the monitoring scope', async () => {
const user = userEvent.setup()
const configuredState: GroupExitMonitorState = {
...state,
events: [],
monitorSelectionConfigured: true,
monitoredRoomIds: ['研发群@chatroom'],
monitoredGroupCount: 1,
unreadCount: 0
}
const savedState: GroupExitMonitorState = {
...configuredState,
}
const setGroups = vi.fn().mockResolvedValue(savedState)
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(configuredState),
onGroupExitMonitorState: vi.fn(() => () => undefined),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue({
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: true },
senderStatus: {} as never,
message: '个人微信已准备好发送日报'
}),
setGroupExitMonitorGroups: setGroups,
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(savedState),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(savedState)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
await user.click(screen.getByRole('combobox', { name: '筛选群聊状态' }))
await user.click(await screen.findByRole('option', { name: '未选择' }))
expect(screen.getByRole('checkbox', { name: '监控设计群' })).toBeVisible()
expect(screen.queryByRole('checkbox', { name: '监控研发群' })).not.toBeInTheDocument()
await user.click(screen.getByRole('combobox', { name: '筛选群聊状态' }))
await user.click(await screen.findByRole('option', { name: '全部' }))
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom']))
})
it('selects every group even when a search term is active', async () => {
const user = userEvent.setup()
const configuredState: GroupExitMonitorState = {
...state,
events: [],
monitorSelectionConfigured: true,
monitoredRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
const setGroups = vi.fn().mockResolvedValue({ ...configuredState, monitoredGroupCount: 2 })
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(configuredState),
onGroupExitMonitorState: vi.fn(() => () => undefined),
setGroupExitMonitorGroups: setGroups,
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(configuredState),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(configuredState)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '选择群聊' }))
await user.type(screen.getByRole('textbox', { name: '搜索群聊' }), '研发')
await user.click(screen.getByRole('button', { name: '全选' }))
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() =>
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom', '设计群@chatroom'])
)
})
it('shows the send capability and explains an unavailable state on hover', async () => {
const user = userEvent.setup()
const capability = {
supported: true,
ready: false,
status: 'needs_binding' as const,
capabilities: { text: false, image: false, voice: false },
senderStatus: {} as never,
message: '请先绑定个人微信'
}
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(capability),
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(state),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
const status = screen.getByText('发送能力未就绪', { exact: true })
expect(status).toHaveClass('unready')
await user.hover(status)
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent('请先绑定个人微信'))
})
/*
* 迁移后这里**不再有模板入口与「通知群聊」列**:
* 模板的唯一编辑处是「自动化 → 规则 → 退群通知」。
* 退群监控只负责监控范围与退群事实。
*/
it('管理群聊页不再提供通知模板与通知群聊配置', async () => {
const user = userEvent.setup()
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null),
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(state),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
expect(screen.queryByRole('button', { name: '查看退群监测模板' })).toBeNull()
expect(screen.queryByRole('heading', { name: '退群监测模板' })).toBeNull()
expect(screen.queryByText('通知群聊')).toBeNull()
expect(screen.queryByLabelText('退群监测模板内容')).toBeNull()
// 监控选择本身仍然可用。
expect(screen.getByRole('checkbox', { name: '监控研发群' })).toBeInTheDocument()
})
it('复制退群信息用的模板来自自动化规则,而不是退群监控自己存的', async () => {
const user = userEvent.setup()
const template = [
'[退群监测]',
'用户: {user}',
'群备注: {groupRemark}',
'微信号: {wxid}',
'人数: {previousCount}->{currentCount}',
'退群时间: {time}'
].join('\n')
const copyText = vi.fn().mockResolvedValue({ success: true })
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
copyText,
// 模板唯一来源:自动化里的退群通知规则。
listAutomationRules: vi.fn().mockResolvedValue([
{
id: 'builtin-leave-notification',
name: '退群通知',
enabled: true,
ruleType: 'leave_notification',
leaveNotification: { target: { type: 'file_transfer' }, template }
}
]),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
await user.click(
await screen.findByRole('button', { name: '复制退群信息:小艾退出了研发群' })
)
await waitFor(() => expect(copyText).toHaveBeenCalledTimes(1))
expect(copyText).toHaveBeenCalledWith(
[
'[退群监测]',
'用户: 小艾微信名',
'群备注: 小艾群备注',
'微信号: wxid_alice',
'人数: 240->239',
`退群时间: ${formatGroupExitMonitorTime(state.events[0].detectedAt)}`
].join('\n')
)
expect(await screen.findByText('已复制')).toBeVisible()
})
it('reports a clipboard failure and keeps the event list intact', async () => {
const user = userEvent.setup()
const copyText = vi.fn().mockResolvedValue({ success: false, error: '剪贴板不可用' })
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
copyText,
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
await user.click(
await screen.findByRole('button', { name: '复制退群信息:小艾退出了研发群' })
)
expect(await screen.findByText('剪贴板不可用')).toBeVisible()
expect(screen.getByText('小艾退出了研发群')).toBeVisible()
expect(screen.queryByText('已复制')).not.toBeInTheDocument()
})
it('leaves every monitoring checkbox off for a new installation', async () => {
const user = userEvent.setup()
const newInstallState: GroupExitMonitorState = {
...state,
events: [],
monitorSelectionConfigured: false,
monitoredRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(newInstallState),
onGroupExitMonitorState: vi.fn(() => () => undefined),
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(newInstallState),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(newInstallState),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(newInstallState)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
expect(screen.getByRole('checkbox', { name: '监控研发群' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: '监控设计群' })).not.toBeChecked()
})
/*
* 深链请求必须是**一次性**的。
*
* 真实拓扑里请求挂在 App,而本组件随一级菜单切换被卸载/重挂。
* 修复前 effect 只 `setView` 不回报父层,请求永远不清 —— 于是重挂时
* 那条陈旧的 `{view:'manage'}` 被重放,用户看到的现象就是
* "点一级菜单退群监控,却跳到了管理群聊"。
*/
it('深链落到「管理群聊」,并立刻回报父层消费掉请求', async () => {
const onHandled = vi.fn()
renderWorkspace(
<GroupExitMonitorWorkspace
dbReady
openViewRequest={{ view: 'manage', requestId: 1 }}
onOpenViewRequestHandled={onHandled}
/>
)
expect(await screen.findByRole('heading', { name: '管理群聊' })).toBeVisible()
await waitFor(() => expect(onHandled).toHaveBeenCalledTimes(1))
})
it('请求被消费后重新挂载,回到「退群事件」而不是「管理群聊」', async () => {
function Harness(): React.ReactElement {
const [request, setRequest] = React.useState<GroupExitMonitorOpenViewRequest | null>({
view: 'manage',
requestId: 1
})
const [mounted, setMounted] = React.useState(true)
return (
<>
<button type="button" onClick={() => setMounted((prev) => !prev)}>
切换页面
</button>
{mounted ? (
<GroupExitMonitorWorkspace
dbReady
openViewRequest={request}
onOpenViewRequestHandled={() => setRequest(null)}
/>
) : null}
</>
)
}
const user = userEvent.setup()
renderWorkspace(<Harness />)
// 第一次:深链确实生效。
expect(await screen.findByRole('heading', { name: '管理群聊' })).toBeVisible()
// 离开再回来(等价于点别的菜单再点「退群监控」)。
await user.click(screen.getByRole('button', { name: '切换页面' }))
await user.click(screen.getByRole('button', { name: '切换页面' }))
expect(await screen.findByText('小艾退出了研发群')).toBeVisible()
expect(screen.queryByRole('heading', { name: '管理群聊' })).toBeNull()
})
})