| 1 | import { describe, expect, it, vi } from 'vitest' |
| 2 | |
| 3 | vi.mock('electron', () => ({ BrowserWindow: {}, safeStorage: {} })) |
| 4 | vi.mock('@electron-toolkit/utils', () => ({ is: {} })) |
| 5 | vi.mock('electron-log/main.js', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn() } })) |
| 6 | |
| 7 | import { |
| 8 | createIpcContext, |
| 9 | getDeckProgressStageBounds, |
| 10 | shouldEmitLlmStatusUpdate |
| 11 | } from '../../../src/main/ipc/context' |
| 12 | import { TypedEventBus } from '../../../src/main/agent-runtime/events/bus' |
| 13 | |
| 14 | describe('getDeckProgressStageBounds', () => { |
| 15 | it('caps preflight/planning at 10 so later stages own the upper range', () => { |
| 16 | expect(getDeckProgressStageBounds('preflight')).toEqual({ min: 0, max: 10 }) |
| 17 | expect(getDeckProgressStageBounds('planning')).toEqual({ min: 0, max: 10 }) |
| 18 | }) |
| 19 | |
| 20 | it('keeps rendering inside [10, 90] so finalization can claim [90, 100]', () => { |
| 21 | expect(getDeckProgressStageBounds('rendering')).toEqual({ min: 10, max: 90 }) |
| 22 | }) |
| 23 | |
| 24 | it('clamps unknown stages (incl. editing) to [0, 90]', () => { |
| 25 | expect(getDeckProgressStageBounds('editing')).toEqual({ min: 0, max: 90 }) |
| 26 | expect(getDeckProgressStageBounds('whatever')).toEqual({ min: 0, max: 90 }) |
| 27 | }) |
| 28 | |
| 29 | it('lets finalizing reach 100 so page-beautify milestones are not stalled at 90', () => { |
| 30 | // Regression: post-agent emits (83/87/91/95/100) used to be clamped to ≤90 by the |
| 31 | // default branch, making the bar freeze one tick short of done. The finalizing stage |
| 32 | // exists specifically to let single-page workflows complete visibly. |
| 33 | const bounds = getDeckProgressStageBounds('finalizing') |
| 34 | expect(bounds.min).toBeLessThanOrEqual(83) |
| 35 | expect(bounds.max).toBeGreaterThanOrEqual(100) |
| 36 | }) |
| 37 | |
| 38 | it('coalesces repetitive llm status updates while preserving meaningful progress changes', () => { |
| 39 | const previous = { |
| 40 | stage: 'editing', |
| 41 | label: '生成页面', |
| 42 | detail: '', |
| 43 | progress: 45, |
| 44 | emittedAt: 1_000 |
| 45 | } |
| 46 | |
| 47 | expect( |
| 48 | shouldEmitLlmStatusUpdate(previous, { ...previous, progress: 45 }, 1_200) |
| 49 | ).toBe(false) |
| 50 | expect( |
| 51 | shouldEmitLlmStatusUpdate(previous, { ...previous, progress: 46 }, 1_200) |
| 52 | ).toBe(false) |
| 53 | expect( |
| 54 | shouldEmitLlmStatusUpdate(previous, { ...previous, progress: 47 }, 1_200) |
| 55 | ).toBe(false) |
| 56 | expect( |
| 57 | shouldEmitLlmStatusUpdate(previous, { ...previous, progress: 50 }, 1_200) |
| 58 | ).toBe(true) |
| 59 | expect( |
| 60 | shouldEmitLlmStatusUpdate(previous, { ...previous, label: '校验页面' }, 1_200) |
| 61 | ).toBe(true) |
| 62 | expect( |
| 63 | shouldEmitLlmStatusUpdate(previous, { ...previous, progress: 45 }, 1_800) |
| 64 | ).toBe(false) |
| 65 | }) |
| 66 | |
| 67 | it('separates streamed chunks from persisted lifecycle events on the typed runtime bus', () => { |
| 68 | const eventBus = new TypedEventBus() |
| 69 | const events: Array<{ type: string; domain: string; jobId: string }> = [] |
| 70 | eventBus.subscribe({}, (event) => events.push(event)) |
| 71 | const context = createIpcContext( |
| 72 | { |
| 73 | isDestroyed: () => false, |
| 74 | isMinimized: () => false, |
| 75 | isVisible: () => true, |
| 76 | restore: vi.fn(), |
| 77 | show: vi.fn(), |
| 78 | focus: vi.fn() |
| 79 | } as never, |
| 80 | {} as never, |
| 81 | {} as never, |
| 82 | eventBus |
| 83 | ) |
| 84 | |
| 85 | context.beginSessionRunState({ |
| 86 | sessionId: 'session-1', |
| 87 | runId: 'run-1', |
| 88 | mode: 'generate', |
| 89 | totalPages: 1, |
| 90 | status: 'running' |
| 91 | }) |
| 92 | context.emitGenerateChunk('session-1', { |
| 93 | type: 'run_error', |
| 94 | payload: { runId: 'run-1', message: '生成已取消', cancelled: true } |
| 95 | }) |
| 96 | |
| 97 | expect(events).toEqual([ |
| 98 | expect.objectContaining({ type: 'job.started', domain: 'generation', jobId: 'run-1' }), |
| 99 | expect.objectContaining({ type: 'generation.chunk', domain: 'generation', jobId: 'run-1' }) |
| 100 | ]) |
| 101 | |
| 102 | context.emitRuntimeJobTerminal({ |
| 103 | sessionId: 'session-1', |
| 104 | jobId: 'run-1', |
| 105 | status: 'cancelled' |
| 106 | }) |
| 107 | |
| 108 | expect(events).toEqual([ |
| 109 | expect.objectContaining({ type: 'job.started', domain: 'generation', jobId: 'run-1' }), |
| 110 | expect.objectContaining({ type: 'generation.chunk', domain: 'generation', jobId: 'run-1' }), |
| 111 | expect.objectContaining({ type: 'job.cancelled', domain: 'generation', jobId: 'run-1' }) |
| 112 | ]) |
| 113 | }) |
| 114 | }) |
| 115 |