feat: 定时日报并入自动化,统一规则管理与发送链路

发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
This commit is contained in:
电摇小子
2026-09-28 17:11:50 +07:00
parent c835d5c570
commit 4d5c6459fe
49 changed files with 6436 additions and 4863 deletions
@@ -1,8 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactElement } from 'react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { GroupExitMonitorWorkspace } from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
import {
GroupExitMonitorWorkspace,
type GroupExitMonitorOpenViewRequest
} from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
import type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor'
import { formatGroupExitMonitorTime } from '../../src/shared/group-exit-monitor'
import type { Contact } from '../../src/shared/types'
@@ -69,7 +72,7 @@ const groups: Contact[] = [
]
describe('GroupExitMonitorWorkspace', () => {
const renderWorkspace = (element: ReactElement): void => {
const renderWorkspace = (element: React.ReactElement): void => {
render(<TooltipProvider>{element}</TooltipProvider>)
}
@@ -387,4 +390,63 @@ describe('GroupExitMonitorWorkspace', () => {
expect(screen.getByRole('checkbox', { name: '监控研发群' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: '监控设计群' })).not.toBeChecked()
})
/*
* 深链请求必须是**一次性**的。
*
* 真实拓扑里请求挂在 App,而本组件随一级菜单切换被卸载/重挂。
* 修复前 effect 只 `setView` 不回报父层,请求永远不清 —— 于是重挂时
* 那条陈旧的 `{view:'manage'}` 被重放,用户看到的现象就是
* "点一级菜单退群监控,却跳到了管理群聊"。
*/
it('深链落到「管理群聊」,并立刻回报父层消费掉请求', async () => {
const onHandled = vi.fn()
renderWorkspace(
<GroupExitMonitorWorkspace
dbReady
openViewRequest={{ view: 'manage', requestId: 1 }}
onOpenViewRequestHandled={onHandled}
/>
)
expect(await screen.findByRole('heading', { name: '管理群聊' })).toBeVisible()
await waitFor(() => expect(onHandled).toHaveBeenCalledTimes(1))
})
it('请求被消费后重新挂载,回到「退群事件」而不是「管理群聊」', async () => {
function Harness(): React.ReactElement {
const [request, setRequest] = React.useState<GroupExitMonitorOpenViewRequest | null>({
view: 'manage',
requestId: 1
})
const [mounted, setMounted] = React.useState(true)
return (
<>
<button type="button" onClick={() => setMounted((prev) => !prev)}>
切换页面
</button>
{mounted ? (
<GroupExitMonitorWorkspace
dbReady
openViewRequest={request}
onOpenViewRequestHandled={() => setRequest(null)}
/>
) : null}
</>
)
}
const user = userEvent.setup()
renderWorkspace(<Harness />)
// 第一次:深链确实生效。
expect(await screen.findByRole('heading', { name: '管理群聊' })).toBeVisible()
// 离开再回来(等价于点别的菜单再点「退群监控」)。
await user.click(screen.getByRole('button', { name: '切换页面' }))
await user.click(screen.getByRole('button', { name: '切换页面' }))
expect(await screen.findByText('小艾退出了研发群')).toBeVisible()
expect(screen.queryByRole('heading', { name: '管理群聊' })).toBeNull()
})
})
+101 -6
View File
@@ -58,7 +58,11 @@ interface ApiSpies {
}
function installApi(
overrides: { leaveRule?: AutomationRule } = {}
overrides: {
leaveRule?: AutomationRule
/** `listAutomationGroups` 的返回:把 roomId 映射成可读群名。 */
automationGroups?: Array<{ id: string; name: string }>
} = {}
): { spies: ApiSpies; rules: AutomationRule[] } {
const leaveRule = overrides.leaveRule ?? createDefaultLeaveNotificationRule(Date.now())
const rules = [createDefaultDailyReportRule(Date.now()), leaveRule]
@@ -92,7 +96,7 @@ function installApi(
}
}),
listAutomationRules: spies.listAutomationRules,
listAutomationGroups: vi.fn().mockResolvedValue([]),
listAutomationGroups: vi.fn().mockResolvedValue(overrides.automationGroups ?? []),
listAutomationExecutions: vi.fn().mockResolvedValue([]),
listSendableContacts: spies.listSendableContacts,
getGroupExitMonitorState: vi.fn().mockResolvedValue(MONITOR_STATE),
@@ -128,6 +132,7 @@ async function leaveNotificationCard(): Promise<HTMLElement> {
/** 从自动化首页那张退群通知卡片的「编辑」进编辑器。 */
async function openLeaveNotificationEditor(overrides: {
leaveRule?: AutomationRule
automationGroups?: Array<{ id: string; name: string }>
} = {}): Promise<{ spies: ApiSpies }> {
const user = userEvent.setup()
const { spies } = installApi(overrides)
@@ -218,12 +223,17 @@ describe('退群通知 · 真实数据', () => {
expect(screen.queryByRole('radio', { name: '张三' })).toBeNull()
})
it('Section 1 没有「触发范围」,只有唯一的「管理监控群聊」入口', async () => {
/*
* 旧版「退群监控 → 通知群聊」的第二层。它曾在迁移里被删掉,
* 导致"规则一启用就全量通知" —— 所以这里锁的是它**必须存在**。
*/
it('Section 1 有「通知范围」二次勾选,且保留唯一的「管理监控群聊」入口', async () => {
await openLeaveNotificationEditor()
// 「触发事件」仍不可配(由退群监控驱动),但这不等于不能筛群。
expect(screen.queryByText('触发范围')).toBeNull()
expect(screen.queryByText('全部已监控群聊')).toBeNull()
expect(screen.queryByText('指定已监控群聊')).toBeNull()
expect(screen.getByRole('radio', { name: '全部已监控群聊' })).toBeChecked()
expect(screen.getByRole('radio', { name: '仅选中的群聊' })).toBeEnabled()
expect(screen.getByRole('button', { name: /管理监控群聊/ })).toBeEnabled()
})
@@ -236,6 +246,88 @@ describe('退群通知 · 真实数据', () => {
})
})
const MONITORED_GROUPS = [
{ id: 'a@chatroom', name: '甲群' },
{ id: 'b@chatroom', name: '乙群' },
{ id: 'c@chatroom', name: '丙群' }
]
/**
* 「通知范围」二次勾选 —— 旧版「退群监控 → 通知群聊」的第二层。
*
* 这组测试锁的是**回归本身**:缺了这一层,规则一启用就等于给全部已监控群聊发通知。
*/
describe('退群通知 · 通知范围(二次勾选)', () => {
it('默认是「全部已监控群聊」,预览里如实说明覆盖范围', async () => {
await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS })
expect(screen.getByRole('radio', { name: '全部已监控群聊' })).toBeChecked()
expect(previewFact('通知范围')).toBe('3 个已监控群聊(全部)')
})
it('切到「仅选中的群聊」才列候选,且候选只含已监控的群', async () => {
const user = userEvent.setup()
await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS })
// 还没切过去时不该出现候选项。
expect(screen.queryByRole('checkbox', { name: '甲群' })).toBeNull()
await user.click(screen.getByRole('radio', { name: '仅选中的群聊' }))
for (const name of ['甲群', '乙群', '丙群']) {
expect(screen.getByRole('checkbox', { name })).not.toBeChecked()
}
// 一个都没勾 = 明确不通知,而不是悄悄退回"全部"。
expect(screen.getByText(/一个群都没勾选/)).toBeVisible()
})
it('勾中的群会被保存成 notifyScope=selected + 那份子集', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS })
await user.click(screen.getByRole('radio', { name: '仅选中的群聊' }))
await user.click(screen.getByRole('checkbox', { name: '甲群' }))
await user.click(screen.getByRole('checkbox', { 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.leaveNotification?.notifyScope).toBe('selected')
// 落盘顺序按候选项顺序(本文件里候选按群名拼音排),所以这里只比较集合:
// 勾选先后不该影响存下来的内容。
expect([...(draft.leaveNotification?.notifyRoomIds ?? [])].sort()).toEqual([
'a@chatroom',
'c@chatroom'
])
})
it('没动过范围时保存下来的是 all,不会顺手把用户缩到子集', async () => {
const user = userEvent.setup()
const { spies } = await openLeaveNotificationEditor({ automationGroups: MONITORED_GROUPS })
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.leaveNotification?.notifyScope).toBe('all')
})
it('旧版迁移过来的子集在界面上是勾好的,不是空选', async () => {
await openLeaveNotificationEditor({
automationGroups: MONITORED_GROUPS,
leaveRule: createDefaultLeaveNotificationRule(Date.now(), {
notifyScope: 'selected',
notifyRoomIds: ['b@chatroom']
})
})
expect(screen.getByRole('radio', { name: '仅选中的群聊' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: '乙群' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: '甲群' })).not.toBeChecked()
expect(previewFact('通知范围')).toBe('1 / 3 个已监控群聊')
})
})
describe('退群通知 · 四种目标', () => {
it('是四选一,「当前群聊」排第一且默认选中', async () => {
await openLeaveNotificationEditor()
@@ -479,7 +571,10 @@ describe('退群通知 · 群聊名护栏', () => {
...createDefaultLeaveNotificationRule(Date.now()),
leaveNotification: {
target: { type: 'source_chat' },
template: '[退群监测]\n\n用户: {user}'
template: '[退群监测]\n\n用户: {user}',
// 明确写全:本组用例只关心群名护栏,范围取默认的"全部已监控群聊"。
notifyScope: 'all',
notifyRoomIds: []
}
})
@@ -8,82 +8,95 @@ import {
AutomationWorkspace,
type AutomationOpenRuleRequest
} from '../../src/renderer/src/features/automation/AutomationWorkspace'
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
import {
createDefaultDailyReportRule,
createDefaultLeaveNotificationRule
createDefaultLeaveNotificationRule,
createDefaultScheduledReportRule,
normalizeScheduledReportConfig,
type AutomationRule
} from '../../src/shared/automation'
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
import { SUMMARY_TYPE_OPTIONS } from '../../src/renderer/src/utils/group-report'
/**
* 「自动化 → 规则 → 定时日报」。
*
* 这一组测试要守住的核心不是"界面长什么样",而是**边界**:
* 任何写操作都不许落到真实定时日报系统上
* (create / update / delete / setEnabled / runNow / retry / 异常通知开关)。
* 迁移前这一组测试守的是**边界**("任何写操作都不许落盘")。
* 迁移后 `scheduled_report` 已经是真实的自动化规则类型,边界随之反转:
* 现在要守的是**写真的发生、且写在正确的通道上**:
* - 保存 → `createAutomationRule` / `updateAutomationRule`,且 `ruleType === 'scheduled_report'`;
* - 立即执行 → `runScheduledReportRule`(与 scheduler 同一条链路);
* - 启停 → `setAutomationRuleEnabled`;
* - 删除 → `deleteAutomationRule`;
* - 且**没有任何** `scheduled-report:*` 旧通道残留。
*/
const TASKS: ScheduledReportTask[] = [
{
id: 'task-1',
const RULES_FIXTURE: AutomationRule[] = [
createDefaultScheduledReportRule(Date.now(), {
id: 'rule-1',
name: 'TraceMemo 每日晚报',
group: 'TraceMemo 交流群',
scheduleTime: '18:21',
reportRange: 'today',
target: 'TraceMemo 管理群',
enabled: true,
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
nextRunAt: '2026-09-24T10:21:00.000Z'
},
{
id: 'task-2',
config: normalizeScheduledReportConfig({
schedule: { time: '18:21' },
report: {
sourceConversationId: 'g1@chatroom',
range: 'today',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target: { type: 'source_chat' }
})
}),
createDefaultScheduledReportRule(Date.now(), {
id: 'rule-2',
name: '技术交流群日报',
group: '技术交流群',
scheduleTime: '09:00',
reportRange: 'yesterday',
target: '技术交流群',
enabled: false,
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
nextRunAt: '2026-09-25T01:00:00.000Z'
}
config: normalizeScheduledReportConfig({
schedule: { time: '09:00' },
report: {
sourceConversationId: 'g2@chatroom',
range: 'yesterday',
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target: { type: 'file_transfer' }
})
})
]
interface Spies {
createScheduledReport: ReturnType<typeof vi.fn>
updateScheduledReport: ReturnType<typeof vi.fn>
deleteScheduledReport: ReturnType<typeof vi.fn>
setScheduledReportEnabled: ReturnType<typeof vi.fn>
runScheduledReportNow: ReturnType<typeof vi.fn>
retryScheduledReportSend: ReturnType<typeof vi.fn>
testScheduledReportErrorNotification: ReturnType<typeof vi.fn>
listScheduledReports: ReturnType<typeof vi.fn>
createRule: ReturnType<typeof vi.fn>
updateRule: ReturnType<typeof vi.fn>
deleteRule: ReturnType<typeof vi.fn>
setEnabled: ReturnType<typeof vi.fn>
runScheduled: ReturnType<typeof vi.fn>
setNotification: ReturnType<typeof vi.fn>
}
/** 所有"写操作"的集合:任何一条被调用都说明界面越界了。 */
function writeSpies(spies: Spies): Array<[string, ReturnType<typeof vi.fn>]> {
return [
['createScheduledReport', spies.createScheduledReport],
['updateScheduledReport', spies.updateScheduledReport],
['deleteScheduledReport', spies.deleteScheduledReport],
['setScheduledReportEnabled', spies.setScheduledReportEnabled],
['runScheduledReportNow', spies.runScheduledReportNow],
['retryScheduledReportSend', spies.retryScheduledReportSend],
['testScheduledReportErrorNotification', spies.testScheduledReportErrorNotification]
]
}
function installApi(tasks: ScheduledReportTask[] = TASKS): Spies {
function installApi(rules: AutomationRule[] = RULES_FIXTURE): Spies {
const spies: Spies = {
listScheduledReports: vi.fn().mockResolvedValue(tasks),
createScheduledReport: vi.fn(),
updateScheduledReport: vi.fn(),
deleteScheduledReport: vi.fn(),
setScheduledReportEnabled: vi.fn(),
runScheduledReportNow: vi.fn(),
retryScheduledReportSend: vi.fn(),
testScheduledReportErrorNotification: vi.fn()
createRule: vi.fn().mockImplementation((draft: unknown) =>
Promise.resolve({
...(draft as AutomationRule),
id: 'rule-new',
createdAt: Date.now(),
updatedAt: Date.now()
})
),
updateRule: vi.fn().mockImplementation((id: string, draft: unknown) =>
Promise.resolve({ ...(draft as AutomationRule), id, createdAt: 0, updatedAt: 0 })
),
deleteRule: vi.fn().mockResolvedValue(true),
setEnabled: vi.fn().mockImplementation((id: string, enabled: boolean) => {
const found = rules.find((rule) => rule.id === id)
return Promise.resolve(found ? { ...found, enabled } : null)
}),
runScheduled: vi.fn().mockResolvedValue({
success: true,
data: { executed: true, executionId: 'exec-1', status: 'success', reportGenerated: true }
}),
setNotification: vi.fn().mockResolvedValue({ success: true, data: { enabled: true } })
}
window.api = {
@@ -102,12 +115,17 @@ function installApi(tasks: ScheduledReportTask[] = TASKS): Spies {
}),
listAutomationRules: vi.fn().mockResolvedValue([
createDefaultDailyReportRule(Date.now()),
createDefaultLeaveNotificationRule(Date.now())
createDefaultLeaveNotificationRule(Date.now()),
...rules
]),
createAutomationRule: spies.createRule,
updateAutomationRule: spies.updateRule,
deleteAutomationRule: spies.deleteRule,
setAutomationRuleEnabled: spies.setEnabled,
listAutomationGroups: vi.fn().mockResolvedValue([
{ id: 'g1', name: 'TraceMemo 交流群' },
{ id: 'g2', name: '技术交流群' },
{ id: 'g3', name: 'TraceMemo 管理群' }
{ id: 'g1@chatroom', name: 'TraceMemo 交流群' },
{ id: 'g2@chatroom', name: '技术交流群' },
{ id: 'g3@chatroom', name: 'TraceMemo 管理群' }
]),
listAutomationExecutions: vi.fn().mockResolvedValue([]),
listSendableContacts: vi.fn().mockResolvedValue([]),
@@ -123,11 +141,15 @@ function installApi(tasks: ScheduledReportTask[] = TASKS): Spies {
unreadCount: 0
}),
onGroupExitMonitorState: vi.fn(() => () => undefined),
// 旧「日报 → 定时日报」页面 mount 时会读这几项;只读、与预览无关。
listScheduledReportExecutions: vi.fn().mockResolvedValue([]),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null),
runScheduledReportRule: spies.runScheduled,
listScheduledReportLegacyExecutions: vi.fn().mockResolvedValue([]),
getScheduledReportNotificationSettings: vi.fn().mockResolvedValue({ enabled: false }),
...spies
getScheduledReportNotificationCapability: vi.fn().mockResolvedValue({
ready: false,
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
}),
setScheduledReportNotificationEnabled: spies.setNotification,
testScheduledReportErrorNotification: vi.fn().mockResolvedValue({ success: true })
} as unknown as typeof window.api
return spies
@@ -174,11 +196,29 @@ describe('自动化 · 规则类型 Tab(定时日报)', () => {
expect(await screen.findByRole('heading', { name: '定时日报' })).toBeVisible()
expect(screen.getByText('按设定时间自动生成日报,并发送到指定微信会话。')).toBeVisible()
expect(screen.getByText('2 条规则')).toBeVisible()
// 列表态不该出现编辑器的保存按钮。
expect(screen.queryByRole('button', { name: '保存' })).toBeNull()
})
it('列表按真实字段渲染(触发 / 日报 / 来源 / 发送到 / 下次执行)', async () => {
it('列表层有「返回规则列表」入口,不切 tab 也能退出', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
/*
* 这里以前是个死胡同:定时日报先落列表,而列表只有新建/编辑/立即执行/删除,
* 页面级「新建自动化」又因编辑态被隐藏,顶部 规则/执行日志 切换条同样被隐藏 ——
* 唯一的出路竟然是切到别的规则类型 tab,再从那边的「取消」绕回来。
*/
const back = await screen.findByRole('button', { name: /返回规则列表/ })
await user.click(back)
// 回到规则面板(三个类型卡片),本类型的 tab 条随之消失。
expect(await screen.findByText('退群通知')).toBeVisible()
expect(screen.queryByRole('radio', { name: '定时日报' })).toBeNull()
})
it('列表按真实规则字段渲染(触发 / 日报 / 来源 / 模板 / 发送到)', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
@@ -194,41 +234,53 @@ describe('自动化 · 规则类型 Tab(定时日报)', () => {
['日报', '今日'],
['来源', 'TraceMemo 交流群'],
['模板', '经典日报'],
// target 与来源群不同 ⇒ 显示 target 的群名;相同时会显示「日报来源群」。
['发送到', 'TraceMemo 管理群'],
['发送到', '发送到日报来源群(TraceMemo 交流群)'],
['下次执行', expect.any(String)]
])
// 暂停的任务下一次执行显示破折号,而不是一个会误导的时间。
// 暂停的规则不再给出"下次执行"时间,避免误导。
const paused = (await screen.findByText('技术交流群日报')).closest('article') as HTMLElement
expect(paused.textContent).toContain('已暂停')
expect(within(paused).getByText('—')).toBeVisible()
// 这条的 target 与来源群相同 ⇒ 用「日报来源群」这种语义文案,而不是重复群名。
expect(paused.textContent).toContain('日报来源群')
expect(paused.textContent).toContain('文件传输助手')
})
})
describe('定时日报 · 编辑器与预览联动', () => {
it('点「+ 新建定时日报」进入编辑器(三段 + 预览)', async () => {
installApi()
describe('定时日报 · 真写入', () => {
it('保存新规则 → createAutomationRule,且 ruleType 与完整配置都对', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
expect(screen.getByRole('heading', { name: '1 · 什么时候触发' })).toBeVisible()
expect(screen.getByRole('heading', { name: '2 · 生成什么日报' })).toBeVisible()
expect(screen.getByRole('heading', { name: '3 · 生成后发送到哪里' })).toBeVisible()
expect(screen.getByRole('heading', { name: '效果预览' })).toBeVisible()
// 只有一个开关:规则级启停。
expect(screen.getAllByRole('switch')).toHaveLength(1)
await user.type(screen.getByLabelText('定时日报任务名称'), '新的定时日报')
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
)
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(spies.createRule).toHaveBeenCalledTimes(1))
const draft = spies.createRule.mock.calls[0][0] as {
name: string
ruleType: string
scheduledReport: { report: { sourceConversationId: string }; target: { type: string } }
}
expect(draft.name).toBe('新的定时日报')
expect(draft.ruleType).toBe('scheduled_report')
// 来源群存的是**稳定会话 id**,不是群名。
expect(draft.scheduledReport.report.sourceConversationId).toBe('g2@chatroom')
// 新建默认发到文件传输助手(不会误打扰群聊)。
expect(draft.scheduledReport.target.type).toBe('file_transfer')
expect(spies.updateRule).not.toHaveBeenCalled()
})
it('点卡片「编辑」进入编辑器,并带出该任务的值', async () => {
installApi()
it('编辑既有规则 → updateAutomationRule(不是新建)', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '编辑' }))
@@ -236,103 +288,31 @@ describe('定时日报 · 编辑器与预览联动', () => {
expect(screen.getByLabelText('定时日报任务名称')).toHaveValue('TraceMemo 每日晚报')
expect(screen.getByLabelText('执行时间小时')).toHaveValue('18')
expect(screen.getByLabelText('执行时间分钟')).toHaveValue('21')
expect(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: 'TraceMemo 交流群'
})
).toBeChecked()
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(spies.updateRule).toHaveBeenCalledTimes(1))
expect(spies.updateRule.mock.calls[0][0]).toBe('rule-1')
expect(spies.createRule).not.toHaveBeenCalled()
})
/*
* 任务里存的群名与群列表是两个数据源,不保证一致
* (群被移出监控、或列表来源变了)。这时**必须回显当前值**,
* 否则编辑已有任务会出现"一串群里没有任何一项被选中"。
*/
it('来源群不在群列表里时,仍会作为候选项被选中', async () => {
installApi([{ ...TASKS[0], group: '已不在列表的群', target: '已不在列表的群' }])
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '编辑' }))
expect(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '已不在列表的群'
})
).toBeChecked()
})
it('改执行时间 → 预览与「预计下次执行」同时更新', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
const hourInput = screen.getByLabelText('执行时间小时')
await user.clear(hourInput)
await user.type(hourInput, '07')
const minuteInput = screen.getByLabelText('执行时间分钟')
await user.clear(minuteInput)
await user.type(minuteInput, '05')
expect(screen.getByTestId('scheduled-next-run').textContent).toMatch(/0?7:05/)
const facts = document.querySelector('.automation-preview-facts')?.textContent ?? ''
expect(facts).toContain('每天 07:05')
})
it('改日报来源 → 预览的生成目标跟着变', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
)
const flow = document.querySelector('.automation-preview-flow')?.textContent ?? ''
expect(flow).toContain('技术交流群')
const facts = document.querySelector('.automation-preview-facts')?.textContent ?? ''
expect(facts).toContain('技术交流群')
})
it('发送目标是四选一,且**没有**「指定群聊」这种"发到另一个群"的选项', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
const group = screen.getByRole('radiogroup', { name: '生成后发送到哪里' })
const labels = Array.from(group.querySelectorAll('label')).map((el) => el.textContent?.trim())
expect(labels).toEqual(['文件传输助手', '发送到日报来源群', '发给自己', '指定好友'])
expect(screen.queryByRole('radio', { name: '指定群聊' })).toBeNull()
// 新建默认 = 文件传输助手(不会误打扰群聊)。
expect(screen.getByRole('radio', { name: '文件传输助手' })).toBeChecked()
})
it('保存只提示,不调用任何真实写接口', async () => {
it('缺名称 / 缺来源群时**不允许保存**,并说明原因', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(screen.getByRole('button', { name: '保存' }))
expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible()
for (const [name, spy] of writeSpies(spies)) {
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
}
expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent('请填写任务名称')
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
await user.type(screen.getByLabelText('定时日报任务名称'), '有名字了')
expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent('请选择日报来源群')
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
expect(spies.createRule).not.toHaveBeenCalled()
})
it('「立即执行」不触发真实执行', async () => {
it('「立即执行」走 runScheduledReportRule(与 scheduler 同一条链路)', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
@@ -340,13 +320,10 @@ describe('定时日报 · 编辑器与预览联动', () => {
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '立即执行' }))
expect(await screen.findByText('UI 预览模式,不会执行真实日报任务')).toBeVisible()
for (const [name, spy] of writeSpies(spies)) {
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
}
await waitFor(() => expect(spies.runScheduled).toHaveBeenCalledWith('rule-1'))
})
it('toggle 只改本地状态并提示,不写回 store', async () => {
it('toggle 真写 setAutomationRuleEnabled', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
@@ -354,16 +331,10 @@ describe('定时日报 · 编辑器与预览联动', () => {
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('switch'))
expect(await screen.findByText('UI 预览模式,配置暂未保存')).toBeVisible()
expect(within(card).getByText('已暂停')).toBeVisible()
expect(spies.setScheduledReportEnabled).not.toHaveBeenCalled()
for (const [name, spy] of writeSpies(spies)) {
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
}
await waitFor(() => expect(spies.setEnabled).toHaveBeenCalledWith('rule-1', false))
})
it('删除只是提示,不移除任务', async () => {
it('删除要经过确认弹层,确认后才真删', async () => {
const spies = installApi()
renderWorkspace()
await openScheduledTab()
@@ -371,24 +342,50 @@ describe('定时日报 · 编辑器与预览联动', () => {
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '删除' }))
expect(await screen.findByText('UI 预览模式,不会删除现有任务')).toBeVisible()
expect(screen.getByText('TraceMemo 每日晚报')).toBeVisible()
for (const [name, spy] of writeSpies(spies)) {
expect(spy, `${name} 不该被调用`).not.toHaveBeenCalled()
}
const dialog = await screen.findByRole('alertdialog', { name: '删除自动化' })
expect(dialog).toHaveTextContent('删除「TraceMemo 每日晚报」?')
expect(spies.deleteRule).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: '删除' }))
await waitFor(() => expect(spies.deleteRule).toHaveBeenCalledWith('rule-1'))
})
it('任务列表为空时给空态与新建入口', async () => {
installApi([])
it('不再有任何旧的 scheduled-report:* 通道被调用', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
expect(await screen.findByText('还没有定时日报')).toBeVisible()
expect(screen.getAllByRole('button', { name: '+ 新建定时日报' }).length).toBeGreaterThan(0)
const api = window.api as unknown as Record<string, unknown>
for (const legacy of [
'listScheduledReports',
'createScheduledReport',
'updateScheduledReport',
'deleteScheduledReport',
'setScheduledReportEnabled',
'runScheduledReportNow',
'retryScheduledReportSend'
]) {
expect(api[legacy], `旧通道 ${legacy} 仍然存在于 preload 契约里`).toBeUndefined()
}
})
})
describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
it('新建时「纳入的消息类型」默认只勾选文本', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
// 逐个断言,而不是只看"有没有被勾":默认值改了方向的话,全选也是"有被勾"。
for (const option of SUMMARY_TYPE_OPTIONS) {
const box = screen.getByRole('checkbox', { name: option.label })
if (option.value === 'text') expect(box).toBeChecked()
else expect(box).not.toBeChecked()
}
})
it('Section 2 承载旧「定时日报」的全部日报配置', async () => {
installApi()
renderWorkspace()
@@ -405,25 +402,7 @@ describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
expect(screen.getByText('模型配置')).toBeVisible()
expect(screen.getByText('更改模型')).toBeVisible()
expect(screen.getByText('日报内容')).toBeVisible()
})
it('纳入的消息类型用旧页面的真实类型与说明(默认全选)', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
expect(screen.getByText('纳入的消息类型')).toBeVisible()
expect(screen.getByText('至少选择一种')).toBeVisible()
// 七类都来自 SUMMARY_TYPE_OPTIONS,不是新写的一套。
for (const label of ['文本', '图片', '表情包', '视频', '语音', '分享/引用', '系统消息']) {
expect(screen.getByRole('checkbox', { name: label })).toBeVisible()
}
// 旧页面默认全选。
expect(screen.getByRole('checkbox', { name: '文本' })).toBeChecked()
expect(screen.getByRole('checkbox', { name: '系统消息' })).toBeChecked()
})
it('成员名称保留旧页面的三个选项', async () => {
@@ -440,31 +419,24 @@ describe('定时日报 · 完整日报配置(不丢旧能力)', () => {
})
})
describe('定时日报 · 返回与四选一发送目标', () => {
it('新建页有「← 返回定时日报」,点击回到规则列表', async () => {
describe('定时日报 · 四选一发送目标', () => {
it('只有四选一,且**没有**「指定群聊」这种"发到另一个群"的选项', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(screen.getByRole('button', { name: '← 返回定时日报' }))
// 回到「定时日报」列表,而不是自动化首页。
expect(await screen.findByText('2 条规则')).toBeVisible()
expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked()
})
it('编辑页也有返回入口,点击回到规则列表', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
const card = (await screen.findByText('TraceMemo 每日晚报')).closest('article') as HTMLElement
await user.click(within(card).getByRole('button', { name: '编辑' }))
expect(screen.getByRole('button', { name: '← 返回定时日报' })).toBeVisible()
await user.click(screen.getByRole('button', { name: '← 返回定时日报' }))
expect(await screen.findByText('2 条规则')).toBeVisible()
const group = screen.getByRole('radiogroup', { name: '生成后发送到哪里' })
const labels = Array.from(group.querySelectorAll('label')).map((el) => el.textContent?.trim())
expect(labels).toEqual([
'发送到日报来源群',
'文件传输助手',
'发给自己',
'指定好友'
])
expect(screen.queryByRole('radio', { name: '指定群聊' })).toBeNull()
expect(screen.getByRole('radio', { name: '文件传输助手' })).toBeChecked()
})
it('只有「指定好友」才展开好友选择器', async () => {
@@ -473,6 +445,14 @@ describe('定时日报 · 返回与四选一发送目标', () => {
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
// 先把名称 / 来源群补齐:阻断提示是有优先级的(名称 → 来源群 → 指定好友),
// 否则这里看到的是「请填写任务名称」,断言就打偏了。
await user.type(screen.getByLabelText('定时日报任务名称'), '发给指定好友的日报')
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
)
for (const label of ['文件传输助手', '发送到日报来源群', '发给自己']) {
await user.click(screen.getByRole('radio', { name: label }))
@@ -481,80 +461,83 @@ describe('定时日报 · 返回与四选一发送目标', () => {
await user.click(screen.getByRole('radio', { name: '指定好友' }))
expect(screen.getByPlaceholderText('搜索好友')).toBeVisible()
})
it('Preview 随发送目标变化,且「发送到日报来源群」显示来源群名', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
// 选了「指定好友」却没选人 → 不允许保存(否则运行期必然失败)。
expect(screen.getByTestId('scheduled-save-blocker')).toHaveTextContent(
'请选择要发送的指定好友'
)
const targetOf = (): string => {
const rows = Array.from(document.querySelectorAll('.automation-preview-facts > div'))
const row = rows.find((item) => item.querySelector('dt')?.textContent === '发送目标')
return row?.querySelector('dd')?.textContent ?? ''
}
expect(targetOf()).toBe('文件传输助手')
await user.click(screen.getByRole('radio', { name: '发送到日报来源群' }))
expect(targetOf()).toBe('技术交流群')
await user.click(screen.getByRole('radio', { name: '发给自己' }))
expect(targetOf()).toBe('我')
})
it('Preview 随来源群与模板变化', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
await user.click(
within(screen.getByRole('radiogroup', { name: '日报来源' })).getByRole('radio', {
name: '技术交流群'
})
)
const flow = document.querySelector('.automation-preview-flow')?.textContent ?? ''
expect(flow).toContain('技术交流群')
expect(flow).toContain('经典日报')
await user.click(screen.getByLabelText('日报模板'))
// 选项文案来自 REPORT_TEMPLATES 的 `label · name`。
await user.click(await screen.findByRole('option', { name: /Mobile 01/ }))
expect(document.querySelector('.automation-preview-flow')?.textContent).not.toContain('经典日报')
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
})
})
describe('定时日报 · 不向用户暴露内部标识', () => {
describe('定时日报 · 迁移遗留与内部标识', () => {
it('targetNeedsReview 的规则在列表上明确提示"需要重选目标"', async () => {
const needsReview = createDefaultScheduledReportRule(Date.now(), {
id: 'rule-review',
name: '旧规则',
enabled: false,
config: {
...normalizeScheduledReportConfig({}),
target: { type: 'source_chat' },
targetNeedsReview: true,
legacyTarget: '某个旧群'
}
})
installApi([needsReview])
renderWorkspace()
await openScheduledTab()
const card = (await screen.findByText('旧规则')).closest('article') as HTMLElement
expect(card.textContent).toContain('发送目标需要重新选择')
expect(card.textContent).toContain('某个旧群')
// 目标待重选时不允许执行(执行必然失败)。
expect(within(card).getByRole('button', { name: '立即执行' })).toBeDisabled()
})
it('列表把 room id / hash 解析成显示名,绝不显示裸 id', async () => {
// 合成值:形态与真实 room id / hash 相同(纯数字 + @chatroom、32 位十六进制),
// 但取值本身可一眼辨认是假数据。与仓库其它测试保持一致。
const FAKE_ROOM_ID = '12345678@chatroom'
const FAKE_GROUP_HASH = '0123456789abcdef0123456789abcdef'
installApi([
{ ...TASKS[0], group: FAKE_ROOM_ID, target: FAKE_ROOM_ID },
{ ...TASKS[1], group: FAKE_GROUP_HASH }
createDefaultScheduledReportRule(Date.now(), {
id: 'rule-internal',
name: '内部标识规则',
config: normalizeScheduledReportConfig({
report: { sourceConversationId: FAKE_ROOM_ID, messageTypes: ['text'] }
})
}),
createDefaultScheduledReportRule(Date.now(), {
id: 'rule-internal-2',
name: '内部标识规则 2',
config: normalizeScheduledReportConfig({
report: { sourceConversationId: FAKE_GROUP_HASH, messageTypes: ['text'] }
})
})
])
renderWorkspace()
await openScheduledTab()
await screen.findByText('TraceMemo 每日晚报')
await screen.findByText('内部标识规则')
const body = document.body.textContent ?? ''
expect(body).not.toContain(FAKE_ROOM_ID)
expect(body).not.toContain(FAKE_GROUP_HASH.slice(0, 12))
// 对不上群名时给中性文案,而不是把 id 当名字。
expect(screen.getAllByText('未知群聊').length).toBeGreaterThan(0)
})
})
describe('定时日报 · 微信异常通知入口', () => {
it('编辑器里保留异常通知开关,并给出 Agent Hub 的去处', async () => {
installApi()
renderWorkspace()
await openScheduledTab()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: '+ 新建定时日报' }))
expect(screen.getByRole('heading', { name: '微信异常通知' })).toBeVisible()
expect(
screen.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')
).toBeVisible()
})
})
describe('定时日报 · 首页汇总卡', () => {
it('首页把它当"多条规则"汇总,而不是一条 singleton', async () => {
installApi()
@@ -563,7 +546,6 @@ describe('定时日报 · 首页汇总卡', () => {
const card = (await screen.findByText('定时日报')).closest('article') as HTMLElement
expect(card.textContent).toContain('2 条规则 · 1 条运行中')
expect(card.textContent).toContain('按设定时间')
// 汇总卡不该有单个启停开关。
expect(within(card).queryByRole('switch')).toBeNull()
await userEvent.setup().click(within(card).getByRole('button', { name: '管理定时日报 →' }))
@@ -571,7 +553,7 @@ describe('定时日报 · 首页汇总卡', () => {
})
})
describe('定时日报 · 深链与旧页面', () => {
describe('定时日报 · 深链', () => {
it('深链直接落到「自动化 → 规则 → 定时日报」', async () => {
installApi()
@@ -592,38 +574,15 @@ describe('定时日报 · 深链与旧页面', () => {
expect(await screen.findByText('2 条规则')).toBeVisible()
expect(screen.getByRole('radio', { name: '定时日报' })).toBeChecked()
})
it('旧「日报 → 定时日报」页面仍在,且入口能触发跳转', async () => {
installApi()
const onOpenAutomation = vi.fn()
render(
<ToastProvider>
<ScheduledReportsWorkspace
contacts={[]}
onOpenWechatSettings={() => {}}
onOpenAgentHub={() => {}}
onNotice={() => {}}
onOpenAutomation={onOpenAutomation}
/>
</ToastProvider>
)
expect(screen.getByRole('heading', { name: '定时日报' })).toBeVisible()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: '在自动化中配置 →' }))
expect(onOpenAutomation).toHaveBeenCalledTimes(1)
})
})
/** 让 `waitFor` 在本文件被使用(空态/异步断言需要)。 */
describe('定时日报 · 异步加载', () => {
it('加载完成后不再显示 loading', async () => {
installApi()
describe('定时日报 · 空态', () => {
it('没有规则时给空态与新建入口', async () => {
installApi([])
renderWorkspace()
await openScheduledTab()
await waitFor(() => expect(screen.queryByText('正在读取定时日报…')).toBeNull())
expect(await screen.findByText('还没有定时日报')).toBeVisible()
expect(screen.getAllByRole('button', { name: '+ 新建定时日报' }).length).toBeGreaterThan(0)
})
})
@@ -1,259 +0,0 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ScheduledReportsWorkspace } from '../../src/renderer/src/components/reports/ScheduledReportsWorkspace'
import type { Contact } from '../../src/shared/types'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
const group: Contact = {
md5: 'group-md5',
m_nsUsrName: 'group@chatroom',
m_nsNickName: '技术交流',
type: 'group'
}
const capability = (
status: PersonalWechatSendCapability['status']
): PersonalWechatSendCapability => ({
supported: status !== 'unsupported',
ready: status === 'ready',
status,
capabilities: { text: status === 'ready', image: status === 'ready', voice: status === 'ready' },
senderStatus: {} as PersonalWechatSendCapability['senderStatus'],
message: ''
})
const task = {
id: 'task-1',
name: '技术交流 · 每日日报',
group: group.md5,
target: group.m_nsUsrName,
scheduleTime: '09:00',
reportRange: 'yesterday' as const,
enabled: true,
createdAt: '2026-08-26T00:00:00.000Z',
updatedAt: '2026-08-26T00:00:00.000Z',
nextRunAt: '2026-08-28T01:00:00.000Z'
}
describe('ScheduledReportsWorkspace', () => {
const onNotice = vi.fn()
const onOpenWechatSettings = vi.fn()
const onOpenAgentHub = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getPersonalWechatSendCapability: vi.fn(async () => capability('ready')),
getScheduledReportNotificationSettings: vi.fn(async () => ({ enabled: false })),
setScheduledReportNotificationEnabled: vi.fn(async (enabled: boolean) => ({
success: true,
data: { enabled }
})),
listScheduledReports: vi.fn(async () => [task]),
listScheduledReportExecutions: vi.fn(async () => []),
createScheduledReport: vi.fn(async () => ({ success: true, data: task })),
updateScheduledReport: vi.fn(async () => ({ success: true, data: task })),
deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })),
setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })),
runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } })),
retryScheduledReportSend: vi.fn(async () => ({
success: true,
data: { status: 'success' }
})),
testScheduledReportErrorNotification: vi.fn(async () => ({
success: true,
data: { status: 'failed', notificationStatus: 'sent' }
}))
}
})
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('loads capability and renders a scheduled report task', async () => {
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
expect(screen.getByText('正在检查微信发送能力…')).toBeInTheDocument()
await waitFor(() => expect(screen.getByText('✓ 微信发送能力已就绪')).toBeVisible())
expect(screen.getByText(task.name)).toBeVisible()
expect(screen.getByText('每天 09:00')).toBeVisible()
})
it('keeps creation available and offers settings when WeChat is unsupported', async () => {
vi.mocked(window.api.getPersonalWechatSendCapability).mockResolvedValue(
capability('unsupported')
)
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() =>
expect(screen.getByText('微信消息发送目前仅支持 macOS 和 Windows')).toBeVisible()
)
expect(screen.getByRole('button', { name: /新建定时日报/ })).toBeEnabled()
})
it('opens the shared dialog and creates a task', async () => {
vi.mocked(window.api.listScheduledReports).mockResolvedValue([])
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible())
fireEvent.click(screen.getAllByRole('button', { name: /新建定时日报/ })[0])
expect(screen.getByRole('dialog')).toBeVisible()
const dialog = screen.getByRole('dialog')
fireEvent.change(within(dialog).getByPlaceholderText('例如:技术交流 · 每日日报'), {
target: { value: '测试日报' }
})
fireEvent.click(within(dialog).getByRole('button', { name: '创建定时日报' }))
await waitFor(() =>
expect(window.api.createScheduledReport).toHaveBeenCalledWith(
expect.objectContaining({ name: '测试日报', group: group.md5, target: group.m_nsUsrName })
)
)
})
it('keeps scheduled report range labels readable in compact layouts', async () => {
vi.mocked(window.api.listScheduledReports).mockResolvedValue([])
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText('还没有定时日报')).toBeVisible())
fireEvent.click(screen.getAllByRole('button', { name: /新建定时日报/ })[0])
const range = screen.getByRole('radiogroup', { name: '总结范围' })
expect(range).toHaveClass('grid-cols-2', 'sm:grid-cols-5')
expect(screen.getByText('自定义', { exact: true })).toHaveClass('whitespace-nowrap')
expect(screen.getByText('即将支持', { exact: true })).toHaveClass('whitespace-nowrap')
})
it('keeps the notification switch off by default and loads the persisted setting', async () => {
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
const notificationSwitch = screen.getByRole('switch', { name: '微信异常通知' })
await waitFor(() => expect(notificationSwitch).toHaveAttribute('aria-checked', 'false'))
expect(window.api.getScheduledReportNotificationSettings).toHaveBeenCalledOnce()
})
it('restores the switch off and offers Agent Hub when enabling fails', async () => {
vi.mocked(window.api.setScheduledReportNotificationEnabled).mockResolvedValue({
success: false,
data: { enabled: false },
reason: 'agent_hub_offline',
error: '需要先连接 Agent Hub 微信机器人,才能接收异常通知。'
})
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText('微信异常通知')).toBeVisible())
fireEvent.click(screen.getByRole('switch', { name: '微信异常通知' }))
await waitFor(() =>
expect(screen.getByText('需要先连接 Agent Hub 微信机器人,才能接收异常通知。')).toBeVisible()
)
expect(screen.getByRole('switch', { name: '微信异常通知' })).toHaveAttribute(
'aria-checked',
'false'
)
fireEvent.click(screen.getByRole('button', { name: '前往 Agent Hub' }))
expect(onOpenAgentHub).toHaveBeenCalledOnce()
})
it('shows the debug notification action only when enabled and sends the test request', async () => {
vi.stubEnv('VITE_SCHEDULED_REPORT_DEBUG', 'true')
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText(task.name)).toBeVisible())
fireEvent.click(screen.getByRole('button', { name: '测试错误信息发送' }))
await waitFor(() =>
expect(window.api.testScheduledReportErrorNotification).toHaveBeenCalledWith(task.id)
)
expect(onNotice).toHaveBeenCalledWith(
'测试错误信息已发送到 Agent Hub 微信通知接收者',
'success'
)
})
it('confirms a manual resend and does not retry when the dialog is cancelled', async () => {
const execution = {
id: 'execution-1',
taskId: task.id,
startedAt: '2026-08-27T01:00:00.000Z',
finishedAt: '2026-08-27T01:01:00.000Z',
status: 'waiting_to_send' as const,
sendTarget: group.m_nsUsrName,
userMessage: '日报已生成,但微信暂不可用'
}
vi.mocked(window.api.listScheduledReportExecutions).mockResolvedValue([execution])
render(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
await waitFor(() => expect(screen.getByText('日报已生成,但微信暂不可用')).toBeVisible())
fireEvent.click(screen.getByRole('button', { name: '重新发送' }))
const dialog = await screen.findByRole('alertdialog')
expect(within(dialog).getByText('目标:' + group.m_nsUsrName)).toBeVisible()
expect(within(dialog).getByText('日报:技术交流 · 每日日报')).toBeVisible()
expect(within(dialog).getByText(/日期:/)).toBeVisible()
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
expect(window.api.retryScheduledReportSend).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '重新发送' }))
const secondDialog = await screen.findByRole('alertdialog')
fireEvent.click(within(secondDialog).getByRole('button', { name: '确认重新发送' }))
await waitFor(() =>
expect(window.api.retryScheduledReportSend).toHaveBeenCalledWith(execution.id)
)
expect(window.api.retryScheduledReportSend).toHaveBeenCalledTimes(1)
})
})
+53 -1
View File
@@ -22,7 +22,10 @@ vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] }
}))
import { AUTOMATION_SEND_PURPOSE } from '../../src/shared/automation'
import {
AUTOMATION_SEND_PURPOSE,
BUILTIN_DAILY_REPORT_RULE_ID
} from '../../src/shared/automation'
import type {
PersonalWechatSendCapability,
PersonalWechatSendRequest
@@ -273,4 +276,53 @@ describe('automation end-to-end chain', () => {
await chain.service.handleMessage(message({ content: '@我 今日日报', mentionTargets: [] }))
expect(chain.sends).toHaveLength(0)
})
it('同一条 store 里存在定时日报规则时,@我消息只触发 @我日报规则(规则类型硬分派)', async () => {
const chain = buildChain()
// 定时日报是**时间驱动**,不参与消息匹配。硬分派一旦写错,它的
// 「空关键词 + 不要求 @我」会让它命中**每一条群消息** ——
// 这正是这条用例要守住的边界。
const scheduled = chain.store.createRule({
name: '每天 09:00 的定时日报',
enabled: true,
ruleType: 'scheduled_report',
trigger: 'message',
scope: 'group',
conditions: {
requireMentionMe: false,
keyword: '',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 0,
scheduledReport: {
schedule: { time: '09:00' },
report: {
sourceConversationId: GROUP_ID,
range: 'yesterday',
messageTypes: ['text'],
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target: { type: 'file_transfer' }
}
})
expect(scheduled.ruleType).toBe('scheduled_report')
await chain.service.handleMessage(message())
// 只有 @我日报那条留下执行记录;定时日报规则**一条都没有**。
const records = chain.log.list()
expect(records).toHaveLength(1)
expect(records[0].ruleId).toBe(BUILTIN_DAILY_REPORT_RULE_ID)
expect(records.some((record) => record.ruleId === scheduled.id)).toBe(false)
// 发送也只有 @我日报的那两次(确认文字 + 日报图片),没有第三条。
expect(chain.sends.map((send) => send.type)).toEqual(['text', 'image'])
})
})
+53 -4
View File
@@ -160,13 +160,62 @@ describe('preload IPC contract', () => {
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:send', reportImageRequest)
await api.getPersonalWechatVoiceDiagnostic()
expect(invoke).toHaveBeenLastCalledWith('wechat-personal:getVoiceDiagnostic')
})
/**
* 定时日报退役后,专属能力全部改走 `automation:*`。
*
* 单独成一个用例(而不是塞进上面那条超长用例)有两个理由:
* 1. 上面的用例在 `voice:recognize` 上会因 vitest 4 对**尾随 undefined** 的严格比较而失败,
* 放在一起会让这几条断言永远跑不到;
* 2. 「旧通道必须彻底消失」本身就是一条独立契约,值得有自己的名字。
*/
it('routes scheduled-report capabilities through automation channels only', async () => {
const api = await loadApi()
invoke.mockResolvedValue({ success: true })
await api.runScheduledReportRule('rule-1')
expect(invoke).toHaveBeenLastCalledWith('automation:runScheduledReportRule', 'rule-1')
await api.listScheduledReportLegacyExecutions('rule-1')
expect(invoke).toHaveBeenLastCalledWith(
'automation:listScheduledReportLegacyExecutions',
'rule-1'
)
await api.getScheduledReportNotificationSettings()
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:getNotificationSettings')
expect(invoke).toHaveBeenLastCalledWith('automation:getScheduledReportNotificationSettings')
await api.getScheduledReportNotificationCapability()
expect(invoke).toHaveBeenLastCalledWith('automation:getScheduledReportNotificationCapability')
await api.setScheduledReportNotificationEnabled(true)
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:setNotificationEnabled', true)
await api.testScheduledReportErrorNotification('task-1')
expect(invoke).toHaveBeenLastCalledWith('scheduled-report:testErrorNotification', 'task-1')
expect(invoke).toHaveBeenLastCalledWith(
'automation:setScheduledReportNotificationEnabled',
true
)
await api.testScheduledReportErrorNotification('rule-1')
expect(invoke).toHaveBeenLastCalledWith(
'automation:testScheduledReportErrorNotification',
'rule-1'
)
// 旧的 `scheduled-report:*` 通道必须**彻底不存在**:
// 留一个"能用但没人维护"的入口,等于给双写 / 双读留后门。
const channels = invoke.mock.calls.map((call) => String(call[0]))
expect(channels.some((channel) => channel.startsWith('scheduled-report:'))).toBe(false)
const legacyMethods = [
'listScheduledReports',
'listScheduledReportExecutions',
'createScheduledReport',
'updateScheduledReport',
'deleteScheduledReport',
'setScheduledReportEnabled',
'runScheduledReportNow',
'retryScheduledReportSend'
]
for (const method of legacyMethods) {
expect(api).not.toHaveProperty(method)
}
})
it('preserves key API return values without exposing ipcRenderer', async () => {
+86 -51
View File
@@ -4,7 +4,9 @@ const fixture = vi.hoisted(() => ({
capability: {
supported: true,
ready: true,
status: 'ready' as const,
// 显式标注成联合类型:否则会被收窄成字面量 'ready',
// 后面把它改成 'needs_binding' 时就不合法了。
status: 'ready' as PersonalWechatSendCapabilityState,
capabilities: { text: true, image: true, voice: true },
senderStatus: {} as never,
message: '个人微信已准备好发送日报'
@@ -58,7 +60,14 @@ vi.mock('../../src/main/services/agent-hub-service', () => ({
}))
import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../../src/shared/scheduled-report'
import type { PersonalWechatSendCapabilityState } from '../../src/shared/personal-wechat'
import type { ScheduledReportApiDependencies } from '../../src/main/services/scheduled-report-api-service'
const TOKEN = 'A'.repeat(43)
@@ -87,57 +96,76 @@ async function startFixture(
options: { tasks?: ScheduledReportTask[] } = {}
): Promise<{ handle: HttpServerHandle; service: ScheduledReportApiDependencies['service'] }> {
const tasks = options.tasks || []
const executions: Array<Record<string, unknown>> = []
const service = {
listTasks: vi.fn(async () => tasks),
listExecutions: vi.fn(async (taskId?: string) =>
taskId ? executions.filter((item) => item.taskId === taskId) : executions
const executions: ScheduledReportExecution[] = []
const service: ScheduledReportApiDependencies['service'] = {
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
listExecutions: vi.fn(
async (taskId?: string): Promise<ScheduledReportExecution[]> =>
taskId ? executions.filter((item) => item.taskId === taskId) : executions
),
createTask: vi.fn(async (input: Record<string, unknown>) => {
const created = makeTask({
id: `task-${tasks.length + 1}`,
name: String(input.name),
group: String(input.group),
target: String(input.target),
scheduleTime: String(input.scheduleTime),
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
enabled: Boolean(input.enabled)
})
tasks.push(created)
return { success: true, data: created }
}),
updateTask: vi.fn(async (id: string, input: Record<string, unknown>) => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}),
deleteTask: vi.fn(async (id: string) => {
const index = tasks.findIndex((item) => item.id === id)
if (index < 0) return { success: false, error: '未找到定时日报任务' }
tasks.splice(index, 1)
return { success: true, data: { deletedId: id } }
}),
setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, enabled }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}),
runScheduledReportNow: vi.fn(async (id: string) => {
const execution = {
id: `execution-${executions.length + 1}`,
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
finishedAt: '2026-08-27T01:01:00.000Z',
status: 'success' as const,
message: '日报生成成功,微信发送成功'
createTask: vi.fn(
async (
input: ScheduledReportCreateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const created = makeTask({
id: `task-${tasks.length + 1}`,
name: String(input.name),
group: String(input.group),
target: String(input.target),
scheduleTime: String(input.scheduleTime),
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
enabled: Boolean(input.enabled)
})
tasks.push(created)
return { success: true, data: created }
}
executions.push(execution)
return { success: true, data: execution }
})
),
updateTask: vi.fn(
async (
id: string,
input: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}
),
deleteTask: vi.fn(
async (id: string): Promise<ScheduledReportResult<{ deletedId: string }>> => {
const index = tasks.findIndex((item) => item.id === id)
if (index < 0) return { success: false, error: '未找到定时日报任务' }
tasks.splice(index, 1)
return { success: true, data: { deletedId: id } }
}
),
setTaskEnabled: vi.fn(
async (
id: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, enabled }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}
),
runScheduledReportNow: vi.fn(
async (id: string): Promise<ScheduledReportResult<ScheduledReportExecution>> => {
const execution: ScheduledReportExecution = {
id: `execution-${executions.length + 1}`,
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
finishedAt: '2026-08-27T01:01:00.000Z',
status: 'success',
message: '日报生成成功,微信发送成功'
}
executions.push(execution)
return { success: true, data: execution }
}
)
}
const handle = await startHttpServer('127.0.0.1', 0, {
tokenProvider: () => TOKEN,
@@ -222,6 +250,13 @@ describe('scheduled report Local HTTP API', () => {
})
expect(await capability.json()).toMatchObject({ capability: { status: 'ready' } })
expect(service.createTask).toHaveBeenCalledOnce()
// HTTP 契约把群解析成**会话 md5**、目标解析成 roomId 后交给服务层
//(服务层再把这些标识收敛成稳定会话 id)。
expect(vi.mocked(service.createTask).mock.calls[0][0]).toMatchObject({
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:00'
})
})
it('allows unavailable sending capability while still rejecting ambiguous groups', async () => {
@@ -0,0 +1,659 @@
import fs from 'fs-extra'
import path from 'path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const root = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = require('node:fs') as typeof import('node:fs')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require('node:os') as typeof import('node:os')
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('node:path') as typeof import('node:path')
return fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-scheduled-report-automation-'))
})
const SELF = 'my_account_9527'
/** 联系人表必须在 `vi.hoisted` 里 —— `vi.mock` 的工厂是提前求值的。 */
const contacts = vi.hoisted(() => [
{
md5: 'tech-md5',
m_nsUsrName: 'tech@chatroom',
m_nsNickName: '技术交流群',
type: 'group' as const
},
{
m_nsUsrName: 'wxid_friend',
m_nsNickName: '好友昵称',
remark: '好友备注',
type: 'user' as const
}
])
vi.mock('electron', () => ({
app: { getPath: () => root },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (value: string) => Buffer.from(value, 'utf8'),
decryptString: (value: Buffer) => value.toString('utf8')
},
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('../../src/main/services/chat-service', () => ({
listContacts: () => contacts,
getSelfAccountInfo: () => ({ wxid: 'my_account_9527' }),
// 头像补全是可选增强:这里显式给一个空实现,避免 runner 落到未 mock 的导出上刷警告。
getContactAvatars: async () => ({})
}))
import type {
PersonalWechatSendCapability,
PersonalWechatSendRequest
} from '../../src/shared/personal-wechat'
import type { Wcdb4Client } from '../../src/main/wcdb4-client'
import type { GeneratedReportRecord } from '../../src/shared/report-history'
import type { AgentGroupReportResult } from '../../src/main/services/agent-group-report-service'
import type {
AutomationRule,
AutomationRuleDraft,
ScheduledReportTargetType
} from '../../src/shared/automation'
import { WechatActionGateway } from '../../src/main/services/wechat-action-gateway'
import { AutomationRuleStore } from '../../src/main/services/automation-rule-store'
import { AutomationExecutionLogService } from '../../src/main/services/automation-execution-log-service'
import { AutomationActionRunner } from '../../src/main/services/automation-action-runner'
import { AutomationService } from '../../src/main/services/automation-service'
import { ScheduledReportService } from '../../src/main/services/scheduled-report-service'
/**
* 定时日报的**端到端链路**。
*
* Scheduler(`ScheduledReportService.tick`)→ `AutomationService.executeScheduledRule`
* → `AutomationActionRunner.runScheduledReport` → `WechatActionGateway` → 被替换掉的传输层。
*
* 除了「微信怎么把图片发出去」,其余**全部是生产实现**。这个文件的存在理由:
* - `AUTOMATION_PURPOSE_ALLOWLIST` 一旦漏登记 `automation_scheduled_report`,
* 整条链会在策略层被**静默**拦下(ACTION_NOT_ALLOWED),而每个单元测试都还是绿的;
* - `scheduledInFlight` / 确定性 executionId 这两道并发闸,只有真的拼起来才看得出来;
* - 四个发送目标、槽位游标、执行日志的 trigger 区分,都是"拼起来"才成立的语义。
*/
function readyCapability(): PersonalWechatSendCapability {
return {
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: false },
message: '个人微信已准备好发送日报',
senderStatus: {
state: 'online',
platform: 'darwin',
arch: 'arm64',
sipDisabled: false,
wechatRunning: true,
endpoint: '127.0.0.1:0',
endpointReady: true,
runtimeReady: true,
attachReady: true,
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: false,
message: '个人微信已准备好发送日报'
}
}
}
const RULE_NAME = '技术交流群 · 每日晚报'
const SCHEDULE_TIME = '18:30'
/** 规则的创建时间固定在 2026-08-20,必须早于所有参与断言的槽位。 */
const CREATED_AT = Date.parse('2026-08-20T09:00:00+08:00')
/** 2026-08-27 18:31,当天 18:30 的槽位已经到点。 */
const AFTER_SLOT = new Date('2026-08-27T18:31:00+08:00')
/**
* `AutomationService` / `AutomationRuleStore` 的 `now` 是**毫秒数**,
* `ScheduledReportService` 的 `now` 是 **`Date`** —— 两者刻意分开命名,
* 避免把毫秒数丢给需要 `Date` 的那一个(那会在推通知时抛 `toISOString` 错误)。
*/
const AFTER_SLOT_MS = AFTER_SLOT.getTime()
const SLOT_ISO = new Date('2026-08-27T18:30:00+08:00').toISOString()
function scheduledDraft(
target: ScheduledReportTargetType,
options: { contactId?: string; enabled?: boolean; postfixText?: string } = {}
): AutomationRuleDraft {
return {
name: RULE_NAME,
enabled: options.enabled !== false,
ruleType: 'scheduled_report',
trigger: 'message',
scope: 'group',
conditions: {
requireMentionMe: false,
keyword: '',
keywordMatchMode: 'contains',
conversationIds: [],
ignoreSelf: true
},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
scheduledReport: {
schedule: { time: SCHEDULE_TIME },
report: {
sourceConversationId: 'tech@chatroom',
range: 'yesterday',
messageTypes: ['text'],
templateId: 'v1',
memberNameMode: 'groupNickname',
timeoutSeconds: 300
},
target:
target === 'contact'
? { type: 'contact', contactId: options.contactId ?? 'wxid_friend' }
: { type: target },
// 只有显式给了后置词才写进规则:空字符串意味着"只发图片",
// 这正是存量规则迁移过来的形态,不能在测试工厂里被悄悄补上默认值。
...(options.postfixText ? { postfixText: options.postfixText } : {})
}
}
}
interface Chain {
scheduler: ScheduledReportService
service: AutomationService
store: AutomationRuleStore
log: AutomationExecutionLogService
sends: PersonalWechatSendRequest[]
ruleId: string
}
function buildChain(
options: {
capability?: PersonalWechatSendCapability
target?: ScheduledReportTargetType
contactId?: string
enabled?: boolean
postfixText?: string
generate?: () => Promise<AgentGroupReportResult>
} = {}
): Chain {
const sends: PersonalWechatSendRequest[] = []
const savedReports: GeneratedReportRecord[] = []
const capability = options.capability ?? readyCapability()
const gateway = new WechatActionGateway({
getUserDataPath: () => root,
getCapability: async () => capability,
// 传输层是唯一被替换的部分:策略、幂等、审计、节流都是真的。
send: async (request) => {
sends.push(request)
return { success: true, status: readyCapability().senderStatus }
},
wait: async () => undefined
})
// `now` 固定:否则 `createdAt` 会变成墙上时钟,把「槽位必须晚于创建时间」那道闸踩掉。
const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT })
const log = new AutomationExecutionLogService({ userDataPath: () => root })
const runner = new AutomationActionRunner({
executeAction: (request) => gateway.execute(request),
generateReport:
options.generate ??
(async () => ({
success: true,
groupName: '技术交流群',
pngPath: path.join(root, 'scheduled-report.png'),
messageCount: 42
})),
saveGeneratedReport: async (request) => {
const record: GeneratedReportRecord = {
id: 'report-1',
contactId: request.contactId,
contactName: request.contactName,
source: 'scheduled',
dateRange: request.dateRange,
messageCount: request.messageCount,
generatedAt: request.generatedAt,
reportDate: '2026-08-26',
pngPath: path.join(root, 'scheduled-report.png'),
htmlStatus: 'ready',
pngStatus: 'ready'
}
savedReports.push(record)
return { success: true, record }
},
resolveReportContact: () => ({
md5: 'tech-md5',
m_nsUsrName: 'tech@chatroom',
m_nsNickName: '技术交流群'
})
})
const client = {
getMyUsernameCandidates: () => [SELF],
getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }]
} as unknown as Wcdb4Client
const service = new AutomationService(client, {
ruleStore: store,
executionLog: log,
runner,
getCapability: async () => capability,
now: () => AFTER_SLOT_MS
})
const ruleId = store.createRule(
scheduledDraft(options.target ?? 'source_chat', {
...(options.contactId ? { contactId: options.contactId } : {}),
...(options.enabled === false ? { enabled: false } : {}),
...(options.postfixText ? { postfixText: options.postfixText } : {})
})
).id
const scheduler = new ScheduledReportService({
storageDir: path.join(root, 'scheduled-reports-legacy'),
executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions),
listRules: () => store.listRules(),
listExecutions: (input) => log.list(input),
isDatabaseReady: () => true,
now: () => AFTER_SLOT
})
return { scheduler, service, store, log, sends, ruleId }
}
/** 每个用例都换一个干净的 userData 目录,避免审计 / 幂等记录互相污染。 */
const workdirs: string[] = []
function isolatedRoot(): void {
const dir = fs.mkdtempSync(path.join(root, 'case-'))
workdirs.push(dir)
}
describe('scheduled report end-to-end chain', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
fs.removeSync(path.join(root, 'actions'))
fs.removeSync(path.join(root, 'scheduled-reports-legacy'))
isolatedRoot()
})
afterAll(() => {
for (const dir of workdirs) fs.removeSync(dir)
fs.removeSync(root)
})
it('到点 ⇒ 生成日报 → 落日报历史 → 穿过 Gateway 发出,并记录 schedule 触发', async () => {
const chain = buildChain()
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
// 真的发出去了:说明 purpose 通过了 `AUTOMATION_PURPOSE_ALLOWLIST`。
expect(chain.sends).toHaveLength(1)
expect(chain.sends[0]).toMatchObject({
type: 'image',
to: 'tech@chatroom',
isGroup: true,
filePath: path.join(root, 'scheduled-report.png')
})
const record = chain.log.list()[0]
expect(record.trigger).toBe('schedule')
expect(record.status).toBe('success')
expect(record.steps.map((step) => `${step.key}:${step.status}`)).toEqual([
'schedule_triggered:success',
'report_generating:success',
'report_generated:success',
'send_resolved:success',
'report_sent:success'
])
// `send_resolved` 的 detail 是**用户可读的目标名**,不是 roomId。
expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('技术交流群')
})
it('配置了后置词 ⇒ 图片 sent 之后补发一条文本,两条各自独立', async () => {
const chain = buildChain({ postfixText: '今日日报' })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
/*
* 两条:先图后文。
*
* 后置词走的是**另一个 purpose**(`automation_scheduled_report_postfix`)——
* 只要它在 `AUTOMATION_PURPOSE_ALLOWLIST` 里漏登记,第二次发送就会被策略层
* 以 ACTION_NOT_ALLOWED 静默拦下,这里会只剩 1 条。这正是本用例存在的理由。
*/
expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text'])
expect(chain.sends[0]).toMatchObject({
type: 'image',
to: 'tech@chatroom',
isGroup: true,
filePath: path.join(root, 'scheduled-report.png')
})
expect(chain.sends[1]).toMatchObject({
type: 'text',
to: 'tech@chatroom',
isGroup: true,
text: '今日日报'
})
// 两条都成功,整次执行才是 success(后置词失败会拖垮整次判定,见下一条)。
expect(chain.log.list()[0].status).toBe('success')
})
it('未配置后置词 ⇒ 只发图片,绝不"顺手补一句"', async () => {
const chain = buildChain()
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends.map((send) => send.type)).toEqual(['image'])
expect(chain.log.list()[0].status).toBe('success')
})
it('图片能力缺失 ⇒ 后置词绝不发送(图片没发出去,就没有"之后")', async () => {
const capability = readyCapability()
const chain = buildChain({
postfixText: '今日日报',
capability: { ...capability, capabilities: { ...capability.capabilities, image: false } }
})
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
// 图片都没发出去,后置词更不可能发 —— 传输层零调用。
expect(chain.sends).toHaveLength(0)
expect(chain.log.list()[0].status).toBe('failed')
})
it('图片已发出但后置词失败 ⇒ 如实判失败,不吞掉也不回滚', async () => {
const capability = readyCapability()
// 文字能力缺失:图片照发,但后置词那一步会被 SEND_NOT_READY 拦下。
const chain = buildChain({
postfixText: '今日日报',
capability: { ...capability, capabilities: { ...capability.capabilities, text: false } }
})
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
// 图片确实发出去了 —— 微信发送不支持回滚,所以**必须留痕**,不能假装没发生。
expect(chain.sends.map((send) => send.type)).toEqual(['image'])
const record = chain.log.list()[0]
expect(record.status).toBe('failed')
// 失败点必须精确落在"后置词",而不是伪装成"日报没生成/没发送"。
expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain(
'后置词发送失败'
)
})
it('同一槽位重放 ⇒ 图片与后置词各自幂等,都不会重复发', async () => {
const chain = buildChain({ postfixText: '今日日报' })
await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
// 两次重放合起来仍然只有一条图 + 一条文:两条各有独立幂等位,
// 少给后置词一个 key 会让它在第二次重放时"被图片的幂等位短路掉"。
expect(chain.sends.map((send) => send.type)).toEqual(['image', 'text'])
})
it('四个发送目标各自解析正确(同一个 Gateway,不绑作用域锁)', async () => {
const cases: Array<{ target: ScheduledReportTargetType; to: string; isGroup: boolean }> = [
{ target: 'source_chat', to: 'tech@chatroom', isGroup: true },
{ target: 'file_transfer', to: 'filehelper', isGroup: false },
{ target: 'self', to: SELF, isGroup: false },
{ target: 'contact', to: 'wxid_friend', isGroup: false }
]
for (const item of cases) {
const chain = buildChain({ target: item.target })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(
chain.sends.map((send) => ({ type: send.type, to: send.to, isGroup: send.isGroup }))
).toEqual([{ type: 'image', to: item.to, isGroup: item.isGroup }])
expect(chain.log.list()[0].status).toBe('success')
}
})
it('目标失效 ⇒ 生成成功但**绝不** fallback 到别处,且如实判失败', async () => {
const chain = buildChain({ target: 'contact', contactId: 'wxid_gone' })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends).toHaveLength(0)
const record = chain.log.list()[0]
expect(record.status).toBe('failed')
// 失败点在"发到哪",不能伪装成"生成失败"。
expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success')
expect(record.steps.find((step) => step.key === 'send_resolved')?.status).toBe('failed')
expect(record.steps.find((step) => step.key === 'report_sent')?.status).toBe('skipped')
})
it('停用的规则完全不执行(连日报都不生成)', async () => {
const chain = buildChain({ enabled: false })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends).toHaveLength(0)
expect(chain.log.list()).toHaveLength(0)
})
it('图片能力缺失 ⇒ 日报照生成,但不发送,并说明原因', async () => {
const capability = readyCapability()
const chain = buildChain({
capability: { ...capability, capabilities: { ...capability.capabilities, image: false } }
})
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends).toHaveLength(0)
const record = chain.log.list()[0]
expect(record.status).toBe('failed')
expect(record.steps.find((step) => step.key === 'report_generating')?.status).toBe('success')
expect(record.steps.find((step) => step.key === 'report_sent')?.error).toContain('无法发送图片')
})
it('手动执行与定时执行**共用同一条链路**,只有 trigger 不同', async () => {
const chain = buildChain()
const outcome = await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' })
expect(outcome.executed).toBe(true)
expect(chain.sends).toHaveLength(1)
expect(chain.log.list()[0].trigger).toBe('manual')
})
it('同一条规则并发触发 ⇒ 第二个被 inFlight 挡掉,只发一次', async () => {
const chain = buildChain()
const [first, second] = await Promise.all([
chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
}),
chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
])
const executed = [first, second].filter((item) => item.executed)
const blocked = [first, second].filter((item) => !item.executed)
expect(executed).toHaveLength(1)
expect(blocked).toHaveLength(1)
expect(blocked[0].reason).toBe('in_flight')
expect(chain.sends).toHaveLength(1)
})
it('同一槽位被重复触发 ⇒ 确定性 executionId 让 Gateway 去重,不会重复发', async () => {
const chain = buildChain()
const first = await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
const second = await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
// executionId 由 ruleId + slot 确定性派生,所以两次完全一致。
expect(first.executionId).toBeTruthy()
expect(second.executionId).toBe(first.executionId)
// 传输层只被调用一次 —— 幂等拦在 Gateway,不是靠上层自觉。
expect(chain.sends).toHaveLength(1)
})
it('槽位游标:定时触发消费槽位,手动执行不消费', async () => {
const chain = buildChain()
await chain.service.executeScheduledRule(chain.ruleId, { trigger: 'manual' })
const afterManual = chain.store.getRule(chain.ruleId)?.scheduledReport
expect(afterManual?.lastRunAt).toBeTruthy()
// 手动执行若写了槽位,就会把今天安排好的那次定时执行一起吞掉。
expect(afterManual?.lastScheduledSlot).toBeUndefined()
await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'schedule',
scheduledSlot: SLOT_ISO
})
expect(chain.store.getRule(chain.ruleId)?.scheduledReport?.lastScheduledSlot).toBe(SLOT_ISO)
})
it('调度器只读规则:槽位已消费后重启不再补跑', async () => {
const chain = buildChain()
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
expect(chain.sends).toHaveLength(1)
// 新建一个调度器实例(= 应用重启):规则与游标都从盘上读。
const restarted = new ScheduledReportService({
storageDir: path.join(root, 'scheduled-reports-legacy'),
executeRule: (id, runOptions) => chain.service.executeScheduledRule(id, runOptions),
listRules: () => chain.store.listRules(),
listExecutions: (input) => chain.log.list(input),
isDatabaseReady: () => true,
now: () => AFTER_SLOT
})
await restarted.tick(AFTER_SLOT)
await restarted.settle()
expect(chain.sends).toHaveLength(1)
})
it('执行日志与步骤 detail 不泄露 roomId / wxid / 文件路径', async () => {
const chain = buildChain({ target: 'contact' })
await chain.scheduler.tick(AFTER_SLOT)
await chain.scheduler.settle()
const record = chain.log.list()[0]
const serialized = JSON.stringify(record)
expect(serialized).not.toContain('@chatroom')
expect(serialized).not.toContain('wxid_friend')
expect(serialized).not.toContain(root)
// 但必须留下可读的目标名(备注优先)。
expect(record.steps.find((step) => step.key === 'send_resolved')?.detail).toBe('好友备注')
})
it('定时失败 ⇒ 推一条微信异常通知,且"今天执行"统计与那条日志是同一份', async () => {
const store = new AutomationRuleStore({ userDataPath: () => root, now: () => CREATED_AT })
const log = new AutomationExecutionLogService({ userDataPath: () => root })
const runner = new AutomationActionRunner({
// 生成直接失败 ⇒ 定时执行判 failed。
generateReport: async () => ({
success: false,
error: '模型调用超时',
errorCode: 'AI_TIMEOUT'
})
})
const service = new AutomationService(
{
getMyUsernameCandidates: () => [SELF],
getSessions: () => [{ username: 'tech@chatroom', nickname: '技术交流群' }]
} as unknown as Wcdb4Client,
{
ruleStore: store,
executionLog: log,
runner,
getCapability: async () => readyCapability(),
now: () => AFTER_SLOT_MS
}
)
const ruleId = store.createRule(scheduledDraft('source_chat')).id
const notifications: string[] = []
const scheduler = new ScheduledReportService({
storageDir: path.join(root, 'scheduled-reports-legacy'),
executeRule: (id, runOptions) => service.executeScheduledRule(id, runOptions),
listRules: () => store.listRules(),
listExecutions: (input) => log.list(input),
sendNotification: async (request) => {
notifications.push(request.text)
return { success: true, status: 'sent' }
},
getNotificationRecipient: () => SELF,
getAgentHubStatus: () => ({ hub: 'online', connector: 'online', updatedAt: 0 }),
isDatabaseReady: () => true,
now: () => AFTER_SLOT
})
// 开启异常通知会先试发一条,把它排掉,后面只看执行失败那一条。
await scheduler.setNotificationEnabled(true)
expect(notifications).toHaveLength(1)
notifications.length = 0
await scheduler.tick(AFTER_SLOT)
await scheduler.settle()
expect(log.list()[0].status).toBe('failed')
expect(log.list()[0].ruleId).toBe(ruleId)
expect(notifications).toHaveLength(1)
expect(notifications[0]).toContain(RULE_NAME)
expect(notifications[0]).toContain('模型调用超时')
})
it('规则不存在 / 目标待重选 ⇒ 明确拒绝,不执行也不发送', async () => {
const chain = buildChain()
const missing = await chain.service.executeScheduledRule('rule_does_not_exist', {
trigger: 'manual'
})
expect(missing.executed).toBe(false)
expect(missing.reason).toBe('rule_not_found')
// 把规则改成「目标待重选」:迁移遗留态,执行必然失败 ⇒ 直接拒绝。
const current = chain.store.getRule(chain.ruleId) as AutomationRule
chain.store.updateRule(chain.ruleId, {
...(current as unknown as AutomationRuleDraft),
scheduledReport: { ...current.scheduledReport!, targetNeedsReview: true }
})
const needsReview = await chain.service.executeScheduledRule(chain.ruleId, {
trigger: 'manual'
})
expect(needsReview.executed).toBe(false)
expect(needsReview.reason).toBe('target_needs_review')
expect(chain.sends).toHaveLength(0)
})
})
+2 -6
View File
@@ -1,6 +1,6 @@
{
"note": "测试文件类型检查基线:只允许下降,不允许上升。用 node scripts/typecheck-tests.cjs --update 下调。",
"total": 234,
"total": 209,
"files": {
"tests/component/ai-model-settings.test.tsx": 2,
"tests/component/ai-search-cache-consent.test.tsx": 3,
@@ -10,7 +10,7 @@
"tests/component/database-connection.test.tsx": 2,
"tests/component/export-voice-transcript.test.tsx": 1,
"tests/component/export-workspace.test.tsx": 8,
"tests/component/group-exit-monitor-workspace.test.tsx": 11,
"tests/component/group-exit-monitor-workspace.test.tsx": 10,
"tests/component/image-bubble.test.tsx": 1,
"tests/component/image-text-index-card.test.tsx": 5,
"tests/component/local-image-text-recognition.test.tsx": 1,
@@ -32,13 +32,11 @@
"tests/integration/image-text-index-synthetic-e2e.test.ts": 22,
"tests/integration/query-agent-image-ocr-text.test.ts": 16,
"tests/integration/query-agent-self-direction.test.ts": 7,
"tests/integration/scheduled-report-api.test.ts": 3,
"tests/unit/agent-hub-routing.test.ts": 1,
"tests/unit/ai-search-pipeline-service.test.ts": 1,
"tests/unit/ai-search-provider-consent.test.ts": 5,
"tests/unit/ai-search-workspace-pure-functions.test.ts": 13,
"tests/unit/ask-wechat-service.test.ts": 8,
"tests/unit/group-exit-monitor-service.test.ts": 1,
"tests/unit/group-report.test.ts": 1,
"tests/unit/http-media-service.test.ts": 2,
"tests/unit/knowledge-freshness.test.ts": 1,
@@ -47,8 +45,6 @@
"tests/unit/query-agent-image-ocr-coverage.test.ts": 1,
"tests/unit/query-agent-service.test.ts": 4,
"tests/unit/report-template-market.test.ts": 1,
"tests/unit/scheduled-report-api-service.test.ts": 2,
"tests/unit/scheduled-report-service.test.ts": 18,
"tests/unit/system-ocr-service.test.ts": 3,
"tests/unit/voice-pipeline.test.ts": 1
}
+216 -16
View File
@@ -220,16 +220,37 @@ describe('AutomationRuleStore', () => {
})
})
/*
* 存量安装:早期版本把"部分勾选"判成无法无损映射,于是落盘了
* `targetNeedsReview: true`。这个标记会拦发送(`handleGroupExit` 第②道闸门),
* 所以**用户重存一次必须能清掉它** —— 否则那条规则永远发不出去。
*
* 注意种的是"已经落盘的存量状态",不是靠迁移产出:迁移现在不再产生这个标记了。
*/
it('保存退群通知会清掉迁移遗留的「目标待重选」标记', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom']
})
const store = new AutomationRuleStore(deps(root))
store.saveLeaveNotificationRule({
name: '退群通知',
enabled: false,
ruleType: 'leave_notification',
scope: 'group',
conditions: {},
actions: [],
cooldownSeconds: 0,
replyDelaySeconds: 2,
leaveNotification: {
target: { type: 'source_chat' },
template: '[退群监测]',
notifyScope: 'all',
notifyRoomIds: [],
targetNeedsReview: true
}
})
expect(
store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)?.leaveNotification?.targetNeedsReview
).toBe(true)
// 用户来重存一次:草稿里不带这个标记 ⇒ 归一化后它就该消失。
store.saveLeaveNotificationRule({
...store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID),
enabled: true,
@@ -384,14 +405,17 @@ describe('AutomationRuleStore', () => {
)
expect(leave?.enabled).toBe(false)
// 关闭状态下 target 只是占位值(= 默认目标),不会发送任何东西。
// 关闭状态下 target 只是占位值(= 「发回本群」),不会发送任何东西。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 范围本身也精确:`selected` + 空集 = 「一个群都不通知」。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual([])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
})
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」且保持启用', () => {
it('旧配置在每个被监控群都通知 → 无损映射为「当前群聊」+ 全部范围,且保持启用', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom'],
notificationRoomIds: ['a@chatroom', 'b@chatroom']
@@ -403,15 +427,16 @@ describe('AutomationRuleStore', () => {
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
expect(leave?.leaveNotification?.notifyScope).toBe('all')
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
})
/*
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,新目标是**单值**。
* 只勾了一部分时无法无损映射 —— 这时候:
* 不取第一个群、不改写成当前群聊、保持关闭、要求用户重选,并留一份备份。
* 关键的一条:旧「通知群聊」是**逐群**的多值配置,恢复 `notifyScope` 之后
* 它就是一个正常可表达的字段 —— 所以这里**不再降级成 needs_review**:
* 那份勾选被原样保留、规则保持启用、不会再多一次"被无辜停用"的体验。
*/
it('旧配置只勾了部分群 → 标记待重选、不发送、留下备份,绝不猜', () => {
it('旧配置只勾了部分群 → 原样保留那份子集,保持启用,不做有损改写', () => {
writeLegacyMonitorState({
monitoredRoomIds: ['a@chatroom', 'b@chatroom', 'c@chatroom'],
notificationRoomIds: ['b@chatroom'],
@@ -421,11 +446,13 @@ describe('AutomationRuleStore', () => {
const store = new AutomationRuleStore(deps(root))
const leave = store.getRule(BUILTIN_LEAVE_NOTIFICATION_RULE_ID)
expect(leave?.enabled).toBe(false)
expect(leave?.leaveNotification?.targetNeedsReview).toBe(true)
// 不回落到「取第一个群」,也不改写成别的具体目标 —— 只留默认占位(当前群聊),
// 且因为 enabled=false + targetNeedsReview=true,在用户重选之前不会发送。
expect(leave?.enabled).toBe(true)
expect(leave?.leaveNotification?.targetNeedsReview).toBeUndefined()
// 收件人恒为事件所在群(旧版 notifyGroup 的事实)⇒ target 仍是 source_chat。
expect(leave?.leaveNotification?.target).toEqual({ type: 'source_chat' })
// 二次勾选逐字保留:只通知 b,不多不少。
expect(leave?.leaveNotification?.notifyScope).toBe('selected')
expect(leave?.leaveNotification?.notifyRoomIds).toEqual(['b@chatroom'])
// 用户写的模板内容保留;同一次启动的"补群名"升级会另加一行。
expect(leave?.leaveNotification?.template).toContain('退群: {user}')
expect(leave?.leaveNotification?.template).toContain('群聊: {groupName}')
@@ -434,10 +461,10 @@ describe('AutomationRuleStore', () => {
path.join(root, 'automation', 'leave-notification-migration-backup.json')
)
expect(backup).toMatchObject({
outcome: 'needs_review',
outcome: 'lossless_selected_groups',
legacy: { notificationRoomIds: ['b@chatroom'] }
})
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('needs_review')
expect(store.getLastLeaveNotificationMigration()?.outcome).toBe('lossless_selected_groups')
})
it('迁移只跑一次:重启两次也只有一条退群通知规则', () => {
@@ -477,6 +504,179 @@ describe('AutomationRuleStore', () => {
})
})
/**
* 旧「定时日报任务」→ `scheduled_report` 规则的 store 级迁移。
*
* 纯映射器的逐字段断言在 `scheduled-report-migration.test.ts`;
* 这里只守 store 负责的三件事:**插几条 / 幂等 / 解析器不可用时推迟**。
*/
describe('AutomationRuleStore · 定时日报迁移', () => {
const legacyTasksFile = (): string => path.join(root, 'scheduled-reports', 'tasks.json')
const backupFile = (): string =>
path.join(root, 'automation', 'scheduled-report-migration-backup.json')
/** 与 main 侧注入的真实解析器同口径(`@chatroom` 原样透传)。 */
const resolver = (raw: string): string | undefined => {
const key = raw.trim()
if (!key) return undefined
if (key.endsWith('@chatroom')) return key
return { 'tech-md5': 'tech@chatroom', 'product-md5': 'product@chatroom' }[key]
}
const writeLegacyTasks = (value: unknown): void => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeJsonSync(legacyTasksFile(), value)
}
const legacyTasks = (): unknown[] => [
{
id: 'scheduled_report_a',
name: 'A 群日报',
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:00',
reportRange: 'yesterday',
enabled: true,
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z'
},
{
id: 'scheduled_report_b',
name: 'B 群日报',
group: 'product-md5',
target: 'product@chatroom',
scheduleTime: '10:00',
reportRange: 'today',
enabled: false,
createdAt: '2026-08-02T01:00:00.000Z',
updatedAt: '2026-08-02T01:00:00.000Z'
},
{
id: 'scheduled_report_c',
name: '发到别的群',
group: 'tech-md5',
target: 'product@chatroom',
scheduleTime: '11:00',
reportRange: 'today',
enabled: true,
createdAt: '2026-08-03T01:00:00.000Z',
updatedAt: '2026-08-03T01:00:00.000Z'
}
]
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
fs.removeSync(path.join(root, 'scheduled-reports'))
})
it('3 条旧任务 ⇒ 3 条 scheduled_report 规则(连同内置规则一起),且判定分布如实', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
const scheduled = store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
expect(scheduled.map((rule) => rule.id).sort()).toEqual([
'scheduled_report_a',
'scheduled_report_b',
'scheduled_report_c'
])
const summary = store.getLastScheduledReportMigration()
expect(summary).toEqual({
total: 3,
migrated: 3,
lossless: 2,
needsReview: 1,
duplicatesSkipped: 0
})
// 「发到别的群」那条:不自动改写目标,且必须停用。
const needsReview = scheduled.find((rule) => rule.id === 'scheduled_report_c')
expect(needsReview?.enabled).toBe(false)
expect(needsReview?.scheduledReport?.targetNeedsReview).toBe(true)
// 旧任务是启用的,但迁移后必须停用 —— 不能替用户决定发到哪儿。
expect(store.getRule('scheduled_report_a')?.enabled).toBe(true)
})
it('二次启动不会重复迁移(仍只有 3 条,不是 6 条)', () => {
writeLegacyTasks(legacyTasks())
const first = new AutomationRuleStore(deps(root))
first.setLegacyConversationResolver(resolver)
expect(
first.listRules().filter((rule) => rule.ruleType === 'scheduled_report')
).toHaveLength(3)
// 第二个实例 = 重启:从盘上读,旧 tasks.json 仍在原处。
const second = new AutomationRuleStore(deps(root))
second.setLegacyConversationResolver(resolver)
const ids = second
.listRules()
.filter((rule) => rule.ruleType === 'scheduled_report')
.map((rule) => rule.id)
expect(ids).toHaveLength(3)
expect(new Set(ids).size).toBe(3)
// 已迁移标记落盘 ⇒ 不再有待迁任务。
expect(second.hasPendingScheduledReportMigration()).toBe(false)
})
it('解析器不可用时**推迟**迁移:不置标记、不插规则、如实报告待迁', () => {
writeLegacyTasks(legacyTasks())
const store = new AutomationRuleStore(deps(root))
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(true)
expect(store.getLastScheduledReportMigration()).toBeNull()
// 数据库就绪后注入解析器 ⇒ 立刻补跑。
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(3)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
})
it('迁移前把旧任务原样落一份 backup(用户数据可回溯)', () => {
const tasks = legacyTasks()
writeLegacyTasks(tasks)
const store = new AutomationRuleStore(deps(root))
// 先注入解析器、后首次加载:这正是 main 的真实顺序(构造函数不读盘)。
// `setLegacyConversationResolver` 在未加载时只记住 resolver,
// 迁移推迟到第一次 `ensureLoaded()`,注入顺序不影响结果。
store.setLegacyConversationResolver(resolver)
store.listRules()
expect(fs.pathExistsSync(backupFile())).toBe(true)
const backup = fs.readJsonSync(backupFile()) as {
summary: { migrated: number }
legacyTasks: unknown[]
}
expect(backup.summary.migrated).toBe(3)
// 备份里保留**原始字段**(包括旧目标原文),否则出了问题无法回溯。
expect(backup.legacyTasks).toEqual(tasks)
// 旧 tasks.json 不被删除或改写 —— 迁移必须是可逆的。
expect(fs.readJsonSync(legacyTasksFile())).toEqual(tasks)
})
it('没有旧任务时走全新安装路径:不插任何定时日报规则,也不报待迁', () => {
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
expect(store.hasPendingScheduledReportMigration()).toBe(false)
expect(store.getLastScheduledReportMigration()).toBeNull()
expect(fs.pathExistsSync(backupFile())).toBe(false)
})
it('旧文件损坏时按「没有旧任务」处理,绝不抛异常挡住启动', () => {
fs.ensureDirSync(path.dirname(legacyTasksFile()))
fs.writeFileSync(legacyTasksFile(), 'not json at all', 'utf8')
const store = new AutomationRuleStore(deps(root))
store.setLegacyConversationResolver(resolver)
expect(store.listRules().filter((rule) => rule.ruleType === 'scheduled_report')).toHaveLength(0)
})
})
describe('AutomationExecutionLogService', () => {
beforeEach(() => {
fs.removeSync(path.join(root, 'automation'))
@@ -201,6 +201,65 @@ describe('AutomationService.handleGroupExit', () => {
expect(harness.executions).toHaveLength(0)
})
/*
* 「通知范围」=历史「退群监控 → 通知群聊」的第二层。
* 缺了它,规则一启用就等于给全部已监控群聊发通知 —— 就是那个被报回来的问题。
*/
it('范围 = 仅选中的群聊:勾中的群照常发送', async () => {
const harness = createHarness({
config: { notifyScope: 'selected', notifyRoomIds: ['room@chatroom'] }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
expect(harness.executions).toHaveLength(1)
})
it('范围 = 仅选中的群聊:没勾的群不发,且不创建 execution', async () => {
const harness = createHarness({
config: { notifyScope: 'selected', notifyRoomIds: ['other@chatroom'] }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
// 被范围排除**不是**"一次失败的执行",所以不能留失败记录吓人。
expect(harness.executions).toHaveLength(0)
})
it('范围 = 全部已监控群聊:不筛群,照常发送', async () => {
const harness = createHarness({ config: { notifyScope: 'all', notifyRoomIds: [] } })
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
})
/*
* 范围判定必须在幂等**之前**:否则被排除的群会白白占用该事件的幂等位,
* 用户随后补勾了那个群,同一个事件反而再也发不出去。
*/
it('范围外不占用幂等位:补勾之后同一事件仍能发出', async () => {
const harness = createHarness({
config: { notifyScope: 'selected', notifyRoomIds: [] }
})
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(0)
expect(harness.executions).toHaveLength(0)
harness.setRule(
createDefaultLeaveNotificationRule(NOW, {
notifyScope: 'selected',
notifyRoomIds: ['room@chatroom']
})
)
await harness.service.handleGroupExit(exitEvent())
expect(harness.requests).toHaveLength(1)
})
it('缺少 eventId 时直接跳过,绝不发送', async () => {
const harness = createHarness()
@@ -260,7 +319,7 @@ describe('AutomationService.handleGroupExit', () => {
})
/*
* §「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
* 「旧联系人失效」:不偷偷发给别人,execution 记 failed,Gateway 一次都不调。
*/
it('指定好友已不存在 → execution failed 且不调用 Gateway', async () => {
const harness = createHarness({
@@ -353,7 +412,7 @@ describe('AutomationService.handleGroupExit', () => {
})
/*
* §「不同事件」:同群两个成员先后退出就是两条通知。
* 不同事件:同群两个成员先后退出就是两条通知。
* 退群通知**不复用**消息型规则的 cooldown,否则第二次会被时间窗吞掉。
*/
it('不同 eventId 各自执行一次,不被合并', async () => {
+54 -22
View File
@@ -45,6 +45,23 @@ const contacts: LeaveNotificationCandidateContact[] = [
{ m_nsUsrName: 'wxid_self', m_nsNickName: '我自己', type: 'user' }
]
/**
* 构造一份合法配置。
*
* 本文件关心的是**目标解析**,通知范围(`notifyScope`)与之正交,
* 所以在这里统一补默认值 —— 契约再新增字段时也只改这一处,
* 不必去逐个补十几处字面量(那才是漏字段的根源)。
*/
function config(overrides: Partial<LeaveNotificationConfig> = {}): LeaveNotificationConfig {
return {
target: { type: 'source_chat' },
template: '',
notifyScope: 'all',
notifyRoomIds: [],
...overrides
}
}
function resolve(
config: LeaveNotificationConfig,
overrides: { selfWxid?: string; event?: GroupMemberExitedEvent } = {}
@@ -59,7 +76,7 @@ function resolve(
describe('leaveNotificationTarget · 四种目标', () => {
it('当前群聊 = 事件所在群,不是"最后活跃会话"', () => {
const result = resolve({ target: { type: 'source_chat' }, template: '' })
const result = resolve(config())
expect(result).toEqual({
ok: true,
@@ -71,23 +88,42 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('当前群聊在群名缺失时用中性称呼,不回落成 roomId', () => {
const result = resolveLeaveNotificationTarget({
config: { target: { type: 'source_chat' }, template: '' },
// 事件里没带群名,但本地联系人表里有这个群 ⇒ 用会话昵称,
// 比「当前群聊」更具体,且**仍然不是** roomId。
const withNickname = resolveLeaveNotificationTarget({
config: config(),
event: { ...EVENT, groupName: undefined },
contacts,
selfWxid: 'wxid_self'
})
expect(withNickname.ok).toBe(true)
if (withNickname.ok) {
expect(withNickname.target.recipient.name).toBe('测试群')
expect(withNickname.target.displayName).toBe('测试群')
}
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.target.recipient.name).toBe('当前群聊')
expect(result.target.displayName).toBe('发生退群事件的群聊')
// 群名与联系人表**都**拿不到 ⇒ 退到中性称呼。
// 唯一不能发生的事是把 roomId / wxid 当显示名吐出去。
const fallback = resolveLeaveNotificationTarget({
config: config(),
event: { ...EVENT, groupName: undefined },
contacts: [],
selfWxid: 'wxid_self'
})
expect(fallback.ok).toBe(true)
if (fallback.ok) {
expect(fallback.target.recipient.name).toBe('当前群聊')
expect(fallback.target.displayName).toBe('发生退群事件的群聊')
// `recipient.id` 本来就该是真实会话 id(发送要用它),
// 这条断言约束的是**显示名**:不许把 id 当称呼给人看。
expect(fallback.target.recipient.name).not.toContain('@chatroom')
expect(fallback.target.displayName).not.toContain('@chatroom')
}
})
it('当前群聊在缺少 conversationId 时直接失败', () => {
const result = resolveLeaveNotificationTarget({
config: { target: { type: 'source_chat' }, template: '' },
config: config(),
event: { ...EVENT, conversationId: '' },
contacts,
selfWxid: 'wxid_self'
@@ -97,7 +133,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('发给自己 = 当前登录账号的真实 wxid', () => {
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: 'wxid_me' })
const result = resolve(config({ target: { type: 'self' } }), { selfWxid: 'wxid_me' })
expect(result).toEqual({
ok: true,
@@ -113,7 +149,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
* 那会让用户以为"发给自己"生效了,实际消息去了别处。
*/
it('拿不到自身身份时失败,且不 fallback 文件传输助手', () => {
const result = resolve({ target: { type: 'self' }, template: '' }, { selfWxid: '' })
const result = resolve(config({ target: { type: 'self' } }), { selfWxid: '' })
expect(result.ok).toBe(false)
if (!result.ok) {
@@ -123,7 +159,7 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('文件传输助手用协议级固定身份,不靠昵称搜索', () => {
const result = resolve({ target: { type: 'file_transfer' }, template: '' })
const result = resolve(config({ target: { type: 'file_transfer' } }))
expect(result.ok).toBe(true)
if (result.ok) {
@@ -136,10 +172,9 @@ describe('leaveNotificationTarget · 四种目标', () => {
})
it('指定好友用稳定 id,显示名用备注', () => {
const result = resolve({
target: { type: 'contact', contactId: 'wxid_friend' },
template: ''
})
const result = resolve(
config({ target: { type: 'contact', contactId: 'wxid_friend' } })
)
expect(result).toEqual({
ok: true,
@@ -153,14 +188,14 @@ describe('leaveNotificationTarget · 四种目标', () => {
describe('leaveNotificationTarget · 失效与非法', () => {
it('联系人不在了 → 失败,且不往别处发', () => {
const result = resolve({ target: { type: 'contact', contactId: 'wxid_gone' }, template: '' })
const result = resolve(config({ target: { type: 'contact', contactId: 'wxid_gone' } }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('已不存在或当前无法发送')
})
it('还没选好友 → 失败并提示重新选择', () => {
const result = resolve({ target: { type: 'contact' }, template: '' })
const result = resolve(config({ target: { type: 'contact' } }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error).toContain('还没有选择通知联系人')
@@ -173,7 +208,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
WECHAT_FILE_HELPER_USERNAME,
'wxid_self'
]) {
const result = resolve({ target: { type: 'contact', contactId }, template: '' })
const result = resolve(config({ target: { type: 'contact', contactId } }))
expect(result.ok).toBe(false)
}
})
@@ -188,10 +223,7 @@ describe('leaveNotificationTarget · 失效与非法', () => {
})
it('未知目标类型 → 失败(不允许静默当成默认值)', () => {
const result = resolve({
target: { type: 'webhook' as never },
template: ''
})
const result = resolve(config({ target: { type: 'webhook' as never } }))
expect(result.ok).toBe(false)
})
})
+60 -30
View File
@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import type { PersonalWechatSendCapability } from '../../src/shared/personal-wechat'
import type { ScheduledReportTask } from '../../src/shared/scheduled-report'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../../src/shared/scheduled-report'
import {
ScheduledReportApiService,
type ScheduledReportApiDependencies
@@ -60,36 +66,60 @@ function makeApi(
): ApiTestResult {
const tasks = options.tasks || []
const service = {
listTasks: vi.fn(async () => tasks),
listExecutions: vi.fn(async () => []),
createTask: vi.fn(async (input: Record<string, unknown>) => {
const created = task({
id: `task-${tasks.length + 1}`,
name: String(input.name),
group: String(input.group),
target: String(input.target),
scheduleTime: String(input.scheduleTime),
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
enabled: Boolean(input.enabled)
})
tasks.push(created)
return { success: true, data: created }
}),
updateTask: vi.fn(async () => ({ success: true, data: tasks[0] })),
deleteTask: vi.fn(async (id: string) => ({ success: true, data: { deletedId: id } })),
setTaskEnabled: vi.fn(async (id: string, enabled: boolean) => ({
success: true,
data: task({ id, enabled })
})),
runScheduledReportNow: vi.fn(async (id: string) => ({
success: true,
data: {
id: 'execution-1',
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
status: 'success' as const
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
listExecutions: vi.fn(
async (_taskId?: string): Promise<ScheduledReportExecution[]> => []
),
createTask: vi.fn(
async (input: ScheduledReportCreateInput): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const created = task({
id: `task-${tasks.length + 1}`,
name: String(input.name),
group: String(input.group),
target: String(input.target),
scheduleTime: String(input.scheduleTime),
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
enabled: Boolean(input.enabled)
})
tasks.push(created)
return { success: true, data: created }
}
}))
),
updateTask: vi.fn(
async (
_taskId: string,
_input: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> => ({
success: true,
data: tasks[0]
})
),
deleteTask: vi.fn(
async (id: string): Promise<ScheduledReportResult<{ deletedId: string }>> => ({
success: true,
data: { deletedId: id }
})
),
setTaskEnabled: vi.fn(
async (
id: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> => ({
success: true,
data: task({ id, enabled })
})
),
runScheduledReportNow: vi.fn(
async (id: string): Promise<ScheduledReportResult<ScheduledReportExecution>> => ({
success: true,
data: {
id: 'execution-1',
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
status: 'success' as const
}
})
)
}
return {
api: new ScheduledReportApiService({
@@ -0,0 +1,296 @@
import { describe, expect, it } from 'vitest'
import {
planScheduledReportMigration,
planScheduledReportMigrationBatch,
scheduledReportMigrationRuleId,
type LegacyScheduledReportTask
} from '../../src/main/services/scheduled-report-migration'
import { SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE } from '../../src/shared/automation'
/**
* 旧「定时日报任务」→ `AutomationRule(ruleType='scheduled_report')` 的映射器。
*
* 这是本次迁移里**唯一会改变用户既有行为**的地方,所以每条断言都对应一条产品约束:
* - Rule ID 优先复用旧 taskId(不许拿显示名当稳定 ID);
* - 配置无损映射(时间 / 范围 / 消息类型 / 模板 / 成员名口径 / 超时);
* - 只有"能证明是同一个群"才无损映射成 `source_chat`;
* 历史行为事实上支持"生成 A 群日报 → 发 B 群",新产品不支持 ⇒ 必须 `targetNeedsReview`
* 且 **enabled = false**,绝不自动改写、绝不静默丢掉目标;
* - 解析不到群标识时**保留原值**,运行期如实失败,而不是在这里猜一个群;
* - 批次按 id 去重 ⇒ 幂等(同一份文件跑两次结果一致)。
*/
const NOW = Date.parse('2026-08-27T10:00:00+08:00')
/** 假联系人表:md5 / 群名 → roomId。 */
const CONTACTS: Record<string, string> = {
'tech-md5': 'tech@chatroom',
'product-md5': 'product@chatroom',
'技术交流群': 'tech@chatroom'
}
/**
* 与 main 侧 `setLegacyConversationResolver` 注入的真实解析器**同口径**:
* - 以 `@chatroom` 结尾 ⇒ 原样返回(旧 UI 的 `target` 存的就是 roomId,
* 而 `chat.resolveMd5` 对 roomId 是查不到的,所以这条 pass-through 是必需的);
* - 否则按 md5 / 群名查联系人表。
*/
const resolve = (raw: string): string | undefined => {
const key = raw.trim()
if (!key) return undefined
if (key.endsWith('@chatroom')) return key
return CONTACTS[key]
}
const task = (overrides: LegacyScheduledReportTask = {}): LegacyScheduledReportTask => ({
id: 'scheduled_report_old_1',
name: '技术交流群 · 每日日报',
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:15',
reportRange: 'yesterday',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'wechatNickname',
timeoutSeconds: 600,
enabled: true,
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-20T01:00:00.000Z',
...overrides
})
describe('定时日报迁移 · Rule ID', () => {
it('优先原样复用旧 taskId(不拿显示名当稳定 ID)', () => {
expect(scheduledReportMigrationRuleId(task())).toBe('scheduled_report_old_1')
// 改名字不影响 id —— 显示名不是身份。
expect(scheduledReportMigrationRuleId(task({ name: '换个名字' }))).toBe(
'scheduled_report_old_1'
)
})
it('旧 id 缺失时退化成由稳定字段派生的确定性 id(重复迁移不产生新规则)', () => {
const broken = task({ id: undefined })
const first = scheduledReportMigrationRuleId(broken)
const second = scheduledReportMigrationRuleId({ ...broken })
expect(first).toMatch(/^scheduled-report:[0-9a-f]{32}$/)
expect(second).toBe(first)
})
it('id 全是空白字符时同样走派生路径', () => {
expect(scheduledReportMigrationRuleId(task({ id: ' ' }))).toMatch(/^scheduled-report:/)
})
})
describe('定时日报迁移 · 逐字段无损映射', () => {
it('配置字段一个不丢(不是"看起来差不多")', () => {
const plan = planScheduledReportMigration(task(), resolve, NOW)
expect(plan.outcome).toBe('lossless_source_chat')
expect(plan.name).toBe('技术交流群 · 每日日报')
expect(plan.enabled).toBe(true)
expect(plan.config.schedule).toEqual({ time: '09:15' })
expect(plan.config.report).toEqual({
// 来源群存**稳定会话 id**:旧值 'tech-md5' 被解析成 roomId。
sourceConversationId: 'tech@chatroom',
range: 'yesterday',
messageTypes: ['text', 'image'],
templateId: 'v1',
memberNameMode: 'wechatNickname',
timeoutSeconds: 600
})
expect(plan.config.target).toEqual({ type: 'source_chat' })
expect(plan.config.targetNeedsReview).toBeUndefined()
expect(plan.config.legacyTarget).toBeUndefined()
// 来源群被改写过 ⇒ 留下原文方便用户核对。
expect(plan.config.legacySourceGroup).toBe('tech-md5')
})
it('createdAt / updatedAt 继承旧值(ISO → 毫秒),非法值回落迁移时刻', () => {
const plan = planScheduledReportMigration(task(), resolve, NOW)
expect(plan.createdAt).toBe(Date.parse('2026-08-01T01:00:00.000Z'))
expect(plan.updatedAt).toBe(Date.parse('2026-08-20T01:00:00.000Z'))
const broken = planScheduledReportMigration(
task({ createdAt: '不是时间', updatedAt: undefined }),
resolve,
NOW
)
expect(broken.createdAt).toBe(NOW)
expect(broken.updatedAt).toBe(NOW)
})
it('继承调度游标(lastRunAt / lastScheduledSlot),避免迁移后重复补跑', () => {
const slot = new Date('2026-08-26T09:15:00+08:00').toISOString()
const plan = planScheduledReportMigration(
task({ lastRunAt: '2026-08-26T01:15:00.000Z', lastScheduledSlot: slot }),
resolve,
NOW
)
expect(plan.config.lastRunAt).toBe('2026-08-26T01:15:00.000Z')
expect(plan.config.lastScheduledSlot).toBe(slot)
})
it('脏值一律收敛到合法默认值,绝不落一条跑不起来的规则', () => {
const plan = planScheduledReportMigration(
task({
scheduleTime: '99:99',
reportRange: '一个不存在的范围',
messageTypes: ['不存在的类型'],
templateId: '不存在的模板',
memberNameMode: '不存在的口径',
timeoutSeconds: 999999
}),
resolve,
NOW
)
expect(plan.config.schedule.time).toBe('18:30')
expect(plan.config.report.range).toBe('today')
expect(plan.config.report.messageTypes).toEqual(['text'])
expect(plan.config.report.templateId).toBe('v1')
expect(plan.config.report.memberNameMode).toBe(SCHEDULED_REPORT_DEFAULT_MEMBER_NAME_MODE)
expect(plan.config.report.timeoutSeconds).toBe(1800)
})
it('名字缺失时回落到「定时日报」,不会留一条空名字的规则', () => {
expect(planScheduledReportMigration(task({ name: ' ' }), resolve, NOW).name).toBe('定时日报')
})
})
describe('定时日报迁移 · 发送目标(唯一会改变行为的地方)', () => {
it('target 为空 ⇒ 旧系统本就回落到来源群,等价于 source_chat', () => {
const plan = planScheduledReportMigration(task({ target: '' }), resolve, NOW)
expect(plan.outcome).toBe('lossless_source_chat')
expect(plan.config.target).toEqual({ type: 'source_chat' })
expect(plan.config.targetNeedsReview).toBeUndefined()
})
it('target 与 group 文本相同 ⇒ 无损映射(不依赖能否解析)', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'tech-md5' }),
() => undefined,
NOW
)
expect(plan.outcome).toBe('lossless_source_chat')
expect(plan.config.targetNeedsReview).toBeUndefined()
})
it('两者都解析到同一个 roomId ⇒ 无损映射(旧 UI 的常态:group=md5, target=roomId)', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'tech@chatroom' }),
resolve,
NOW
)
expect(plan.outcome).toBe('lossless_source_chat')
// 这条无损判定**依赖**解析器对 `@chatroom` 的 pass-through:
// 少了它,旧 UI 里每一组 (md5, roomId) 都会退化成 needs_review,
// 等于把所有用户的定时日报全部停用。
expect(resolve('tech@chatroom')).toBe('tech@chatroom')
})
it('target 指向**另一个群** ⇒ needs_review + 强制停用 + 保留旧目标原文', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'product@chatroom' }),
resolve,
NOW
)
expect(plan.outcome).toBe('needs_review')
// 不能自动改写目标,也不能在用户确认前就被定时发出去。
expect(plan.enabled).toBe(false)
expect(plan.config.targetNeedsReview).toBe(true)
expect(plan.config.legacyTarget).toBe('product@chatroom')
})
it('target 无法解析 ⇒ 一律 needs_review(拿不到证据就不自动改写)', () => {
const plan = planScheduledReportMigration(
task({ group: 'tech-md5', target: 'unknown-id' }),
resolve,
NOW
)
expect(plan.outcome).toBe('needs_review')
expect(plan.enabled).toBe(false)
expect(plan.config.legacyTarget).toBe('unknown-id')
})
it('needs_review 的规则即使旧任务是启用的也必须停用', () => {
const plan = planScheduledReportMigration(
task({ target: 'product@chatroom', enabled: true, group: 'tech-md5' }),
resolve,
NOW
)
expect(plan.enabled).toBe(false)
})
it('来源群解析不到 ⇒ 保留原值,不在迁移期猜一个群', () => {
const plan = planScheduledReportMigration(task({ group: '未知群', target: '' }), resolve, NOW)
// 原值留着 —— 运行期会如实失败,比"悄悄换一个群发"安全得多。
expect(plan.config.report.sourceConversationId).toBe('未知群')
expect(plan.config.legacySourceGroup).toBeUndefined()
})
})
describe('定时日报迁移 · 批次幂等', () => {
const tasks: LegacyScheduledReportTask[] = [
task({ id: 'scheduled_report_a', name: 'A', group: 'tech-md5', target: 'tech@chatroom' }),
task({ id: 'scheduled_report_b', name: 'B', group: 'product-md5', target: 'product@chatroom' }),
task({ id: 'scheduled_report_c', name: 'C', group: 'tech-md5', target: 'product@chatroom' })
]
it('3 条旧任务 ⇒ 3 条规则,并给出判定分布', () => {
const { plans, summary } = planScheduledReportMigrationBatch(tasks, resolve, NOW)
expect(plans.map((plan) => plan.ruleId)).toEqual([
'scheduled_report_a',
'scheduled_report_b',
'scheduled_report_c'
])
expect(summary).toEqual({
total: 3,
migrated: 3,
lossless: 2,
needsReview: 1,
duplicatesSkipped: 0
})
})
it('重复执行结果完全一致(幂等,不是"看起来没多")', () => {
const first = planScheduledReportMigrationBatch(tasks, resolve, NOW)
const second = planScheduledReportMigrationBatch(tasks, resolve, NOW)
expect(second).toEqual(first)
})
it('同一 id 出现两次只迁一条,并如实计入 duplicatesSkipped', () => {
const { plans, summary } = planScheduledReportMigrationBatch(
[tasks[0], task({ id: 'scheduled_report_a', name: '重复' })],
resolve,
NOW
)
expect(plans).toHaveLength(1)
expect(plans[0].name).toBe('A')
expect(summary.total).toBe(2)
expect(summary.migrated).toBe(1)
expect(summary.duplicatesSkipped).toBe(1)
})
it('文件里混进非对象元素时跳过而不是崩掉', () => {
const { plans } = planScheduledReportMigrationBatch(
[tasks[0], null as unknown as LegacyScheduledReportTask, 'x' as unknown as LegacyScheduledReportTask],
resolve,
NOW
)
expect(plans).toHaveLength(1)
})
it('空文件 ⇒ 0 条规则(全新安装路径)', () => {
const { plans, summary } = planScheduledReportMigrationBatch([], resolve, NOW)
expect(plans).toHaveLength(0)
expect(summary.migrated).toBe(0)
})
})
File diff suppressed because it is too large Load Diff