返回 oh-my-ppt
page-edit-assessment-guard.test.ts
根目录 / tests / unit / edit-jobs / page-edit-assessment-guard.test.ts
1 import { describe, expect, it, vi } from 'vitest'
2
3 const { assessPageEditMock, executeEditGenerationMock, resolveEditContextMock } = vi.hoisted(() => ({
4 assessPageEditMock: vi.fn(),
5 executeEditGenerationMock: vi.fn(),
6 resolveEditContextMock: vi.fn()
7 }))
8
9 vi.mock('electron', () => ({ ipcMain: { handle: vi.fn() } }))
10 vi.mock('electron-log/main.js', () => ({ default: { error: vi.fn(), warn: vi.fn() } }))
11 vi.mock('../../../src/main/generation/edit-flow', () => ({
12 assessPageEdit: assessPageEditMock,
13 executeEditGeneration: executeEditGenerationMock,
14 resolveEditContext: resolveEditContextMock
15 }))
16 vi.mock('../../../src/main/generation/generation-utils', () => ({
17 createEmitAssistantMessage: vi.fn(),
18 resolvePageHtmlPath: vi.fn()
19 }))
20 vi.mock('../../../src/main/edit-jobs/edit-job-finalization', () => ({
21 settleEditJobFailure: vi.fn(),
22 settleEditJobSuccess: vi.fn()
23 }))
24
25 import { PageEditJobService } from '../../../src/main/edit-jobs/page-edit-job-service'
26 import { JobCoordinator, sessionLockKey } from '../../../src/main/agent-runtime'
27
28 const reserveSessionWrite = async (coordinator: JobCoordinator, sessionId: string): Promise<void> => {
29 const result = await coordinator.reserve({
30 jobId: `existing-${sessionId}`,
31 domain: 'edit',
32 owner: { kind: 'session', id: sessionId },
33 claims: { write: [sessionLockKey(sessionId)] },
34 wait: 'fail'
35 })
36 if (result.status !== 'acquired') throw new Error('expected existing session write lease')
37 }
38
39 describe('PageEditJobService assessment guard', () => {
40 it('does not start a read-only assessment while the session has a write job', async () => {
41 const sessionId = 'session-1'
42 const ctx = {
43 sessionRunStates: new Map([
44 [
45 sessionId,
46 {
47 runId: 'active-run',
48 status: 'running'
49 }
50 ]
51 ])
52 }
53 const service = new PageEditJobService(ctx as never, new JobCoordinator())
54
55 await expect(
56 service.assess({
57 sessionId,
58 userMessage: 'Change the title',
59 type: 'page',
60 chatType: 'page',
61 selectedPageId: 'page-1'
62 })
63 ).rejects.toThrow('当前有页面修改任务正在执行')
64 expect(assessPageEditMock).not.toHaveBeenCalled()
65 })
66
67 it('does not start an assessment while the coordinator holds an active lease', async () => {
68 const sessionId = 'session-lease'
69 const ctx = { sessionRunStates: new Map() }
70 const coordinator = new JobCoordinator()
71 await reserveSessionWrite(coordinator, sessionId)
72 const service = new PageEditJobService(ctx as never, coordinator)
73
74 await expect(
75 service.assess({
76 sessionId,
77 userMessage: 'Change the title',
78 type: 'page',
79 chatType: 'page',
80 selectedPageId: 'page-1'
81 })
82 ).rejects.toThrow('当前有页面修改任务正在执行')
83 expect(assessPageEditMock).not.toHaveBeenCalled()
84 })
85
86 it('replaces a prior assessment through its JobCoordinator lease', async () => {
87 const sessionId = 'session-replace-assessment'
88 const coordinator = new JobCoordinator()
89 const service = new PageEditJobService({ sessionRunStates: new Map() } as never, coordinator)
90 assessPageEditMock.mockImplementationOnce(
91 (_ctx: unknown, _payload: unknown, signal: AbortSignal) =>
92 new Promise((_resolve, reject) => {
93 signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
94 })
95 )
96 assessPageEditMock.mockResolvedValueOnce({ reply: 'new assessment' })
97
98 const firstAssessment = service.assess({
99 sessionId,
100 userMessage: 'Change the title',
101 type: 'page',
102 chatType: 'page',
103 selectedPageId: 'page-1'
104 })
105 await vi.waitFor(() => {
106 expect(coordinator.getByOwner({ kind: 'session', id: sessionId })).toMatchObject({
107 state: 'active'
108 })
109 })
110
111 await expect(
112 service.assess({
113 sessionId,
114 userMessage: 'Change the title again',
115 type: 'page',
116 chatType: 'page',
117 selectedPageId: 'page-1'
118 })
119 ).resolves.toEqual({ reply: 'new assessment' })
120 await expect(firstAssessment).rejects.toThrow('生成已取消')
121 expect(coordinator.getByOwner({ kind: 'session', id: sessionId })).toBeNull()
122 })
123
124 it('cancels and waits for an in-flight read assessment before reserving a page-edit writer', async () => {
125 const sessionId = 'session-assessment-race'
126 const ctx = {
127 db: {
128 listSessionPages: vi.fn().mockResolvedValue([
129 {
130 id: 'page-1',
131 file_slug: 'page-1',
132 html_path: '',
133 page_number: 1
134 }
135 ]),
136 createGenerationRunWithSessionJob: vi.fn().mockResolvedValue(undefined)
137 },
138 sessionRunStates: new Map(),
139 beginSessionRunState: vi.fn(),
140 emitGenerateChunk: vi.fn(),
141 agentManager: { removeSession: vi.fn() }
142 }
143 const coordinator = new JobCoordinator()
144 const service = new PageEditJobService(ctx as never, coordinator)
145 resolveEditContextMock.mockImplementation(
146 async (
147 _ctx: unknown,
148 _event: unknown,
149 _payload: unknown,
150 execution: { runId: string; abortSignal: AbortSignal }
151 ) =>
152 ({
153 sessionId,
154 runId: execution.runId,
155 abortSignal: execution.abortSignal,
156 selectedPageId: 'page-1',
157 projectDir: '/tmp/page-edit-assessment-race',
158 previousSessionStatus: 'completed',
159 effectiveMode: 'edit',
160 messageScope: 'page',
161 projectId: 'project-1'
162 })
163 )
164 executeEditGenerationMock.mockResolvedValue(undefined)
165 assessPageEditMock.mockImplementationOnce(
166 (_ctx: unknown, _payload: unknown, signal: AbortSignal) =>
167 new Promise((_resolve, reject) => {
168 signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
169 })
170 )
171
172 const assessment = service.assess({
173 sessionId,
174 userMessage: 'Change the title',
175 type: 'page',
176 chatType: 'page',
177 selectedPageId: 'page-1'
178 })
179 const assessmentCancelled = expect(assessment).rejects.toThrow('生成已取消')
180 await vi.waitFor(() => {
181 expect(coordinator.getByOwner({ kind: 'session', id: sessionId })).toMatchObject({
182 claims: { read: [sessionLockKey(sessionId)] }
183 })
184 })
185
186 await expect(
187 service.start({} as Electron.IpcMainInvokeEvent, {
188 sessionId,
189 userMessage: 'Change the title',
190 type: 'page',
191 chatType: 'page',
192 selectedPageId: 'page-1',
193 autoApply: true
194 })
195 ).resolves.toMatchObject({ success: true })
196 await assessmentCancelled
197 await vi.waitFor(() => {
198 expect(coordinator.getByOwner({ kind: 'session', id: sessionId })).toBeNull()
199 })
200 })
201 })
202
202 lines TYPESCRIPT