feat: 退群监控接入自动化能力统一管理, 支持发送个人/文件传输助手/指定群聊

This commit is contained in:
Wxw-Gu
2026-09-24 16:31:07 +08:00
parent 3bd3935042
commit 59872daf52
43 changed files with 5661 additions and 1132 deletions
@@ -0,0 +1,300 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ToastProvider } from '../../src/renderer/src/components/ui'
import {
AutomationWorkspace,
type AutomationOpenRuleRequest
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
import {
BUILTIN_DAILY_REPORT_RULE_ID,
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule,
type AutomationRule
} from '../../src/shared/automation'
/**
* 规则类型切换的**目标归属**回归。
*
* 真实事故:从「退群通知」卡片进编辑器(编辑器内部记的是"没有具体目标"),
* 切到「@我生成日报」Tab 后保存 —— 结果改掉了**内置日报**(用户看到的就是
* "我编辑退群通知,保存却改到了另一条日报上")。
*
* 根因两条,都要锁死:
* 1. 切换类型时继承了上一个类型的 `mode`,留下 `mode='edit'` 却没有目标的中间态;
* 2. 编辑目标**不可见** —— 表单不显示它在改哪条规则。
*/
const DAILY_NAME = '我的日报'
function installApi(): {
rules: AutomationRule[]
updateAutomationRule: ReturnType<typeof vi.fn>
createAutomationRule: ReturnType<typeof vi.fn>
saveLeaveNotificationRule: ReturnType<typeof vi.fn>
} {
const renamedDaily: AutomationRule = {
...createDefaultDailyReportRule(Date.now()),
name: DAILY_NAME
}
const leaveRule = createDefaultLeaveNotificationRule(Date.now())
const rules = [renamedDaily, leaveRule]
const updateAutomationRule = vi.fn(async (id: string, draft: AutomationRule) => ({
...renamedDaily,
...draft,
id
}))
const createAutomationRule = vi.fn(async (draft: AutomationRule) => ({
...renamedDaily,
...draft,
id: 'created-rule'
}))
const saveLeaveNotificationRule = vi.fn(async (draft: AutomationRule) => draft)
window.api = {
getAutomationStatus: vi.fn().mockResolvedValue({
listening: true,
listeningDegraded: false,
todayExecutions: 0,
todaySuccesses: 0,
sendCapability: {
supported: true,
ready: true,
canSendText: true,
canSendImage: true,
message: 'ready'
}
}),
listAutomationRules: vi.fn().mockResolvedValue(rules),
listAutomationGroups: vi.fn().mockResolvedValue([]),
listAutomationExecutions: vi.fn().mockResolvedValue([]),
listSendableContacts: vi.fn().mockResolvedValue([]),
getGroupExitMonitorState: vi.fn().mockResolvedValue({
events: [],
enabled: true,
running: true,
nativeMonitorActive: true,
monitoredGroupCount: 3,
monitorSelectionConfigured: true,
monitoredRoomIds: [],
lastReadAt: 0,
unreadCount: 0
}),
onGroupExitMonitorState: vi.fn(() => () => undefined),
updateAutomationRule,
createAutomationRule,
saveLeaveNotificationRule
} as unknown as typeof window.api
return { rules, updateAutomationRule, createAutomationRule, saveLeaveNotificationRule }
}
const renderWorkspace = (): void => {
render(
<ToastProvider>
<AutomationWorkspace dbReady onOpenExitMonitorGroups={() => {}} />
</ToastProvider>
)
}
const editingTarget = (): string =>
screen.getByTestId('automation-editing-target').textContent ?? ''
const leaveCard = async (): Promise<HTMLElement> =>
(await screen.findByText('退群通知')).closest('article') as HTMLElement
describe('规则类型切换 · 编辑目标归属', () => {
it('从退群通知进编辑器时,「正在编辑」显示退群通知', async () => {
const user = userEvent.setup()
installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
expect(editingTarget()).toContain('退群通知')
})
/*
* 事故核心:切到日报后,界面必须**明说**现在改的是哪条。
* 以前这里什么都不显示,用户以为还在改退群通知。
*/
it('切到日报后,「正在编辑」显示那条日报的名字(不再隐式指向未知目标)', async () => {
const user = userEvent.setup()
installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
expect(editingTarget()).toContain(DAILY_NAME)
expect(editingTarget()).not.toContain('退群通知')
})
it('切到日报后保存:只更新那一条日报,退群通知与新建都不受影响', async () => {
const user = userEvent.setup()
const api = installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.updateAutomationRule).toHaveBeenCalledTimes(1))
// 必须更新"屏幕上显示的那条"。
expect(api.updateAutomationRule.mock.calls[0][0]).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
// 绝不能顺手新建一条重复规则,也不能动退群通知。
expect(api.createAutomationRule).not.toHaveBeenCalled()
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
it('从日报卡片进入并保存:更新日报,且不会误存到退群通知', async () => {
const user = userEvent.setup()
const api = installApi()
renderWorkspace()
const dailyCard = (await screen.findByText(DAILY_NAME)).closest('article') as HTMLElement
await user.click(within(dailyCard).getByRole('button', { name: '编辑' }))
expect(editingTarget()).toContain(DAILY_NAME)
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.updateAutomationRule).toHaveBeenCalledTimes(1))
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
it('从日报切到退群通知后保存:走退群通知通道,不改日报', async () => {
const user = userEvent.setup()
const api = installApi()
renderWorkspace()
const dailyCard = (await screen.findByText(DAILY_NAME)).closest('article') as HTMLElement
await user.click(within(dailyCard).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('radio', { name: '退群通知' }))
expect(editingTarget()).toContain('退群通知')
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.saveLeaveNotificationRule).toHaveBeenCalledTimes(1))
expect(api.updateAutomationRule).not.toHaveBeenCalled()
})
it('内置日报不存在时切到日报:显示「新建自动化」,保存走新建而不是更新', async () => {
const user = userEvent.setup()
const api = installApi()
// 只剩退群通知(等价于用户把内置日报删掉了)。
;(window.api.listAutomationRules as ReturnType<typeof vi.fn>).mockResolvedValue([
createDefaultLeaveNotificationRule(Date.now())
])
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
expect(editingTarget()).toContain('新建自动化')
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.createAutomationRule).toHaveBeenCalledTimes(1))
expect(api.updateAutomationRule).not.toHaveBeenCalled()
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
/*
* 日报可以有多条。切走再切回时必须回到**刚才那条**,
* 否则用户会在不知情的情况下被带到内置日报上(等于换个方式误改规则)。
*/
it('从别的日报进入,切走再切回仍指向那条日报,不会被换回内置日报', async () => {
const user = userEvent.setup()
const api = installApi()
const OTHER_NAME = '我的另一条日报'
;(window.api.listAutomationRules as ReturnType<typeof vi.fn>).mockResolvedValue([
{ ...createDefaultDailyReportRule(Date.now()), name: DAILY_NAME },
{
...createDefaultDailyReportRule(Date.now()),
id: 'custom-daily',
name: OTHER_NAME
},
createDefaultLeaveNotificationRule(Date.now())
])
renderWorkspace()
const otherCard = (await screen.findByText(OTHER_NAME)).closest('article') as HTMLElement
await user.click(within(otherCard).getByRole('button', { name: '编辑' }))
expect(editingTarget()).toContain(OTHER_NAME)
await user.click(screen.getByRole('radio', { name: '退群通知' }))
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
expect(editingTarget()).toContain(OTHER_NAME)
expect(editingTarget()).not.toContain(DAILY_NAME)
// 保存也必须落在这一条上。
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(api.updateAutomationRule).toHaveBeenCalledTimes(1))
expect(api.updateAutomationRule.mock.calls[0][0]).toBe('custom-daily')
})
/*
* 同类 bug 的第二处:`mode === 'edit' || !target ? createRule : updateRule` ——
* "编辑态但没有目标"被静默当成"新建",用户以为在改 A,结果多出一条新规则。
* 现在必须明确报错,且不能给 User 一个看起来能保存的空表单。
*/
it('编辑态但目标规则已不存在:不给新建入口,明确提示', async () => {
const api = installApi()
// 内置日报不存在(等价于用户把它删掉了)。
;(window.api.listAutomationRules as ReturnType<typeof vi.fn>).mockResolvedValue([
createDefaultLeaveNotificationRule(Date.now())
])
function DeepLinkHarness(): React.ReactElement {
const [request] = React.useState<AutomationOpenRuleRequest>({
ruleType: 'daily_report',
requestId: 1
})
return <AutomationWorkspace dbReady openRuleRequest={request} />
}
render(
<ToastProvider>
<DeepLinkHarness />
</ToastProvider>
)
expect(await screen.findByText(/要编辑的规则已不存在/)).toBeVisible()
// 关键:没有可点的「保存」——否则用户点下去就会凭空新建一条规则。
expect(screen.queryByRole('button', { name: '保存' })).toBeNull()
expect(api.createAutomationRule).not.toHaveBeenCalled()
expect(api.updateAutomationRule).not.toHaveBeenCalled()
})
it('切类型不会静默新建出重复规则', async () => {
const user = userEvent.setup()
const api = installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
for (const label of ['@我生成日报', '退群通知', '@我生成日报', '退群通知']) {
await user.click(screen.getByRole('radio', { name: label }))
}
expect(api.createAutomationRule).not.toHaveBeenCalled()
expect(api.updateAutomationRule).not.toHaveBeenCalled()
expect(api.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
it('Tab 文案说明它是「切换规则类型」,不是改当前规则的类型', async () => {
const user = userEvent.setup()
installApi()
renderWorkspace()
await user.click(within(await leaveCard()).getByRole('button', { name: '编辑' }))
expect(screen.getByText('切换规则类型')).toBeVisible()
expect(
screen.getByRole('radiogroup', { name: '切换规则类型' }) as HTMLElement
).toBeInTheDocument()
})
})
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { 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'
@@ -48,7 +48,6 @@ const state: GroupExitMonitorState = {
monitoredGroupCount: 3,
monitorSelectionConfigured: true,
monitoredRoomIds: ['研发群@chatroom', '设计群@chatroom'],
notificationRoomIds: [],
lastCheckedAt: 1_756_600_000_000,
lastReadAt: 0,
unreadCount: 1
@@ -163,25 +162,23 @@ describe('GroupExitMonitorWorkspace', () => {
await user.click(screen.getByRole('checkbox', { name: '监控研发群' }))
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom'], []))
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 () => {
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'],
notificationRoomIds: [],
monitoredGroupCount: 1,
unreadCount: 0
}
const savedState: GroupExitMonitorState = {
...configuredState,
notificationRoomIds: ['研发群@chatroom']
}
const setGroups = vi.fn().mockResolvedValue(savedState)
window.api = {
@@ -209,12 +206,9 @@ describe('GroupExitMonitorWorkspace', () => {
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'])
)
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom']))
})
it('selects every group even when a search term is active', async () => {
@@ -224,7 +218,6 @@ describe('GroupExitMonitorWorkspace', () => {
events: [],
monitorSelectionConfigured: true,
monitoredRoomIds: [],
notificationRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
@@ -244,7 +237,7 @@ describe('GroupExitMonitorWorkspace', () => {
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() =>
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom', '设计群@chatroom'], [])
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom', '设计群@chatroom'])
)
})
@@ -275,43 +268,17 @@ describe('GroupExitMonitorWorkspace', () => {
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent('请先绑定个人微信'))
})
it('shows the notification template from the management page header', async () => {
/*
* 迁移后这里**不再有模板入口与「通知群聊」列**:
* 模板的唯一编辑处是「自动化 → 规则 → 退群通知」。
* 退群监控只负责监控范围与退群事实。
*/
it('管理群聊页不再提供通知模板与通知群聊配置', 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)
@@ -319,16 +286,16 @@ describe('GroupExitMonitorWorkspace', () => {
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()
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('copies the exit notice built from the template saved on the management page', async () => {
it('复制退群信息用的模板来自自动化规则,而不是退群监控自己存的', async () => {
const user = userEvent.setup()
const template = [
'[退群监测]',
@@ -340,11 +307,19 @@ describe('GroupExitMonitorWorkspace', () => {
].join('\n')
const copyText = vi.fn().mockResolvedValue({ success: true })
window.api = {
getGroupExitMonitorState: vi
.fn()
.mockResolvedValue({ ...state, notificationTemplate: template }),
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
@@ -396,7 +371,6 @@ describe('GroupExitMonitorWorkspace', () => {
events: [],
monitorSelectionConfigured: false,
monitoredRoomIds: [],
notificationRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
@@ -0,0 +1,665 @@
import React from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ToastProvider, TooltipProvider } from '../../src/renderer/src/components/ui'
import {
AutomationWorkspace,
type AutomationOpenRuleRequest
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
import { GroupExitMonitorWorkspace } from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
import {
BUILTIN_LEAVE_NOTIFICATION_RULE_ID,
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule,
type AutomationRule
} from '../../src/shared/automation'
import type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor'
/**
* 「自动化 → 规则 → 退群通知」现在是**真实配置页**。
*
* 这组测试锁的是「真实化」之后的契约:
* - 已监控群聊数量来自退群监控(不是硬编码的 243);
* - 好友清单来自 main 侧的可发送联系人接口(不是 mock 的张三李四);
* - 保存真的会调用 singleton upsert;
* - **效果预览永远不发送**;
* - 退群监控里旧的通知群聊列与模板入口已经不存在。
*/
const MONITOR_STATE: GroupExitMonitorState = {
events: [],
enabled: true,
running: true,
nativeMonitorActive: true,
monitoredGroupCount: 7,
monitorSelectionConfigured: true,
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
lastReadAt: 0,
unreadCount: 0
}
const SENDABLE_CONTACTS = [
{ id: 'wxid_friend_a', name: '好友甲' },
{ id: 'wxid_friend_b', name: '好友乙' }
]
interface ApiSpies {
saveLeaveNotificationRule: ReturnType<typeof vi.fn>
createAutomationRule: ReturnType<typeof vi.fn>
updateAutomationRule: ReturnType<typeof vi.fn>
deleteAutomationRule: ReturnType<typeof vi.fn>
setAutomationRuleEnabled: ReturnType<typeof vi.fn>
setGroupExitMonitorGroups: ReturnType<typeof vi.fn>
listSendableContacts: ReturnType<typeof vi.fn>
listAutomationRules: ReturnType<typeof vi.fn>
}
function installApi(
overrides: { leaveRule?: AutomationRule } = {}
): { spies: ApiSpies; rules: AutomationRule[] } {
const leaveRule = overrides.leaveRule ?? createDefaultLeaveNotificationRule(Date.now())
const rules = [createDefaultDailyReportRule(Date.now()), leaveRule]
const spies: ApiSpies = {
saveLeaveNotificationRule: vi.fn(async (draft: AutomationRule) => ({
...leaveRule,
...draft,
id: BUILTIN_LEAVE_NOTIFICATION_RULE_ID
})),
createAutomationRule: vi.fn().mockResolvedValue(null),
updateAutomationRule: vi.fn().mockResolvedValue(null),
deleteAutomationRule: vi.fn().mockResolvedValue(false),
setAutomationRuleEnabled: vi.fn().mockResolvedValue(null),
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(MONITOR_STATE),
listSendableContacts: vi.fn().mockResolvedValue(SENDABLE_CONTACTS),
listAutomationRules: vi.fn().mockResolvedValue(rules)
}
window.api = {
getAutomationStatus: vi.fn().mockResolvedValue({
listening: true,
listeningDegraded: false,
todayExecutions: 0,
todaySuccesses: 0,
sendCapability: {
supported: true,
ready: true,
canSendText: true,
canSendImage: true,
message: '个人微信发送能力已就绪'
}
}),
listAutomationRules: spies.listAutomationRules,
listAutomationGroups: vi.fn().mockResolvedValue([]),
listAutomationExecutions: vi.fn().mockResolvedValue([]),
listSendableContacts: spies.listSendableContacts,
getGroupExitMonitorState: vi.fn().mockResolvedValue(MONITOR_STATE),
onGroupExitMonitorState: vi.fn(() => () => undefined),
saveLeaveNotificationRule: spies.saveLeaveNotificationRule,
createAutomationRule: spies.createAutomationRule,
updateAutomationRule: spies.updateAutomationRule,
deleteAutomationRule: spies.deleteAutomationRule,
setAutomationRuleEnabled: spies.setAutomationRuleEnabled,
setGroupExitMonitorGroups: spies.setGroupExitMonitorGroups
} as unknown as typeof window.api
return { spies, rules }
}
const renderWithProviders = (element: ReactElement): void => {
render(<ToastProvider>{element}</ToastProvider>)
}
const renderMonitorWithProviders = (element: ReactElement): void => {
render(
<TooltipProvider>
<ToastProvider>{element}</ToastProvider>
</TooltipProvider>
)
}
/** 找到首页那张「退群通知」卡片(现在有两张卡,都有「编辑」)。 */
async function leaveNotificationCard(): Promise<HTMLElement> {
return (await screen.findByText('退群通知')).closest('article') as HTMLElement
}
/** 从自动化首页那张退群通知卡片的「编辑」进编辑器。 */
async function openLeaveNotificationEditor(overrides: {
leaveRule?: AutomationRule
} = {}): Promise<{ spies: ApiSpies }> {
const user = userEvent.setup()
const { spies } = installApi(overrides)
renderWithProviders(<AutomationWorkspace dbReady onOpenExitMonitorGroups={() => {}} />)
const card = await leaveNotificationCard()
await user.click(within(card).getByRole('button', { name: '编辑' }))
return { spies }
}
const previewRecipient = (): string =>
screen.getByTestId('leave-preview-recipient').textContent ?? ''
const previewFact = (label: string): string => {
const rows = Array.from(document.querySelectorAll('.automation-preview-facts > div'))
for (const row of rows) {
if (row.querySelector('dt')?.textContent === label) {
return row.querySelector('dd')?.textContent ?? ''
}
}
return ''
}
const targetRadio = (label: string): HTMLElement => screen.getByRole('radio', { name: label })
describe('自动化 · 规则类型 Tab', () => {
it('两种规则类型都是真规则,切换互不影响', async () => {
const user = userEvent.setup()
installApi()
renderWithProviders(<AutomationWorkspace dbReady />)
await screen.findByText('退群通知')
await user.click(screen.getAllByRole('button', { name: '新建自动化' })[0])
expect(screen.getByLabelText('关键词')).toBeInTheDocument()
await user.click(screen.getByRole('radio', { name: '退群通知' }))
expect(screen.getByLabelText('退群通知模板内容')).toBeInTheDocument()
expect(screen.queryByLabelText('关键词')).toBeNull()
await user.click(screen.getByRole('radio', { name: '@我生成日报' }))
expect(screen.getByLabelText('关键词')).toBeInTheDocument()
})
it('首页卡片显示真实状态与真实目标,不再有「待配置 / UI 预览」', async () => {
installApi()
renderWithProviders(<AutomationWorkspace dbReady />)
await screen.findByText('退群通知')
const card = screen.getByText('退群通知').closest('article') as HTMLElement
expect(card.textContent).toContain('运行中')
expect(card.textContent).toContain('当前群聊')
expect(card.textContent).toContain('3 个已监控群聊')
expect(document.body.textContent).not.toContain('待配置')
expect(document.body.textContent).not.toContain('UI 预览')
})
it('迁移待重选的规则在首页显示为「配置异常」', async () => {
installApi({
leaveRule: createDefaultLeaveNotificationRule(Date.now(), {
enabled: false,
targetNeedsReview: true
})
})
renderWithProviders(<AutomationWorkspace dbReady />)
const card = (await screen.findByText('退群通知')).closest('article') as HTMLElement
expect(card.textContent).toContain('配置异常')
})
})
describe('退群通知 · 真实数据', () => {
it('已监控群聊数量来自退群监控', async () => {
await openLeaveNotificationEditor()
expect(await screen.findByText('3 个群聊')).toBeVisible()
expect(screen.queryByText('243 个群聊')).toBeNull()
})
it('好友清单来自 main 侧的可发送联系人接口', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
await user.click(targetRadio('指定好友'))
expect(screen.getByRole('radio', { name: '好友甲' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '好友乙' })).toBeInTheDocument()
expect(screen.queryByRole('radio', { name: '张三' })).toBeNull()
})
it('Section 1 没有「触发范围」,只有唯一的「管理监控群聊」入口', async () => {
await openLeaveNotificationEditor()
expect(screen.queryByText('触发范围')).toBeNull()
expect(screen.queryByText('全部已监控群聊')).toBeNull()
expect(screen.queryByText('指定已监控群聊')).toBeNull()
expect(screen.getByRole('button', { name: /管理监控群聊/ })).toBeEnabled()
})
it('规则级启停只有一个开关,没有第二个「发送退群通知」', async () => {
await openLeaveNotificationEditor()
expect(screen.getAllByRole('switch')).toHaveLength(1)
expect(screen.getByRole('switch', { name: '启用这条自动化' })).toBeVisible()
expect(screen.queryByText('发送退群通知')).toBeNull()
})
})
describe('退群通知 · 四种目标', () => {
it('是四选一,「当前群聊」排第一且默认选中', async () => {
await openLeaveNotificationEditor()
const order = Array.from(
document.querySelectorAll('.automation-leave-targets .automation-leave-radio label')
).map((label) => label.textContent?.trim())
expect(order).toEqual(['当前群聊', '文件传输助手', '发给自己', '指定好友'])
expect(targetRadio('当前群聊')).toBeChecked()
})
it('只有「指定好友」才展开好友选择器', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
for (const label of ['文件传输助手', '当前群聊', '发给自己']) {
await user.click(targetRadio(label))
expect(screen.queryByPlaceholderText('搜索好友')).toBeNull()
}
await user.click(targetRadio('指定好友'))
expect(screen.getByPlaceholderText('搜索好友')).toBeVisible()
})
it('好友只能选一个', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
await user.click(targetRadio('指定好友'))
await user.click(screen.getByRole('radio', { name: '好友甲' }))
expect(screen.getByRole('radio', { name: '好友甲' })).toBeChecked()
await user.click(screen.getByRole('radio', { name: '好友乙' }))
expect(screen.getByRole('radio', { name: '好友乙' })).toBeChecked()
expect(screen.getByRole('radio', { name: '好友甲' })).not.toBeChecked()
})
})
describe('退群通知 · 保存与取消', () => {
it('保存会写入 singleton 规则(含目标、模板、开关)', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
// 刻意选一个**与默认不同**的目标:这样才验证了"保存的是用户选的"。
await user.click(targetRadio('发给自己'))
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(spies.saveLeaveNotificationRule).toHaveBeenCalledTimes(1))
const draft = spies.saveLeaveNotificationRule.mock.calls[0][0] as AutomationRule
expect(draft.id).toBe(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(draft.ruleType).toBe('leave_notification')
expect(draft.enabled).toBe(true)
expect(draft.leaveNotification?.target).toEqual({ type: 'self' })
expect(draft.leaveNotification?.template).toContain('[退群监测]')
})
it('取消不写任何东西', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
await user.click(targetRadio('发给自己'))
await user.click(screen.getByRole('button', { name: '取消' }))
expect(spies.saveLeaveNotificationRule).not.toHaveBeenCalled()
// 回到首页:卡片上的目标仍是持久化的那个(默认「当前群聊」),不是刚点的「发给自己」。
expect((await screen.findByText('退群通知')).closest('article')?.textContent).toContain(
'当前群聊'
)
})
it('重新打开时恢复的是已持久化配置,不是上次未保存的改动', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
await user.click(targetRadio('发给自己'))
await user.click(screen.getByRole('button', { name: '取消' }))
await user.click(within(await leaveNotificationCard()).getByRole('button', { name: '编辑' }))
expect(targetRadio('当前群聊')).toBeChecked()
expect(spies.saveLeaveNotificationRule).not.toHaveBeenCalled()
})
it('启用开关也会被保存', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
await user.click(screen.getByRole('switch', { name: '启用这条自动化' }))
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(spies.saveLeaveNotificationRule).toHaveBeenCalledTimes(1))
const draft = spies.saveLeaveNotificationRule.mock.calls[0][0] as AutomationRule
expect(draft.enabled).toBe(false)
})
})
describe('退群通知 · 效果预览', () => {
it('预览用的是模板渲染结果,不是写死的示例', async () => {
await openLeaveNotificationEditor()
const bubble = document.querySelector('.automation-chat-bubble.pre')
expect(bubble?.textContent).toContain('[退群监测]')
// 占位符已按样本事件替换(同一套 shared 渲染规则)。
expect(bubble?.textContent).toContain('用户: 张三')
expect(bubble?.textContent).toContain('人数: 243 -> 242')
})
it('改发送目标 → 顶部模拟收件人跟着变', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
// 默认 = 当前群聊 ⇒ 顶部收件会话就是发生退群的那个群。
expect(previewRecipient()).toBe('TraceMemo 交流群')
await user.click(targetRadio('文件传输助手'))
expect(previewRecipient()).toBe('文件传输助手')
await user.click(targetRadio('发给自己'))
expect(previewRecipient()).toBe('我')
})
it('指定好友时预览收件人跟着好友变,未选时如实说未选择', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
await user.click(targetRadio('指定好友'))
expect(previewRecipient()).toBe('未选择好友')
await user.click(screen.getByRole('radio', { name: '好友甲' }))
expect(previewRecipient()).toBe('好友甲')
})
it('底部摘要随目标变化,且不含「执行方式」', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
expect(previewFact('触发事件')).toBe('成员退出')
expect(previewFact('监控来源')).toBe('3 个已监控群聊')
expect(previewFact('通知目标')).toBe('发生退群事件的群聊')
expect(document.querySelector('.automation-preview-facts')?.textContent).not.toContain('执行方式')
await user.click(targetRadio('文件传输助手'))
expect(previewFact('通知目标')).toBe('文件传输助手')
})
it('预览永远不触发发送与保存', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor()
await user.click(targetRadio('当前群聊'))
await user.click(targetRadio('指定好友'))
for (const spy of [
spies.saveLeaveNotificationRule,
spies.createAutomationRule,
spies.updateAutomationRule,
spies.setAutomationRuleEnabled
]) {
expect(spy).not.toHaveBeenCalled()
}
})
it('编辑器里不出现底层实现词', async () => {
await openLeaveNotificationEditor()
expect(document.body.textContent).not.toMatch(/AutomationRule|trigger matcher|ActionRunner|WCDB/)
})
})
describe('退群通知 · 通知内容与变量', () => {
it('变量清单以 {groupName} 打头,且可以插进模板', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
const chips = Array.from(document.querySelectorAll('.automation-leave-template-chip')).map(
(chip) => chip.textContent
)
expect(chips).toEqual([
'{groupName}',
'{user}',
'{groupRemark}',
'{wxid}',
'{previousCount}',
'{currentCount}',
'{time}'
])
await user.click(screen.getByRole('button', { name: '插入变量 groupName' }))
const textarea = screen.getByLabelText('退群通知模板内容') as HTMLTextAreaElement
expect(textarea.value).toContain('{groupName}')
})
/*
* 预览必须能同时看出「哪个群」和「谁退的」:
* groupName 与 groupRemark 一旦取值相同,两者的区别就从界面上消失了。
*/
it('预览里群名与成员群昵称是两个不同的值', async () => {
await openLeaveNotificationEditor()
const bubble = document.querySelector('.automation-chat-bubble.pre')
expect(bubble?.textContent).toContain('群聊: TraceMemo 交流群')
expect(bubble?.textContent).toContain('群备注: 小张')
})
it('底部说明直说「群名 vs 成员群昵称」的区别,且不含旧语义残句', async () => {
await openLeaveNotificationEditor()
const note = document.querySelector('.automation-leave-template-note')?.textContent ?? ''
expect(note).toContain('未读取到')
// 实测有人把 {groupRemark} 当成群名 —— 这句必须直说区别。
expect(note).toContain('群聊名')
expect(note).toContain('不是群名')
// 旧文案两句都是错的:回退链被安到了 groupRemark 上;后半句是"只能发回原群"时代的残句。
expect(note).not.toContain('通知仅发送到本规则配置的通知群聊')
expect(note).not.toContain('群备注为空时可回退显示群昵称')
})
/*
* 变量清单原先只有 {groupRemark} 这种符号,用户看不出含义,
* 于是有人把它当成群名。每个 chip 必须带中文说明。
*/
it('每个变量 chip 都带中文含义说明', async () => {
await openLeaveNotificationEditor()
const chips = Array.from(document.querySelectorAll('.automation-leave-template-chip'))
const titleOf = (token: string): string =>
chips
.find((chip) => chip.textContent?.trim() === token)
?.getAttribute('title') ?? ''
expect(titleOf('{groupName}')).toBe('{groupName}:群聊名(发生退群的群)')
expect(titleOf('{groupRemark}')).toContain('不是群名')
expect(titleOf('{user}')).toContain('退群成员')
expect(titleOf('{time}')).toContain('退群时间')
})
})
describe('退群通知 · 群聊名护栏', () => {
/** 一份没有群名变量的模板(模拟用户主动删掉了那一行)。 */
const ruleWithoutGroupName = (): AutomationRule => ({
...createDefaultLeaveNotificationRule(Date.now()),
leaveNotification: {
target: { type: 'source_chat' },
template: '[退群监测]\n\n用户: {user}'
}
})
it('目标是「当前群聊」时不提示 —— 通知就发在群里,群名是多余的', async () => {
await openLeaveNotificationEditor({ leaveRule: ruleWithoutGroupName() })
expect(screen.queryByText(/收件人看不出是哪个群退的人/)).toBeNull()
expect(screen.queryByRole('button', { name: '补上群聊名' })).toBeNull()
})
it('目标改成非当前群聊时才提示,并给一键补上', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor({ leaveRule: ruleWithoutGroupName() })
await user.click(targetRadio('文件传输助手'))
expect(await screen.findByText(/收件人看不出是哪个群退的人/)).toBeVisible()
await user.click(screen.getByRole('button', { name: '补上群聊名' }))
const textarea = screen.getByLabelText('退群通知模板内容') as HTMLTextAreaElement
expect(textarea.value).toContain('群聊: {groupName}')
// 补完提示就消失。
expect(screen.queryByText(/收件人看不出是哪个群退的人/)).toBeNull()
})
it('模板本来就含群名时,任何目标都不提示', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor()
await user.click(targetRadio('发给自己'))
expect(screen.queryByText(/收件人看不出是哪个群退的人/)).toBeNull()
})
})
describe('退群监控 → 退群通知自动化', () => {
it('退群监控页顶部有自动化入口', async () => {
installApi()
renderMonitorWithProviders(
<GroupExitMonitorWorkspace dbReady onOpenLeaveNotificationAutomation={() => {}} />
)
expect(await screen.findByRole('button', { name: '配置退群通知自动化' })).toBeVisible()
})
it('点入口直接落到「自动化 → 规则 → 退群通知」', async () => {
const user = userEvent.setup()
installApi()
function Harness(): React.ReactElement {
const [page, setPage] = React.useState<'exit-monitor' | 'automation'>('exit-monitor')
const [request, setRequest] = React.useState<AutomationOpenRuleRequest | null>(null)
return page === 'exit-monitor' ? (
<GroupExitMonitorWorkspace
dbReady
onOpenLeaveNotificationAutomation={() => {
setRequest({ ruleType: 'leave_notification', requestId: 1 })
setPage('automation')
}}
/>
) : (
<AutomationWorkspace dbReady openRuleRequest={request} />
)
}
renderMonitorWithProviders(<Harness />)
await user.click(await screen.findByRole('button', { name: '配置退群通知自动化' }))
expect(await screen.findByLabelText('退群通知模板内容')).toBeVisible()
expect(screen.getByRole('radio', { name: '退群通知' })).toBeChecked()
})
it('「管理监控群聊 →」把用户送回退群监控的管理群聊页', async () => {
const user = userEvent.setup()
installApi()
const onOpenExitMonitorGroups = vi.fn()
renderWithProviders(
<AutomationWorkspace dbReady onOpenExitMonitorGroups={onOpenExitMonitorGroups} />
)
await user.click(within(await leaveNotificationCard()).getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('button', { name: /管理监控群聊/ }))
expect(onOpenExitMonitorGroups).toHaveBeenCalledTimes(1)
})
})
describe('退群通知 · 规则缺失时的兜底', () => {
/** 深链的对象引用必须稳定,否则 effect 每次渲染都会重跑。 */
function DeepLinkHarness(): React.ReactElement {
const [request] = React.useState<AutomationOpenRuleRequest>({
ruleType: 'leave_notification',
requestId: 1
})
return <AutomationWorkspace dbReady openRuleRequest={request} />
}
/*
* 深链进来时编辑器是**同步**打开的,而规则列表还在 IPC 回来的路上。
* 这一帧必然还没有规则 —— 它是最正常的加载状态,不能说成"尚未就绪 / 版本不匹配"。
*/
it('规则还在读取时显示加载态,不把正常加载描述成故障', async () => {
const { spies } = installApi()
let release: (rules: AutomationRule[]) => void = () => undefined
spies.listAutomationRules.mockReturnValue(
new Promise<AutomationRule[]>((resolve) => {
release = resolve
})
)
renderWithProviders(<DeepLinkHarness />)
expect(await screen.findByText('正在读取退群通知规则…')).toBeVisible()
// 旧兜底文案(把正常加载说成故障)必须消失。这里用**完整旧句子**匹配 ——
// 宽泛的 /尚未就绪/ 会命中顶部「发送能力」卡片,那是另一块 UI。
expect(screen.queryByText(/退群通知规则尚未就绪/)).toBeNull()
expect(screen.queryByText(/没有找到内置的退群通知规则/)).toBeNull()
release([
createDefaultDailyReportRule(Date.now()),
createDefaultLeaveNotificationRule(Date.now())
])
expect(await screen.findByLabelText('退群通知模板内容')).toBeVisible()
})
it('读完了确实没有这条规则时,给出准确说明与重试入口', async () => {
const user = userEvent.setup()
const { spies } = installApi()
// 只有日报规则 —— 等价于「主进程还是迁移前的旧构建」。
spies.listAutomationRules.mockResolvedValue([createDefaultDailyReportRule(Date.now())])
renderWithProviders(<DeepLinkHarness />)
expect(await screen.findByText('没有找到内置的退群通知规则。')).toBeVisible()
expect(screen.getByText(/请重启 TraceMemo/)).toBeVisible()
expect(screen.queryByText('正在读取退群通知规则…')).toBeNull()
const before = spies.listAutomationRules.mock.calls.length
await user.click(screen.getByRole('button', { name: '重新加载' }))
await waitFor(() =>
expect(spies.listAutomationRules.mock.calls.length).toBeGreaterThan(before)
)
})
it('重试后规则回来了就直接进编辑器', async () => {
const user = userEvent.setup()
const { spies } = installApi()
spies.listAutomationRules
.mockResolvedValueOnce([createDefaultDailyReportRule(Date.now())])
.mockResolvedValue([
createDefaultDailyReportRule(Date.now()),
createDefaultLeaveNotificationRule(Date.now())
])
renderWithProviders(<DeepLinkHarness />)
await user.click(await screen.findByRole('button', { name: '重新加载' }))
expect(await screen.findByLabelText('退群通知模板内容')).toBeVisible()
})
})
describe('退群监控 · 旧通知 UI 已移除', () => {
it('管理群聊页没有「通知群聊」列,也没有「模板」按钮', async () => {
const user = userEvent.setup()
installApi()
renderMonitorWithProviders(
<GroupExitMonitorWorkspace
dbReady
contacts={[
{ m_nsUsrName: 'group_a@chatroom', m_nsNickName: '群 A', md5: 'md5-a', type: 'group' }
]}
onOpenLeaveNotificationAutomation={() => {}}
/>
)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
expect(screen.queryByText('通知群聊')).toBeNull()
expect(screen.queryByRole('button', { name: '查看退群监测模板' })).toBeNull()
// 监控选择本身仍然可用。
expect(screen.getByLabelText('监控群 A')).toBeInTheDocument()
})
})