feat: 增加退群监控功能

This commit is contained in:
Wxw-Gu
2026-08-31 21:06:53 +08:00
parent 6cbf9af662
commit e59332b514
22 changed files with 3321 additions and 30 deletions
@@ -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
View File
@@ -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[] = []
+92
View File
@@ -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) => {
+86
View File
@@ -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'
})
})
})
+104
View File
@@ -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')
})
})