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({element}) } 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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( ) expect(await screen.findByRole('heading', { name: '管理群聊' })).toBeVisible() await waitFor(() => expect(onHandled).toHaveBeenCalledTimes(1)) }) it('请求被消费后重新挂载,回到「退群事件」而不是「管理群聊」', async () => { function Harness(): React.ReactElement { const [request, setRequest] = React.useState({ view: 'manage', requestId: 1 }) const [mounted, setMounted] = React.useState(true) return ( <> {mounted ? ( setRequest(null)} /> ) : null} ) } const user = userEvent.setup() renderWorkspace() // 第一次:深链确实生效。 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() }) })