// 统一渲染 Task 11(审签来源):整文审签先在项目对话写一条已提交的用户消息作为来源, // 再以该消息为锚点分发命令。覆盖:调用顺序、顶层锚点字段、复用/新建记录对话、 // 每一步失败的可读拒绝(失败即不分发)、同一动作的重试幂等编号、双击只提交一次。 import { describe, expect, it, vi } from 'vitest' import type { Conversation, Message, Project } from '../src/contracts' import { commandRequestBody, rdmProjectCommandError } from '../src/rdmManagement' import { createSingleFlight, describeRenderFailure, decisionPayload } from '../src/documentRenderPresentation' import { DECISION_RECORD_TITLE, commitDecision, decisionSourceText, hostProjectFor, type DecisionPort, type DecisionRequest, } from '../src/documentDecision' const conv = (id: string, over: Partial = {}): Conversation => ({ id, projectId: 'host-1', title: DECISION_RECORD_TITLE, createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z', ...over, }) const userMsg = (id: string, conversationId: string): Message => ({ id, conversationId, role: 'user', parts: [], createdAt: '2026-10-17T00:00:01Z', }) const hostProject = (over: Partial = {}): Project => ({ id: 'host-1', name: '宿主项目', createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z', settings: { 'rdm.state': 'ready', 'rdm.projectId': 'rdm-1' }, ...over, }) function harness(over: Partial = {}) { const calls: string[] = [] const port: DecisionPort = { listConversations: vi.fn(async () => { calls.push('list'); return [conv('c-1')] }), createConversation: vi.fn(async (_pid: string, title?: string) => { calls.push('create'); return conv('c-new', { title: title ?? '' }) }), sendMessage: vi.fn(async (convId: string) => { calls.push('send'); return { message: userMsg('m-1', convId) } }), rdmCommand: vi.fn(async () => { calls.push('command'); return { receipt: { state: 'committed' } } }), ...over, } return { port, calls } } /** 断言并取出拒绝原因;若意外成功则失败。 */ async function failureOf(p: Promise): Promise { try { await p } catch (e) { return e as Error } throw new Error('expected the decision to be refused') } function request(over: Partial = {}): DecisionRequest { const payload = decisionPayload('approve', 'rdm-1', 'doc-9', '同意发布', 2) return { host: hostProject(), operationId: 'op_a', decision: 'approve', doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布', command: payload.command, params: payload.params, ...over, } } describe('decisionSourceText', () => { it('states the decision, document, round and note in Chinese without identity fields', () => { const text = decisionSourceText({ decision: 'approve', doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布' }) expect(text).toContain('审签决定:审核通过') expect(text).toContain('文档:采购流程说明(doc-9)') expect(text).toContain('轮次:第 2 轮') expect(text).toContain('说明:同意发布') expect(text.startsWith('/')).toBe(false) expect(text).not.toMatch(/owner|actor|author|identity/i) }) it('labels a rejection reason and writes 无 when the note is empty', () => { const reject = decisionSourceText({ decision: 'reject', doc: { id: 'd', title: '' }, round: 1, note: '依据不足' }) expect(reject).toContain('审签决定:驳回') expect(reject).toContain('驳回原因:依据不足') expect(reject).toContain('文档:d') const final = decisionSourceText({ decision: 'final', doc: { id: 'd', title: 'T' }, round: 1, note: ' ' }) expect(final).toContain('审签决定:终批') expect(final).toContain('说明:无') }) }) describe('commitDecision', () => { it('posts the committed source message first, then dispatches with it as the top-level anchor', async () => { const { port, calls } = harness() const req = request() await commitDecision(port, req) expect(calls).toEqual(['list', 'send', 'command']) expect(port.sendMessage).toHaveBeenCalledWith('c-1', decisionSourceText(req), [], 'decision:op_a') expect(port.rdmCommand).toHaveBeenCalledWith(req.command, req.params, { conversationId: 'c-1', anchorMessageId: 'm-1', entrance: 'button' }) }) it('keeps the command params free of conversation fields and identity', async () => { const { port } = harness() await commitDecision(port, request()) const [, params] = vi.mocked(port.rdmCommand).mock.calls[0] expect(params).toEqual({ projectId: 'rdm-1', docId: 'doc-9', note: '同意发布' }) expect(params).not.toHaveProperty('conversationId') expect(params).not.toHaveProperty('anchorMessageId') expect(params).not.toHaveProperty('entrance') }) it('reuses the titled, non-archived decision record conversation of the host project', async () => { const { port } = harness({ listConversations: vi.fn(async () => [ conv('c-other', { title: '闲聊' }), conv('c-archived', { archived: true }), conv('c-record'), ]), }) await commitDecision(port, request()) expect(port.listConversations).toHaveBeenCalledWith('host-1') expect(port.createConversation).not.toHaveBeenCalled() expect(port.sendMessage).toHaveBeenCalledWith('c-record', expect.any(String), [], 'decision:op_a') }) it('creates the decision record conversation when the host project has none', async () => { const { port } = harness({ listConversations: vi.fn(async () => []) }) await commitDecision(port, request()) expect(port.createConversation).toHaveBeenCalledWith('host-1', DECISION_RECORD_TITLE) expect(port.sendMessage).toHaveBeenCalledWith('c-new', expect.any(String), [], 'decision:op_a') }) it('anchors on the message id returned by the committed post, never on a different conversation', async () => { const { port } = harness({ sendMessage: vi.fn(async () => ({ message: userMsg('m-9', 'other-conv') })), }) await expect(commitDecision(port, request())).rejects.toThrow('未能提交,审签未执行') expect(port.rdmCommand).not.toHaveBeenCalled() }) it('refuses when the response is not a committed user message', async () => { const assistant = { ...userMsg('m-2', 'c-1'), role: 'assistant' as const } const { port } = harness({ sendMessage: vi.fn(async () => ({ message: assistant })) }) await expect(commitDecision(port, request())).rejects.toThrow('审签未执行') expect(port.rdmCommand).not.toHaveBeenCalled() }) it('refuses when the post was treated as a command rather than recorded as source text', async () => { const { port } = harness({ sendMessage: vi.fn(async (convId: string) => ({ message: userMsg('m-1', convId), command: 'system.intent' })), }) await expect(commitDecision(port, request())).rejects.toThrow('审签未执行') expect(port.rdmCommand).not.toHaveBeenCalled() }) it('refuses without dispatching when the host project is not found', async () => { const { port } = harness() await expect(commitDecision(port, request({ host: undefined }))).rejects.toThrow('未找到该研发项目对应的对话项目') expect(port.listConversations).not.toHaveBeenCalled() expect(port.sendMessage).not.toHaveBeenCalled() expect(port.rdmCommand).not.toHaveBeenCalled() }) it('refuses without any write when the host project is not ready', async () => { const { port } = harness() const host = hostProject({ settings: { 'rdm.state': 'provisioning' } }) await expect(commitDecision(port, request({ host }))).rejects.toThrow('项目正在准备') expect(port.listConversations).not.toHaveBeenCalled() expect(port.sendMessage).not.toHaveBeenCalled() expect(port.rdmCommand).not.toHaveBeenCalled() }) it('shows a readable refusal and does not dispatch when the session has lapsed', async () => { const { port } = harness({ listConversations: vi.fn(async () => { throw new Error('401: unauthorized') }) }) const err = await failureOf(commitDecision(port, request())) expect(err.message).toContain('登录已失效') expect(port.sendMessage).not.toHaveBeenCalled() expect(port.rdmCommand).not.toHaveBeenCalled() }) it('reports a busy conversation as retryable and does not dispatch', async () => { const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('409: conversation busy') }) }) const err = await failureOf(commitDecision(port, request())) expect(err.message).toContain('会话正在处理其他任务,请稍后重试') expect(err.message).toContain('审签未执行') expect(port.rdmCommand).not.toHaveBeenCalled() }) it('reports a forbidden source post as a refusal and does not dispatch', async () => { const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('403: forbidden') }) }) const err = await failureOf(commitDecision(port, request())) expect(err.message).toContain('当前账号无权在该对话中记录审签') expect(port.rdmCommand).not.toHaveBeenCalled() }) it('surfaces a source-preflight rejection from the command step as a readable refusal', async () => { const { port } = harness({ rdmCommand: vi.fn(async () => { throw new Error('project source preflight: formal project command requires an authenticated conversation source') }), }) const err = await failureOf(commitDecision(port, request())) const view = describeRenderFailure(err) expect(view.message).toContain('审签来源尚未通过校验') expect(view.message.startsWith('{')).toBe(false) }) it('retries the same operation with the same request id so the server returns the same source message', async () => { let fail = true const { port } = harness({ sendMessage: vi.fn(async (convId: string) => { if (fail) { fail = false; throw new Error('503: unavailable') } return { message: userMsg('m-1', convId) } }), }) await expect(commitDecision(port, request())).rejects.toThrow() await commitDecision(port, request()) const ids = vi.mocked(port.sendMessage).mock.calls.map(c => c[3]) expect(ids).toEqual(['decision:op_a', 'decision:op_a']) expect(port.rdmCommand).toHaveBeenCalledTimes(1) }) }) describe('double click', () => { it('runs one source post and one command when the action fires twice before settling', async () => { const { port } = harness() const flight = createSingleFlight() const click = () => { if (flight.busy()) return void flight.run(() => commitDecision(port, request())).catch(() => undefined) } click() click() await vi.waitFor(() => expect(port.rdmCommand).toHaveBeenCalledTimes(1)) expect(port.sendMessage).toHaveBeenCalledTimes(1) expect(flight.busy()).toBe(false) }) it('releases the flight after a failure so the readable refusal can be retried', async () => { const flight = createSingleFlight() await expect(flight.run(() => Promise.reject(new Error('x')))).rejects.toThrow('x') expect(flight.busy()).toBe(false) }) }) describe('hostProjectFor', () => { it('finds the host project bound to the RDM project id and ignores unbound projects', () => { const bound = hostProject() const unbound = hostProject({ id: 'host-2', settings: {} }) expect(hostProjectFor([unbound, bound], 'rdm-1')).toBe(bound) expect(hostProjectFor([unbound], 'rdm-1')).toBeUndefined() }) }) describe('command request body', () => { it('keeps the plain body without a source', () => { expect(commandRequestBody({ a: 1 })).toEqual({ params: { a: 1 } }) }) it('places the source fields at the top level and leaves params untouched', () => { expect(commandRequestBody({ a: 1 }, { conversationId: 'c', anchorMessageId: 'm', entrance: 'button' })) .toEqual({ params: { a: 1 }, conversationId: 'c', anchorMessageId: 'm', entrance: 'button' }) }) }) describe('rdmProjectCommandError for decisions', () => { it('gates decision commands on project work readiness', () => { expect(rdmProjectCommandError(hostProject(), 'rdm.review.doc.approve')).toBeNull() expect(rdmProjectCommandError(hostProject({ settings: { 'rdm.state': 'closed' } }), 'rdm.review.doc.approve')).toContain('项目已关闭') }) })