mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 05:27:48 +08:00
feat: 增加退群监控功能
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { GroupExitMonitorWorkspace } from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||
import type { GroupExitMonitorState } 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
|
||||
}
|
||||
],
|
||||
running: true,
|
||||
nativeMonitorActive: true,
|
||||
monitoredGroupCount: 3,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: ['研发群@chatroom', '设计群@chatroom'],
|
||||
notificationRoomIds: [],
|
||||
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: 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('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 persists notification targets separately', async () => {
|
||||
const user = userEvent.setup()
|
||||
const configuredState: GroupExitMonitorState = {
|
||||
...state,
|
||||
events: [],
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: ['研发群@chatroom'],
|
||||
notificationRoomIds: [],
|
||||
monitoredGroupCount: 1,
|
||||
unreadCount: 0
|
||||
}
|
||||
const savedState: GroupExitMonitorState = {
|
||||
...configuredState,
|
||||
notificationRoomIds: ['研发群@chatroom']
|
||||
}
|
||||
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('checkbox', { name: '是否通知研发群' }))
|
||||
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom'], ['研发群@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: [],
|
||||
notificationRoomIds: [],
|
||||
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('shows the notification template from the management page header', async () => {
|
||||
const user = userEvent.setup()
|
||||
window.api = {
|
||||
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
|
||||
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
||||
getPersonalWechatSendCapability: vi.fn().mockResolvedValue({
|
||||
supported: true,
|
||||
ready: false,
|
||||
status: 'needs_binding',
|
||||
capabilities: { text: false, image: false, voice: false },
|
||||
senderStatus: {} as never,
|
||||
message: '请先绑定个人微信'
|
||||
}),
|
||||
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: '管理群聊' }))
|
||||
await user.click(screen.getByRole('button', { name: '查看退群监测模板' }))
|
||||
|
||||
expect(screen.getByRole('heading', { name: '退群监测模板' })).toBeVisible()
|
||||
const template = screen.getByLabelText('退群监测模板内容')
|
||||
expect((template as HTMLTextAreaElement).value).toContain('用户: {user}')
|
||||
expect((template as HTMLTextAreaElement).value).toContain('群备注: {groupRemark}')
|
||||
})
|
||||
|
||||
it('edits and persists the notification template', async () => {
|
||||
const user = userEvent.setup()
|
||||
const savedTemplate = '[退群监测]\n用户: {user}\n群: {groupRemark}'
|
||||
const setTemplate = vi.fn().mockResolvedValue({ ...state, notificationTemplate: savedTemplate })
|
||||
window.api = {
|
||||
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
|
||||
onGroupExitMonitorState: vi.fn(() => () => undefined),
|
||||
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null),
|
||||
setGroupExitMonitorNotificationTemplate: setTemplate,
|
||||
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: '管理群聊' }))
|
||||
await user.click(screen.getByRole('button', { name: '查看退群监测模板' }))
|
||||
const template = screen.getByLabelText('退群监测模板内容')
|
||||
fireEvent.change(template, { target: { value: savedTemplate } })
|
||||
await user.click(screen.getByRole('button', { name: '保存模板' }))
|
||||
|
||||
await waitFor(() => expect(setTemplate).toHaveBeenCalledWith(savedTemplate))
|
||||
expect(screen.queryByRole('heading', { name: '退群监测模板' })).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: [],
|
||||
notificationRoomIds: [],
|
||||
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()
|
||||
})
|
||||
})
|
||||
+50
-1
@@ -117,7 +117,7 @@ test('KEY-03 changing one key does not invalidate archive data or unrelated sett
|
||||
|
||||
test('NAV-01 NAV-02 every top-level page is unique and switchable', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const labels = ['档案', '问问微信', '日报', 'Agent', '导出', 'API', '设置']
|
||||
const labels = ['档案', '问问微信', '日报', '退群监控', 'Agent', '导出', 'API', '设置']
|
||||
try {
|
||||
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
|
||||
await expect(navigation).toBeVisible()
|
||||
@@ -131,6 +131,55 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test('NAV-03 exit monitor page shows a member departure event and stays within the viewport', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page.getByRole('button', { name: '退群监控' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '退群监控', exact: true })).toBeVisible()
|
||||
await expect(fixture.page.getByText('测试成员退出了产品测试群', { exact: true })).toBeVisible()
|
||||
await expect(fixture.page.getByText(/240 人 → 239 人/)).toBeVisible()
|
||||
await expect(fixture.page.getByText('实时监听已启用')).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '立即检查' })).toBeEnabled()
|
||||
await expect(fixture.page.getByRole('button', { name: '清空记录' })).toBeEnabled()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('NAV-04 exit monitor management can save an empty scope and show the setup state', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
try {
|
||||
await fixture.page.getByRole('button', { name: '退群监控' }).click()
|
||||
await fixture.page.getByRole('button', { name: '管理群聊' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '管理群聊', exact: true })).toBeVisible()
|
||||
await expect(fixture.page.getByText('发送能力已就绪', { exact: true })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('checkbox', { name: '监控产品测试群' })).toBeChecked()
|
||||
await fixture.page.getByRole('button', { name: '查看退群监测模板' }).click()
|
||||
await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(/用户: \{user\}/)
|
||||
await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(/群备注: \{groupRemark\}/)
|
||||
const customTemplate = '[退群监测]\n用户: {user}\n群备注: {groupRemark}'
|
||||
await fixture.page.getByLabel('退群监测模板内容').fill(customTemplate)
|
||||
await fixture.page.getByRole('button', { name: '保存模板' }).click()
|
||||
await fixture.page.getByRole('button', { name: '查看退群监测模板' }).click()
|
||||
await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(customTemplate)
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await fixture.page.getByRole('checkbox', { name: '监控产品测试群' }).click()
|
||||
await fixture.page.getByRole('checkbox', { name: '监控折叠群聊样本' }).click()
|
||||
await fixture.page.getByRole('button', { name: '保存监控群聊' }).click()
|
||||
await expect(fixture.page.getByText('还没有设置监控群聊', { exact: true })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '选择群聊' })).toBeVisible()
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
|
||||
@@ -234,6 +234,98 @@ const handle = (channel, fn) => {
|
||||
ipcMain.handle(channel, async (event, ...args) => fn(...args))
|
||||
}
|
||||
|
||||
const groupExitEvent = {
|
||||
id: 'fixture-exit-event',
|
||||
contactId: 'group-regular-md5',
|
||||
roomId: 'group_regular@chatroom',
|
||||
groupName: '产品测试群',
|
||||
memberWxid: 'wxid_fixture_member',
|
||||
memberName: '测试成员',
|
||||
wechatName: '测试成员微信名',
|
||||
groupRemark: '测试成员群备注',
|
||||
contactRemark: '测试成员通讯录备注',
|
||||
previousCount: 240,
|
||||
currentCount: 239,
|
||||
delta: -1,
|
||||
message: '测试成员退出了产品测试群',
|
||||
detectedAt: fixtureNowMs - 60 * 1000
|
||||
}
|
||||
let groupExitMonitorState = {
|
||||
events: [groupExitEvent],
|
||||
running: connected,
|
||||
nativeMonitorActive: connected,
|
||||
monitoredGroupCount: 2,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: ['group_regular@chatroom', 'group_folded@chatroom'],
|
||||
notificationRoomIds: [],
|
||||
notificationTemplate:
|
||||
'[退群监测]\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n退群时间: {time}',
|
||||
lastCheckedAt: fixtureNowMs - 30 * 1000,
|
||||
lastReadAt: 0,
|
||||
unreadCount: 1
|
||||
}
|
||||
const cloneGroupExitMonitorState = () => ({
|
||||
...groupExitMonitorState,
|
||||
events: groupExitMonitorState.events.map((event) => ({ ...event })),
|
||||
unreadCount: groupExitMonitorState.events.filter(
|
||||
(event) => event.detectedAt > groupExitMonitorState.lastReadAt
|
||||
).length
|
||||
})
|
||||
handle('group-exit-monitor:getState', () => cloneGroupExitMonitorState())
|
||||
handle('group-exit-monitor:setGroups', (roomIds, notificationRoomIds) => {
|
||||
const selected = Array.isArray(roomIds)
|
||||
? roomIds.filter((roomId) => typeof roomId === 'string' && roomId.endsWith('@chatroom'))
|
||||
: []
|
||||
const notifications = Array.isArray(notificationRoomIds)
|
||||
? notificationRoomIds.filter(
|
||||
(roomId) => typeof roomId === 'string' && selected.includes(roomId)
|
||||
)
|
||||
: []
|
||||
groupExitMonitorState = {
|
||||
...groupExitMonitorState,
|
||||
monitorSelectionConfigured: true,
|
||||
monitoredRoomIds: [...new Set(selected)],
|
||||
notificationRoomIds: [...new Set(notifications)],
|
||||
monitoredGroupCount: selected.length,
|
||||
lastCheckedAt: fixtureNowMs
|
||||
}
|
||||
return cloneGroupExitMonitorState()
|
||||
})
|
||||
handle('group-exit-monitor:setTemplate', (template) => {
|
||||
if (typeof template !== 'string' || !template.trim()) throw new Error('模板不能为空')
|
||||
groupExitMonitorState = {
|
||||
...groupExitMonitorState,
|
||||
notificationTemplate: template.trim()
|
||||
}
|
||||
return cloneGroupExitMonitorState()
|
||||
})
|
||||
handle('group-exit-monitor:checkNow', () => {
|
||||
groupExitMonitorState = {
|
||||
...groupExitMonitorState,
|
||||
lastCheckedAt: fixtureNowMs
|
||||
}
|
||||
return cloneGroupExitMonitorState()
|
||||
})
|
||||
handle('group-exit-monitor:clearEvents', () => {
|
||||
groupExitMonitorState = {
|
||||
...groupExitMonitorState,
|
||||
events: [],
|
||||
lastReadAt: fixtureNowMs
|
||||
}
|
||||
return cloneGroupExitMonitorState()
|
||||
})
|
||||
handle('group-exit-monitor:markRead', (readAt) => {
|
||||
const requestedAt = Number(readAt)
|
||||
groupExitMonitorState = {
|
||||
...groupExitMonitorState,
|
||||
lastReadAt: Math.max(
|
||||
groupExitMonitorState.lastReadAt,
|
||||
Number.isFinite(requestedAt) && requestedAt > 0 ? requestedAt : fixtureNowMs
|
||||
)
|
||||
}
|
||||
return cloneGroupExitMonitorState()
|
||||
})
|
||||
|
||||
const scheduledReportTasks = []
|
||||
const scheduledReportExecutions = []
|
||||
const scheduledReportNextRun = (scheduleTime) => {
|
||||
|
||||
@@ -284,6 +284,92 @@ test('API-02 Agent target segmented control visual @visual', async () => {
|
||||
})
|
||||
|
||||
for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
test(`EXIT-01 group exit monitor page ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByRole('button', { name: '退群监控' }).click()
|
||||
await expect(
|
||||
fixture.page.getByRole('heading', { name: '退群监控', exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
fixture.page.getByText('测试成员退出了产品测试群', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(fixture.page.getByText(/240 人 → 239 人/)).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(`group-exit-monitor-${appearanceTheme}.png`, {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test(`EXIT-02 group exit monitor management ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByRole('button', { name: '退群监控' }).click()
|
||||
await fixture.page.getByRole('button', { name: '管理群聊' }).click()
|
||||
await expect(
|
||||
fixture.page.getByRole('heading', { name: '管理群聊', exact: true })
|
||||
).toBeVisible()
|
||||
await expect(fixture.page.getByRole('checkbox', { name: '监控产品测试群' })).toBeChecked()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(
|
||||
`group-exit-monitor-manage-${appearanceTheme}.png`,
|
||||
{
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test(`EXIT-03 group exit monitor setup empty ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByRole('button', { name: '退群监控' }).click()
|
||||
await fixture.page.getByRole('button', { name: '管理群聊' }).click()
|
||||
await fixture.page.getByRole('checkbox', { name: '监控产品测试群' }).click()
|
||||
await fixture.page.getByRole('checkbox', { name: '监控折叠群聊样本' }).click()
|
||||
await fixture.page.getByRole('button', { name: '保存监控群聊' }).click()
|
||||
await expect(fixture.page.getByText('还没有设置监控群聊', { exact: true })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(
|
||||
`group-exit-monitor-empty-${appearanceTheme}.png`,
|
||||
{
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test(`REPORT-00 report configuration controls ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
|
||||
@@ -67,6 +67,25 @@ describe('preload IPC contract', () => {
|
||||
await api.getAIVisionRuntimeConfig()
|
||||
expect(invoke).toHaveBeenLastCalledWith('ai:getVisionRuntimeConfig')
|
||||
|
||||
await api.getGroupExitMonitorState()
|
||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:getState')
|
||||
await api.setGroupExitMonitorGroups(['room@chatroom'])
|
||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setGroups', ['room@chatroom'])
|
||||
await api.setGroupExitMonitorGroups(['room@chatroom'], ['room@chatroom'])
|
||||
expect(invoke).toHaveBeenLastCalledWith(
|
||||
'group-exit-monitor:setGroups',
|
||||
['room@chatroom'],
|
||||
['room@chatroom']
|
||||
)
|
||||
await api.setGroupExitMonitorNotificationTemplate('用户: {user}')
|
||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setTemplate', '用户: {user}')
|
||||
await api.checkGroupExitMonitorNow()
|
||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:checkNow')
|
||||
await api.clearGroupExitMonitorEvents()
|
||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:clearEvents')
|
||||
await api.markGroupExitMonitorRead(123)
|
||||
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:markRead', 123)
|
||||
|
||||
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
|
||||
force: true,
|
||||
priority: 0
|
||||
@@ -163,4 +182,26 @@ describe('preload IPC contract', () => {
|
||||
unsubscribe()
|
||||
expect(removeListener).toHaveBeenCalledWith('wcdb-change', listener)
|
||||
})
|
||||
|
||||
it('unsubscribes the same listener registered for group exit state', async () => {
|
||||
const api = await loadApi()
|
||||
const callback = vi.fn()
|
||||
const unsubscribe = api.onGroupExitMonitorState(callback)
|
||||
expect(on).toHaveBeenCalledWith('group-exit-monitor:state', expect.any(Function))
|
||||
const listener = on.mock.calls.at(-1)?.[1]
|
||||
listener(
|
||||
{},
|
||||
{
|
||||
events: [],
|
||||
running: false,
|
||||
nativeMonitorActive: false,
|
||||
monitoredGroupCount: 0,
|
||||
lastReadAt: 0,
|
||||
unreadCount: 0
|
||||
}
|
||||
)
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
unsubscribe()
|
||||
expect(removeListener).toHaveBeenCalledWith('group-exit-monitor:state', listener)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { mkdtempSync, readJsonSync, rmSync } from 'fs-extra'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
userData: '',
|
||||
chat: {
|
||||
getChatDb: vi.fn(),
|
||||
getCurrentAccountRoot: vi.fn(() => 'fixture-account'),
|
||||
isReady: vi.fn(() => true),
|
||||
getGroupSnapshotAsync: vi.fn()
|
||||
},
|
||||
capability: {
|
||||
getPersonalWechatSendCapability: vi.fn()
|
||||
},
|
||||
sender: {
|
||||
send: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => mocks.userData },
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
vi.mock('../../src/main/services/chat-service', () => mocks.chat)
|
||||
vi.mock('../../src/main/services/personal-wechat-capability-service', () => ({
|
||||
personalWechatCapabilityService: mocks.capability
|
||||
}))
|
||||
vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
|
||||
personalWechatSendService: mocks.sender
|
||||
}))
|
||||
|
||||
import { GroupExitMonitorService } from '../../src/main/services/group-exit-monitor-service'
|
||||
|
||||
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
|
||||
|
||||
function installGroupDb(): void {
|
||||
const client = {
|
||||
getSessionsAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ username: 'room@chatroom', nickname: '测试群' }]),
|
||||
md5: vi.fn(() => 'room-md5')
|
||||
}
|
||||
mocks.chat.getChatDb.mockReturnValue({ getWcdb4Client: () => client })
|
||||
}
|
||||
|
||||
describe('GroupExitMonitorService', () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.userData = mkdtempSync(join(tmpdir(), 'tracememo-group-monitor-'))
|
||||
temporaryDirectories.push(mocks.userData)
|
||||
mocks.chat.getChatDb.mockReset()
|
||||
mocks.chat.getCurrentAccountRoot.mockReset().mockReturnValue('fixture-account')
|
||||
mocks.chat.isReady.mockReset().mockReturnValue(true)
|
||||
mocks.chat.getGroupSnapshotAsync.mockReset()
|
||||
mocks.capability.getPersonalWechatSendCapability.mockReset()
|
||||
mocks.sender.send.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (temporaryDirectories.length) {
|
||||
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('persists a validated custom template in the monitor state file', () => {
|
||||
const service = new GroupExitMonitorService()
|
||||
const template = '[退群监测]\n用户: {user}'
|
||||
|
||||
const state = service.setNotificationTemplate(template)
|
||||
expect(state.notificationTemplate).toBe(template)
|
||||
expect(readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).notificationTemplate).toBe(
|
||||
template
|
||||
)
|
||||
expect(() => service.setNotificationTemplate('用户: {unknown}')).toThrow('不支持的占位符')
|
||||
})
|
||||
|
||||
it('keeps the previous baseline when a native snapshot contains null members', async () => {
|
||||
installGroupDb()
|
||||
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
|
||||
roomId: 'room@chatroom',
|
||||
members: [member]
|
||||
})
|
||||
const service = new GroupExitMonitorService()
|
||||
service.setMonitoredRoomIds(['room@chatroom'])
|
||||
await service.start(true)
|
||||
|
||||
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
|
||||
roomId: 'room@chatroom',
|
||||
members: null
|
||||
})
|
||||
const state = await service.checkNow()
|
||||
|
||||
expect(state.events).toEqual([])
|
||||
expect(state.monitoredGroupCount).toBe(1)
|
||||
})
|
||||
|
||||
it('returns from scope saving before the background baseline read completes', async () => {
|
||||
installGroupDb()
|
||||
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
|
||||
roomId: 'room@chatroom',
|
||||
members: [member]
|
||||
})
|
||||
const service = new GroupExitMonitorService()
|
||||
service.setMonitoredRoomIds(['room@chatroom'])
|
||||
await service.start(true)
|
||||
|
||||
let release!: (snapshot: unknown) => void
|
||||
mocks.chat.getGroupSnapshotAsync.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const startedAt = Date.now()
|
||||
const state = await service.setMonitoredRoomIds(['room@chatroom'])
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(250)
|
||||
expect(state.monitoredRoomIds).toEqual(['room@chatroom'])
|
||||
release({ roomId: 'room@chatroom', members: [member] })
|
||||
})
|
||||
|
||||
it('renders the saved template before sending a group notification', async () => {
|
||||
installGroupDb()
|
||||
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
|
||||
ready: true,
|
||||
capabilities: { text: true }
|
||||
})
|
||||
mocks.sender.send.mockResolvedValue({ success: true })
|
||||
mocks.chat.getGroupSnapshotAsync
|
||||
.mockResolvedValueOnce({
|
||||
roomId: 'room@chatroom',
|
||||
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
|
||||
})
|
||||
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
|
||||
const service = new GroupExitMonitorService()
|
||||
service.setNotificationTemplate('退群: {user}/{groupRemark}/{wxid}')
|
||||
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
|
||||
await service.start(true)
|
||||
await service.checkNow()
|
||||
|
||||
expect(mocks.sender.send).toHaveBeenCalledWith({
|
||||
type: 'text',
|
||||
to: 'room@chatroom',
|
||||
isGroup: true,
|
||||
text: '退群: 另一个人/未设置/wxid_other'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findRemovedGroupMembers,
|
||||
groupExitMemberName,
|
||||
normalizeGroupExitNotificationTemplate,
|
||||
renderGroupExitMonitorNotification,
|
||||
validateGroupExitNotificationTemplate
|
||||
} from '../../src/shared/group-exit-monitor'
|
||||
|
||||
describe('group exit monitor member diff', () => {
|
||||
it('compares wxid sets and keeps the removed member metadata', () => {
|
||||
const removed = findRemovedGroupMembers(
|
||||
[
|
||||
{ wxid: 'wxid_alice', groupNickname: '小艾' },
|
||||
{ wxid: 'wxid_bob', nickname: '小博' },
|
||||
{ wxid: 'wxid_bob', nickname: '重复行' }
|
||||
],
|
||||
[{ wxid: 'wxid_alice', groupNickname: '小艾' }]
|
||||
)
|
||||
|
||||
expect(removed).toEqual([{ wxid: 'wxid_bob', nickname: '小博' }])
|
||||
})
|
||||
|
||||
it('uses group nickname before contact fallbacks', () => {
|
||||
expect(
|
||||
groupExitMemberName({
|
||||
wxid: 'wxid_fixture',
|
||||
nickname: '微信昵称',
|
||||
wechatNickname: '微信昵称字段',
|
||||
remark: '备注名',
|
||||
groupNickname: '群昵称'
|
||||
})
|
||||
).toBe('群昵称')
|
||||
expect(groupExitMemberName({ wxid: 'wxid_fixture' })).toBe('wxid_fixture')
|
||||
})
|
||||
|
||||
it('uses the WeChat nickname when a group nickname is unavailable', () => {
|
||||
expect(
|
||||
groupExitMemberName({
|
||||
wxid: 'wxid_fixture',
|
||||
wechatNickname: '微信名',
|
||||
remark: '通讯录备注'
|
||||
})
|
||||
).toBe('微信名')
|
||||
})
|
||||
|
||||
it('renders the notification template with a fallback for a missing group remark', () => {
|
||||
const message = renderGroupExitMonitorNotification({
|
||||
id: 'event-1',
|
||||
contactId: 'group-md5',
|
||||
roomId: '测试群@chatroom',
|
||||
groupName: '测试群',
|
||||
memberWxid: 'wxid_fixture',
|
||||
memberName: '群昵称',
|
||||
wechatName: '微信名',
|
||||
groupRemark: '',
|
||||
previousCount: 240,
|
||||
currentCount: 239,
|
||||
delta: -1,
|
||||
message: '群昵称退出了测试群',
|
||||
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
|
||||
})
|
||||
|
||||
expect(message).toContain('[退群监测]')
|
||||
expect(message).toContain('用户: 微信名')
|
||||
expect(message).toContain('群备注: 未设置')
|
||||
expect(message).toContain('微信号: wxid_fixture')
|
||||
expect(message).toContain('退群时间: 2026-08-31 00:19:07')
|
||||
})
|
||||
|
||||
it('validates custom notification templates and rejects unknown placeholders', () => {
|
||||
expect(validateGroupExitNotificationTemplate(' 用户: {user} ')).toEqual({
|
||||
valid: true,
|
||||
template: '用户: {user}'
|
||||
})
|
||||
expect(validateGroupExitNotificationTemplate('用户: {unknown}')).toEqual({
|
||||
valid: false,
|
||||
error: '不支持的占位符: unknown'
|
||||
})
|
||||
expect(normalizeGroupExitNotificationTemplate('')).toContain('[退群监测]')
|
||||
})
|
||||
|
||||
it('renders a saved custom template with all supported fields', () => {
|
||||
const message = renderGroupExitMonitorNotification(
|
||||
{
|
||||
id: 'event-2',
|
||||
contactId: 'group-md5',
|
||||
roomId: '测试群@chatroom',
|
||||
groupName: '测试群',
|
||||
memberWxid: 'wxid_fixture',
|
||||
memberName: '群昵称',
|
||||
wechatName: '微信名',
|
||||
groupRemark: '群内昵称',
|
||||
previousCount: 2,
|
||||
currentCount: 1,
|
||||
delta: -1,
|
||||
message: '群昵称退出了测试群',
|
||||
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
|
||||
},
|
||||
'{user}|{groupRemark}|{wxid}|{time}'
|
||||
)
|
||||
expect(message).toBe('微信名|群内昵称|wxid_fixture|2026-08-31 00:19:07')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user