Files
goodbuddy/src/main/magic-notes/magic-note-analyzer.test.ts
T
mesalogo bf1d34e897 feat: configure canvas pages sent for note analysis
Canvas analysis previously sent every page. Add a Magic Notes setting for
1-8 canvas pages, defaulting to 1 for new and historical settings. Select
the first N pages in current canvas order before capture and use the same
text/image range for saved entries, drafts and canvas-source todos.

Keep full 50-page saving/export, expose the same setting through config
tools, and use the shared settings select with an inline help icon.
Include pagination, persistence, production IPC and isolated Electron
regressions, including text-only input and scaled page boundaries.

Validation: focused feature tests and isolated Electron regressions passed;
the final compact UI passed 14 tests. Typecheck and lint were reported
passing by the coordinating agent. No full-suite pass is claimed because
the completed full test command exposed no summary. No live model calls
were made for this commit preparation.

Release note: 魔法笔记新增“发送画布页数”设置,可选 1~8 页,默认 1 页,按当前画布顺序发送前 N 页的文字和图片;标题旁问号可查看说明,保存和导出完整画布不受影响。
2026-09-20 00:07:07 +08:00

445 lines
17 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { AgentExecutionRequest, AgentRuntime, RuntimeModelUsageEvent } from '../agent/runtime'
import type {
MagicNoteEntry,
MagicNoteAnalysisOptions,
MagicTodoItem
} from '../../shared/magic-notes-contracts'
import {
analyzeMagicNoteEntry,
analyzeMagicNoteDraft,
analyzeMagicTodo
} from './magic-note-analyzer'
const entry: MagicNoteEntry = {
id: '00000000-0000-4000-8000-000000000501',
noteId: '00000000-0000-4000-8000-000000000502',
content: { version: 1, ops: [{ insert: '周五前整理发布清单\n' }] },
plainText: '周五前整理发布清单',
comments: [],
revision: 0,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
describe('magic note analyzer', () => {
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII='
const canvasEntry = {
...entry,
content: {
version: 2,
kind: 'paged-canvas',
pages: ['first', 'second'].map((id, index) => ({
id, width: 800, height: 1000,
background: { type: 'template', template: 'blank' }, objects: [{ text: index === 0 ? '目标' : '验收' }]
})),
assets: []
},
plainText: '第 1 页:目标\n第 2 页:验收'
} satisfies MagicNoteEntry
const options: MagicNoteAnalysisOptions = {
requestId: '00000000-0000-4000-8000-000000000506',
direction: 'general', format: 'structured',
canvasImages: ['second', 'first'].map((pageId) => ({
pageId, dataUrl: `data:image/png;base64,${png}`
}))
}
function recordingRuntime(error?: string) {
const requests: AgentExecutionRequest[] = []
const runtime: AgentRuntime = {
requiresToolApproval: false,
supportsToolExecution: false,
async getStatus() {
return { id: 'model', label: 'Test model', available: true, detail: 'Ready', supportsToolExecution: false }
},
async dispose() {},
async *run(input: AgentExecutionRequest) {
requests.push(input)
if (error) {
yield { requestId: input.requestId, type: 'error', status: 'failed', message: error } as const
return
}
yield { requestId: input.requestId, type: 'text', delta: '{"comments":[{"kind":"summary","content":"明确目标与验收条件。"}]}' } as const
yield {
requestId: input.requestId, type: 'model-usage', callId: 'analysis-call',
runtime: 'model', provider: 'test', model: 'test', inputTokens: 25,
outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0
} as const
yield { requestId: input.requestId, type: 'done' } as const
}
}
return { runtime, requests }
}
const canvasTodo: MagicTodoItem = {
id: '00000000-0000-4000-8000-000000000601',
noteId: entry.noteId, entryId: entry.id, noteTitle: 'Canvas',
sourceIndex: 0, source: 'note', title: 'Review release',
instructions: 'Check acceptance criteria', completed: false,
comments: [], revision: 0, createdAt: entry.createdAt, updatedAt: entry.updatedAt
}
it.each([true, false])('analyzes canvas todos with task text and source context (vision=%s)', async (supportsImageInput) => {
const { runtime, requests } = recordingRuntime()
const comments = await analyzeMagicTodo(
runtime, canvasTodo, options, undefined, undefined,
{ supportsImageInput, content: canvasEntry.content, canvasPageCount: 2 }
)
expect(requests).toHaveLength(1)
expect(requests[0]?.prompt).toContain(canvasTodo.title)
expect(requests[0]?.prompt).toContain(canvasTodo.instructions)
expect(requests[0]?.prompt).toContain('第 2 页:\\n验收')
expect(requests[0]?.conversationId).toBe(`magic-todos:${canvasTodo.id}`)
expect(requests[0]?.workMode).toBe('ask')
if (supportsImageInput) {
expect(requests[0]?.images).toHaveLength(2)
} else {
expect(requests[0]?.images).toBeUndefined()
expect(requests[0]?.prompt).toContain('未查看页面图像')
}
expect(comments[0]?.inputMode).toBe(supportsImageInput ? 'canvas-images' : 'text-fallback')
})
it('rejects missing canvas todo captures instead of pretending to see the source', async () => {
const { runtime, requests } = recordingRuntime()
await expect(analyzeMagicTodo(
runtime, canvasTodo, { ...options, canvasImages: undefined }, undefined, undefined,
{ supportsImageInput: true, content: canvasEntry.content }
)).rejects.toThrow('截图缺失或与当前页面不匹配')
expect(requests).toHaveLength(0)
})
it('keeps legacy todo analysis limited to its title and instructions', async () => {
const { runtime, requests } = recordingRuntime()
const comments = await analyzeMagicTodo(
runtime, canvasTodo, options, undefined, undefined,
{ supportsImageInput: true, content: entry.content }
)
expect(requests[0]?.images).toBeUndefined()
expect(requests[0]?.prompt).toContain(canvasTodo.instructions)
expect(requests[0]?.prompt).not.toContain(entry.plainText)
expect(comments[0]?.inputMode).toBe('text')
})
it('passes complete composited pages in document order to AgentRuntime with page labels and text', async () => {
const { runtime, requests } = recordingRuntime()
const usage: RuntimeModelUsageEvent[] = []
const comments = await analyzeMagicNoteEntry(
runtime, canvasEntry, options, undefined,
(event) => usage.push(event), { supportsImageInput: true, canvasPageCount: 2 }
)
expect(requests).toHaveLength(1)
expect(requests[0]?.images).toEqual([
{ name: 'page-1.png', mediaType: 'image/png', data: png },
{ name: 'page-2.png', mediaType: 'image/png', data: png }
])
expect(requests[0]?.prompt).toContain('第 1 页 = page-1.png')
expect(requests[0]?.prompt).toContain('第 2 页 = page-2.png')
expect(requests[0]?.prompt).toContain('验收')
expect(requests[0]?.workMode).toBe('ask')
expect(comments[0]?.inputMode).toBe('canvas-images')
expect(usage).toEqual([expect.objectContaining({ callId: 'analysis-call', inputTokens: 25 })])
})
it('analyzes a visual-only draft using page images', async () => {
const { runtime, requests } = recordingRuntime()
const content = {
...canvasEntry.content,
pages: canvasEntry.content.pages.map((page) => ({ ...page, objects: [] }))
}
const comments = await analyzeMagicNoteDraft(runtime, '', options, undefined, undefined, { supportsImageInput: true, content })
expect(requests[0]?.images).toHaveLength(1)
expect(comments[0]?.inputMode).toBe('canvas-images')
})
it('limits both continuous flow and objects to the first page for a text model', async () => {
const { runtime, requests } = recordingRuntime()
const content = {
...canvasEntry.content,
pages: canvasEntry.content.pages.map((page, index) => ({
...page, objects: [{ text: index === 0 ? '首页结论' : '最后一页的结论' }]
})),
flow: { version: 1 as const, ops: [{ insert: '首页正文\n末页正文\n' }] }
}
const comments = await analyzeMagicNoteEntry(runtime, { ...canvasEntry, content }, {
...options, canvasPageText: [{ pageId: 'first', text: '首页正文\n' }, { pageId: 'second', text: '末页正文\n' }]
}, undefined, undefined, { supportsImageInput: false })
expect(requests[0]?.images).toBeUndefined()
expect(requests[0]?.prompt).toContain('首页正文')
expect(requests[0]?.prompt).toContain('首页结论')
expect(requests[0]?.prompt).not.toContain('最后一页的结论')
expect(requests[0]?.prompt).not.toContain('末页正文')
expect(requests[0]?.prompt).toContain('未查看页面图像')
expect(comments[0]?.inputMode).toBe('text-fallback')
})
it('rejects incomplete flow page text without sending the full cross-page body', async () => {
const { runtime, requests } = recordingRuntime()
const content = { ...canvasEntry.content, flow: { version: 1 as const, ops: [{ insert: 'First and later pages\n' }] } }
for (const canvasPageText of [undefined, [], [{ pageId: 'second', text: 'Later' }],
[{ pageId: 'first', text: 'A' }, { pageId: 'first', text: 'B' }]]) {
await expect(analyzeMagicNoteDraft(runtime, 'Full text', { ...options, canvasPageText }, undefined, undefined,
{ supportsImageInput: false, content })).rejects.toThrow('分页文字缺失')
}
expect(requests).toHaveLength(0)
})
it('does not use later-page text when the selected first page is visual-only', async () => {
const { runtime, requests } = recordingRuntime()
const content = { ...canvasEntry.content, pages: canvasEntry.content.pages.map((page, index) => ({ ...page,
objects: index === 0 ? [] : [{ text: 'Later-page text' }] })) }
await expect(analyzeMagicNoteDraft(runtime, 'Later-page text', options, undefined, undefined,
{ supportsImageInput: false, content })).rejects.toThrow('所选画布页中没有')
expect(requests).toHaveLength(0)
})
it('rejects visual-only text fallback before issuing a request', async () => {
const { runtime, requests } = recordingRuntime()
const content = {
...canvasEntry.content,
pages: canvasEntry.content.pages.map((page) => ({ ...page, objects: [] })),
flow: { version: 1 as const, ops: [{ insert: { image: 'placeholder' } }] }
}
await expect(analyzeMagicNoteEntry(runtime, { ...canvasEntry, content, plainText: ' ' }, options, undefined, undefined, { supportsImageInput: false, canvasPageCount: 2 })).rejects.toThrow('请切换支持图像输入的默认模型')
expect(requests).toHaveLength(0)
})
it.each([undefined, [], [options.canvasImages![0]!], [options.canvasImages![0]!, options.canvasImages![0]!]])('rejects missing or incomplete canvas captures (%j)', async (canvasImages) => {
const { runtime, requests } = recordingRuntime()
await expect(analyzeMagicNoteEntry(runtime, canvasEntry, { ...options, canvasImages }, undefined, undefined, { supportsImageInput: true })).rejects.toThrow('截图缺失或与当前页面不匹配')
expect(requests).toHaveLength(0)
})
it.each(['401 Unauthorized', 'network connection failed', 'context length exceeded'])('preserves provider errors without retrying text-only: %s', async (error) => {
const { runtime, requests } = recordingRuntime(error)
await expect(analyzeMagicNoteEntry(runtime, canvasEntry, options, undefined, undefined, { supportsImageInput: true })).rejects.toThrow(error)
expect(requests).toHaveLength(1)
expect(requests[0]?.images).toHaveLength(1)
})
it('keeps ordinary notes text-only even when the model supports images', async () => {
const { runtime, requests } = recordingRuntime()
const comments = await analyzeMagicNoteEntry(runtime, entry, options, undefined, undefined, { supportsImageInput: true })
expect(requests[0]?.images).toBeUndefined()
expect(requests[0]?.prompt).toContain(entry.plainText)
expect(comments[0]?.inputMode).toBe('text')
})
it('includes native PDF text with its canvas page number during text fallback', async () => {
const { runtime, requests } = recordingRuntime()
const content = {
...canvasEntry.content,
pages: canvasEntry.content.pages.map((page, index) => ({
...page,
background: {
type: 'pdf' as const, assetId: 'pdf-source',
pageNumber: index + 10, text: `PDF 原生文字 ${index + 1}`
}
}))
}
await analyzeMagicNoteDraft(runtime, '', options, undefined, undefined, {
supportsImageInput: false, content, canvasPageCount: 2
})
expect(requests[0]?.prompt).toContain('第 2 页:\\nPDF 原生文字 2')
expect(requests[0]?.images).toBeUndefined()
})
it('uses ask mode and converts bounded JSON into comments only', async () => {
let request: AgentExecutionRequest | undefined
const runtime = {
requiresToolApproval: false,
supportsToolExecution: false,
async getStatus() {
return {
id: 'model',
label: 'Test model',
available: true,
detail: 'Ready',
supportsToolExecution: false
} as const
},
async *run(input: AgentExecutionRequest) {
request = input
yield {
requestId: input.requestId,
type: 'text',
delta:
'```json\n{"comments":[{"kind":"suggestion","content":"先列出发布检查项。"}]}\n```'
} as const
yield { requestId: input.requestId, type: 'done' } as const
},
async dispose() {}
} as AgentRuntime
const result = await analyzeMagicNoteEntry(
runtime,
entry,
{
requestId: '00000000-0000-4000-8000-000000000506',
direction: 'general',
format: 'structured'
}
)
expect(request).toMatchObject({
workMode: 'ask',
knowledgeLibraryIds: []
})
expect(request?.trustedInstructions).toContain('禁止工具调用')
expect(result).toEqual([
expect.objectContaining({
kind: 'suggestion',
content: '先列出发布检查项。'
})
])
expect(request?.prompt).toContain('不创建待办')
})
it('does not analyze image-only records', async () => {
const runtime = {} as AgentRuntime
await expect(
analyzeMagicNoteEntry(
runtime,
{
...entry,
plainText: ''
},
{
requestId: '00000000-0000-4000-8000-000000000507',
direction: 'general',
format: 'structured'
}
)
).rejects.toThrow('没有可供 AI 分析的文字')
})
it('analyzes a magic todo as comments without tool access', async () => {
let request: AgentExecutionRequest | undefined
const runtime = {
requiresToolApproval: false,
supportsToolExecution: false,
async getStatus() {
return {
id: 'model',
label: 'Test model',
available: true,
detail: 'Ready',
supportsToolExecution: false
} as const
},
async *run(input: AgentExecutionRequest) {
request = input
yield {
requestId: input.requestId,
type: 'text',
delta:
'{"comments":[{"kind":"warning","content":"验收条件还不够明确。"}]}'
} as const
yield { requestId: input.requestId, type: 'done' } as const
},
async dispose() {}
} as AgentRuntime
const todo: MagicTodoItem = {
id: '00000000-0000-4000-8000-000000000601',
noteId: '00000000-0000-4000-8000-000000000602',
entryId: '00000000-0000-4000-8000-000000000603',
noteTitle: '发布笔记',
sourceIndex: 0,
source: 'note',
title: '整理发布清单',
instructions: '核对版本、说明和构建产物。',
completed: false,
comments: [],
revision: 0,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
await expect(
analyzeMagicTodo(
runtime,
todo,
{
requestId: '00000000-0000-4000-8000-000000000604',
direction: 'general',
format: 'structured'
}
)
).resolves.toEqual([
expect.objectContaining({
kind: 'warning',
content: '验收条件还不够明确。'
})
])
expect(request?.workMode).toBe('ask')
expect(request?.trustedInstructions).toContain('禁止工具调用')
})
it('streams the narrative and snapshots combined comment options', async () => {
const runtime = {
requiresToolApproval: false,
supportsToolExecution: false,
async getStatus() {
return {
id: 'model',
label: 'Test model',
available: true,
detail: 'Ready',
supportsToolExecution: false
} as const
},
async *run(input: AgentExecutionRequest) {
yield {
requestId: input.requestId,
type: 'text',
delta: '可以先扩展目标读者,'
} as const
yield {
requestId: input.requestId,
type: 'text',
delta:
'再补充一个实际例子。\n<<<GOODBUDDY_STRUCTURED_COMMENTS>>>\n'
} as const
yield {
requestId: input.requestId,
type: 'text',
delta:
'{"comments":[{"kind":"suggestion","content":"补充一个读者场景。"}]}'
} as const
yield { requestId: input.requestId, type: 'done' } as const
},
async dispose() {}
} as AgentRuntime
const deltas: string[] = []
const result = await analyzeMagicNoteEntry(
runtime,
entry,
{
requestId: '00000000-0000-4000-8000-000000000508',
direction: 'expand',
format: 'combined'
},
(delta) => deltas.push(delta)
)
expect(deltas.join('')).toBe(
'可以先扩展目标读者,再补充一个实际例子。\n'
)
expect(result).toEqual([
expect.objectContaining({
kind: 'narrative',
direction: 'expand',
format: 'combined'
}),
expect.objectContaining({
kind: 'suggestion',
content: '补充一个读者场景。',
direction: 'expand',
format: 'combined'
})
])
})
})