mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 退群监控接入自动化能力统一管理, 支持发送个人/文件传输助手/指定群聊
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user