278 lines
17 KiB
TypeScript
278 lines
17 KiB
TypeScript
// 计划 U6(项目访问生命周期)界面呈现:工作状态控制消费 U4 合同(rdm.project.work.close/reopen、
|
||
// rdm.project.member.suspend/reactivate),模板浏览消费 U5 合同(rdm.docs.templates/instantiate)。
|
||
// 锁定:命令面门控、载荷形状、文字徽标(非颜色唯一)、禁用逻辑、高危确认流与 unsupported 拒绝。
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { createElement } from 'react'
|
||
import { renderToStaticMarkup } from 'react-dom/server'
|
||
import { api } from '../src/api'
|
||
import type { Conversation, Message, Project } from '../src/contracts'
|
||
import { MemberWorkCell, ProjectWorkCardView, TemplateListView, WorkStateBadge } from '../src/components/adminRdmSections'
|
||
import {
|
||
CONFIRM_CARD_PENDING, ConfirmCardPending, RDM_DOC_TEMPLATE_COMMANDS, RDM_WORK_STATE_COMMANDS,
|
||
TEMPLATE_RECORD_TITLE, WORK_CONFIRM_RECORD_TITLE, WORK_STATE_NOTICES,
|
||
commandRequestBody, commitTemplateInstantiate, highDangerSource, instantiatePayload, instantiateSourceText,
|
||
isHighDangerCommand, memberWorkControl, memberWorkPayload, memberWorkState, projectWorkClosed, projectWorkControl,
|
||
projectWorkPayload, projectWorkStatus, rdmDocTemplatesReady, rdmWorkStatesReady, templateSelectable,
|
||
type DocTemplateRow, type InstantiatePort,
|
||
} from '../src/rdmManagement'
|
||
|
||
const project = (settings: Record<string, unknown> = {}, owner = 'alice'): Project => ({
|
||
id: 'host-1', name: '宿主项目', owner, createdAt: '', updatedAt: '',
|
||
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready', ...settings },
|
||
})
|
||
const active = project({ 'rdm.workState': 'active', 'rdm.workVersion': 3 })
|
||
const closed = project({ 'rdm.workState': 'closed', 'rdm.workVersion': 4 })
|
||
const conv = (id: string, over: Partial<Conversation> = {}): Conversation => ({
|
||
id, projectId: 'host-1', title: TEMPLATE_RECORD_TITLE, createdAt: '', updatedAt: '', ...over,
|
||
})
|
||
const userMsg = (id: string, conversationId: string): Message => ({ id, conversationId, role: 'user', parts: [], createdAt: '' })
|
||
const template = (over: Partial<DocTemplateRow> = {}): DocTemplateRow => ({
|
||
templateId: 'tpl-srs', version: '1.2.0', title: '软件需求规格说明', docTypes: ['SRS'], stage: 'design',
|
||
state: 'published', sourceFormat: 'docx', sourceSha256: 'abc123', generation: 'supported', ...over,
|
||
})
|
||
const unsupported = template({
|
||
templateId: 'tpl-risk', version: '0.9.0', title: '风险管理报告', generation: 'unsupported',
|
||
unsupportedReason: 'dhf: 模板包缺失签署区映射', state: 'needs-review',
|
||
})
|
||
const noop = () => undefined
|
||
const html = (el: Parameters<typeof renderToStaticMarkup>[0]) => renderToStaticMarkup(el)
|
||
|
||
describe('命令面门控(fail-closed)', () => {
|
||
const cmds = (ids: readonly string[], enabled = true) => ids.map(id => ({ id, enabled }))
|
||
it('工作状态四条命令齐备且 enabled 才就绪', () => {
|
||
expect(rdmWorkStatesReady(cmds(RDM_WORK_STATE_COMMANDS))).toBe(true)
|
||
expect(rdmWorkStatesReady(cmds(RDM_WORK_STATE_COMMANDS.slice(1)))).toBe(false)
|
||
expect(rdmWorkStatesReady(cmds(RDM_WORK_STATE_COMMANDS, false))).toBe(false)
|
||
expect(rdmWorkStatesReady(null)).toBe(false)
|
||
})
|
||
it('模板入口需要清单与实例化两条命令', () => {
|
||
expect(rdmDocTemplatesReady(cmds(RDM_DOC_TEMPLATE_COMMANDS))).toBe(true)
|
||
expect(rdmDocTemplatesReady(cmds(['rdm.docs.templates']))).toBe(false)
|
||
expect(rdmDocTemplatesReady(undefined)).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('工作状态读面与载荷', () => {
|
||
it('项目工作状态取自项目列表的 rdm.workState / rdm.workVersion;缺任一不渲染控制', () => {
|
||
expect(projectWorkStatus(active)).toEqual({ state: 'active', version: 3 })
|
||
expect(projectWorkStatus(closed)).toEqual({ state: 'closed', version: 4 })
|
||
expect(projectWorkStatus(project({ 'rdm.workState': 'closed' }))).toBeNull()
|
||
expect(projectWorkStatus(project({ 'rdm.workState': 'weird', 'rdm.workVersion': 1 }))).toBeNull()
|
||
expect(projectWorkStatus(undefined)).toBeNull()
|
||
expect(projectWorkClosed(closed)).toBe(true)
|
||
expect(projectWorkClosed(active)).toBe(false)
|
||
})
|
||
it('载荷按命令 paramsSchema:projectId + expectedVersion(成员命令另带 member),不含身份字段', () => {
|
||
expect(projectWorkPayload('host-1', 3)).toEqual({ projectId: 'host-1', expectedVersion: 3 })
|
||
expect(memberWorkPayload('host-1', 'bob', 7)).toEqual({ projectId: 'host-1', member: 'bob', expectedVersion: 7 })
|
||
})
|
||
it('成员状态:缺省 active;suspended/removed 原样保留', () => {
|
||
expect(memberWorkState({})).toBe('active')
|
||
expect(memberWorkState({ state: 'suspended' })).toBe('suspended')
|
||
expect(memberWorkState({ state: 'removed' })).toBe('removed')
|
||
})
|
||
it('只有 close 与 suspend 是高危命令', () => {
|
||
expect(RDM_WORK_STATE_COMMANDS.filter(isHighDangerCommand)).toEqual(['rdm.project.work.close', 'rdm.project.member.suspend'])
|
||
})
|
||
})
|
||
|
||
describe('控件禁用逻辑', () => {
|
||
it('项目卡:active→关闭,closed→重新开启;非创建者禁用并说明', () => {
|
||
expect(projectWorkControl({ state: 'active', version: 1 }, true)).toMatchObject({ command: 'rdm.project.work.close', disabledReason: '' })
|
||
expect(projectWorkControl({ state: 'closed', version: 2 }, true)).toMatchObject({ command: 'rdm.project.work.reopen', disabledReason: '' })
|
||
expect(projectWorkControl({ state: 'closed', version: 2 }, false).disabledReason).toContain('仅项目创建者')
|
||
})
|
||
it('成员行:active→暂停,suspended→恢复,removed 无控件;创建者自身不可暂停;缺版本禁用', () => {
|
||
const ctx = { isCreator: true, creator: 'alice' }
|
||
expect(memberWorkControl({ owner: 'bob', state: 'active', stateVersion: 1 }, ctx))
|
||
.toMatchObject({ command: 'rdm.project.member.suspend', disabledReason: '' })
|
||
expect(memberWorkControl({ owner: 'bob', state: 'suspended', stateVersion: 2 }, ctx))
|
||
.toMatchObject({ command: 'rdm.project.member.reactivate', disabledReason: '' })
|
||
expect(memberWorkControl({ owner: 'bob', state: 'removed', stateVersion: 3 }, ctx)).toBeNull()
|
||
expect(memberWorkControl({ owner: 'alice', state: 'active', stateVersion: 1 }, ctx)?.disabledReason).toContain('创建者不可暂停')
|
||
expect(memberWorkControl({ owner: 'bob', state: 'active' }, ctx)?.disabledReason).toContain('状态版本')
|
||
expect(memberWorkControl({ owner: 'bob', state: 'active', stateVersion: 1 }, { isCreator: false, creator: 'alice' })?.disabledReason)
|
||
.toContain('仅项目创建者')
|
||
})
|
||
})
|
||
|
||
describe('状态呈现(文字徽标,非颜色唯一)', () => {
|
||
it.each([
|
||
['active', '正常'], ['suspended', '已暂停'], ['removed', '已移除'], ['closed', '已关闭'],
|
||
] as const)('%s 渲染为文字徽标「%s」', (state, label) => {
|
||
const out = html(createElement(WorkStateBadge, { state }))
|
||
expect(out).toContain(`data-work-state="${state}"`)
|
||
expect(out).toContain(`>${label}<`)
|
||
})
|
||
|
||
it('closed 项目:只读说明 + 创建者「重新开启」保持可用', () => {
|
||
const out = html(createElement(ProjectWorkCardView, { status: { state: 'closed', version: 4 }, isCreator: true, busy: false, onRun: noop }))
|
||
expect(out).toContain('已关闭')
|
||
expect(out).toContain(WORK_STATE_NOTICES.closed)
|
||
expect(out).toContain('重新开启')
|
||
expect(out).not.toContain('disabled=""')
|
||
})
|
||
|
||
it('active 项目:关闭按钮标明需确认;非创建者禁用并显示原因', () => {
|
||
const creator = html(createElement(ProjectWorkCardView, { status: { state: 'active', version: 3 }, isCreator: true, busy: false, onRun: noop }))
|
||
expect(creator).toContain('关闭项目工作(需确认)')
|
||
expect(creator).toContain('正常')
|
||
const other = html(createElement(ProjectWorkCardView, { status: { state: 'active', version: 3 }, isCreator: false, busy: false, onRun: noop }))
|
||
expect(other).toContain('disabled=""')
|
||
expect(other).toContain('仅项目创建者可关闭或重新开启项目工作')
|
||
})
|
||
|
||
it('成员行:suspended 显示「已暂停」+ 恢复;removed 只读无控件;busy 时禁用', () => {
|
||
const suspended = html(createElement(MemberWorkCell, { row: { owner: 'bob', state: 'suspended', stateVersion: 2 }, isCreator: true, creator: 'alice', busy: false, onRun: noop }))
|
||
expect(suspended).toContain('已暂停')
|
||
expect(suspended).toContain('>恢复<')
|
||
const removed = html(createElement(MemberWorkCell, { row: { owner: 'bob', state: 'removed', stateVersion: 3 }, isCreator: true, creator: 'alice', busy: false, onRun: noop }))
|
||
expect(removed).toContain('已移除')
|
||
expect(removed).not.toContain('<button')
|
||
const busy = html(createElement(MemberWorkCell, { row: { owner: 'bob', state: 'active', stateVersion: 1 }, isCreator: true, creator: 'alice', busy: true, onRun: noop }))
|
||
expect(busy).toContain('>暂停<')
|
||
expect(busy).toContain('disabled=""')
|
||
})
|
||
})
|
||
|
||
describe('命令总线管道(经既有 rdmProjectCommand)', () => {
|
||
let requests: { path: string; body: any }[]
|
||
let reply: () => Response
|
||
beforeEach(() => {
|
||
requests = []
|
||
reply = () => new Response(JSON.stringify({ payload: { ok: true } }))
|
||
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
|
||
requests.push({ path, body: init?.body ? JSON.parse(String(init.body)) : undefined })
|
||
return reply()
|
||
})
|
||
})
|
||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||
|
||
it('非高危命令只发 params,projectId 换成已绑定的研发项目', async () => {
|
||
await api.rdmProjectCommand(closed, 'rdm.project.work.reopen', projectWorkPayload(closed.id, 4))
|
||
expect(requests).toEqual([{ path: '/api/commands/rdm.project.work.reopen', body: { params: { projectId: 'domain-7', expectedVersion: 4 } } }])
|
||
})
|
||
|
||
it('高危命令携带对话上下文(无锚点);返回确认卡时抛出带卡片 id 的 ConfirmCardPending', async () => {
|
||
reply = () => new Response(JSON.stringify({ receipt: { state: 'accepted' }, card: { id: 'card-9' } }))
|
||
const run = api.rdmProjectCommand(active, 'rdm.project.member.suspend', memberWorkPayload(active.id, 'bob', 2),
|
||
{ conversationId: 'c-confirm', entrance: 'button' })
|
||
await expect(run).rejects.toBeInstanceOf(ConfirmCardPending)
|
||
await expect(run).rejects.toMatchObject({ cardId: 'card-9', message: CONFIRM_CARD_PENDING })
|
||
expect(requests[0].body).toEqual({
|
||
params: { projectId: 'domain-7', member: 'bob', expectedVersion: 2 }, conversationId: 'c-confirm', entrance: 'button',
|
||
})
|
||
})
|
||
|
||
it('陈旧版本被拒(4xx)按既有错误管道原样抛出', async () => {
|
||
reply = () => new Response(JSON.stringify({ receipt: { state: 'rejected', error: '工作状态已被并发修改' } }), { status: 400 })
|
||
await expect(api.rdmProjectCommand(active, 'rdm.project.work.close', projectWorkPayload(active.id, 2), { conversationId: 'c', entrance: 'button' }))
|
||
.rejects.toThrow(/400: .*并发修改/)
|
||
})
|
||
|
||
it('请求体:锚点缺省不携带,正式写命令的锚点保留', () => {
|
||
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', entrance: 'button' })).toEqual({ params: { a: 1 }, conversationId: 'c', entrance: 'button' })
|
||
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', anchorMessageId: 'm', entrance: 'button' }))
|
||
.toEqual({ params: { a: 1 }, conversationId: 'c', anchorMessageId: 'm', entrance: 'button' })
|
||
})
|
||
|
||
it('高危确认上下文:复用未归档的确认记录对话,否则新建;不发送消息', async () => {
|
||
const port = {
|
||
listConversations: vi.fn(async () => [conv('c-arch', { title: WORK_CONFIRM_RECORD_TITLE, archived: true })]),
|
||
createConversation: vi.fn(async (_p: string, title?: string) => conv('c-new', { title: title ?? '' })),
|
||
}
|
||
expect(await highDangerSource(port, active)).toEqual({ conversationId: 'c-new', entrance: 'button' })
|
||
expect(port.createConversation).toHaveBeenCalledWith('host-1', WORK_CONFIRM_RECORD_TITLE)
|
||
port.listConversations.mockResolvedValueOnce([conv('c-live', { title: WORK_CONFIRM_RECORD_TITLE })])
|
||
expect(await highDangerSource(port, active)).toEqual({ conversationId: 'c-live', entrance: 'button' })
|
||
})
|
||
})
|
||
|
||
describe('模板浏览与实例化(U5 合同)', () => {
|
||
it('列表:全部条目可见;unsupported 禁用选择并显示 unsupportedReason', () => {
|
||
const out = html(createElement(TemplateListView, { templates: [template(), unsupported], selectedKey: '', blockedReason: null, onChoose: noop }))
|
||
expect(out).toContain('软件需求规格说明')
|
||
expect(out).toContain('风险管理报告')
|
||
expect(out).toContain('tpl-srs@1.2.0')
|
||
expect(out).toContain('tpl-risk@0.9.0')
|
||
expect(out).toContain('不可生成:dhf: 模板包缺失签署区映射')
|
||
expect(out).toContain('待复核')
|
||
expect(out.match(/disabled=""/g)).toHaveLength(1)
|
||
})
|
||
|
||
it('项目工作已关闭时全部选择禁用', () => {
|
||
const out = html(createElement(TemplateListView, { templates: [template(), unsupported], selectedKey: '', blockedReason: WORK_STATE_NOTICES.closed, onChoose: noop }))
|
||
expect(out.match(/disabled=""/g)).toHaveLength(2)
|
||
})
|
||
|
||
it('可选判定与 instantiate 同源:generation≠supported 或缺文档类型即不可选', () => {
|
||
expect(templateSelectable(template())).toEqual({ ok: true, reason: '' })
|
||
expect(templateSelectable(unsupported)).toEqual({ ok: false, reason: 'dhf: 模板包缺失签署区映射' })
|
||
expect(templateSelectable(template({ generation: 'unsupported', unsupportedReason: '' })).ok).toBe(false)
|
||
expect(templateSelectable(template({ docTypes: [] })).ok).toBe(false)
|
||
expect(templateSelectable(template({ docTypes: null })).ok).toBe(false)
|
||
})
|
||
|
||
it('载荷按 paramsSchema 携带 templateId/version 溯源;title 为空不携带', () => {
|
||
expect(instantiatePayload('domain-7', template(), 'SRS', ' ')).toEqual({ projectId: 'domain-7', docType: 'SRS', templateId: 'tpl-srs', version: '1.2.0' })
|
||
expect(instantiatePayload('domain-7', template(), 'SRS', ' 需求 v1 ')).toEqual({
|
||
projectId: 'domain-7', docType: 'SRS', templateId: 'tpl-srs', version: '1.2.0', title: '需求 v1',
|
||
})
|
||
expect(() => instantiatePayload('domain-7', unsupported, 'SRS')).toThrow('签署区映射')
|
||
expect(() => instantiatePayload('domain-7', template(), 'SDD')).toThrow('不在该模板的适用范围内')
|
||
})
|
||
|
||
it('来源正文含模板身份与版本,首字符不为 “/”', () => {
|
||
const text = instantiateSourceText(template(), 'SRS', '')
|
||
expect(text).toContain('模板:软件需求规格说明(tpl-srs@1.2.0)')
|
||
expect(text).toContain('文档标题:沿用模板标题')
|
||
expect(text.startsWith('/')).toBe(false)
|
||
})
|
||
|
||
function harness(over: Partial<InstantiatePort> = {}) {
|
||
const calls: string[] = []
|
||
const port: InstantiatePort = {
|
||
listConversations: vi.fn(async () => { calls.push('list'); return [conv('c-1')] }),
|
||
createConversation: vi.fn(async (_p: 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 { docId: 'doc-1' } }),
|
||
...over,
|
||
}
|
||
return { port, calls }
|
||
}
|
||
|
||
it('显式选择后:记录来源消息再以其为锚点分发 rdm.docs.instantiate', async () => {
|
||
const { port, calls } = harness()
|
||
const out = await commitTemplateInstantiate(port, { host: active, operationId: 'op-1', template: template(), docType: 'SRS', title: '' })
|
||
expect(out).toEqual({ docId: 'doc-1' })
|
||
expect(calls).toEqual(['list', 'send', 'command'])
|
||
expect(port.sendMessage).toHaveBeenCalledWith('c-1', expect.stringContaining('tpl-srs@1.2.0'), [], 'instantiate:op-1')
|
||
expect(port.rdmCommand).toHaveBeenCalledWith('rdm.docs.instantiate',
|
||
{ projectId: 'domain-7', docType: 'SRS', templateId: 'tpl-srs', version: '1.2.0' },
|
||
{ conversationId: 'c-1', anchorMessageId: 'm-1', entrance: 'button' })
|
||
})
|
||
|
||
it('unsupported 条目、工作已关闭、未绑定项目:一律拒绝且不记录、不分发', async () => {
|
||
for (const [host, tpl, pattern] of [
|
||
[active, unsupported, /签署区映射/],
|
||
[closed, template(), /项目工作已关闭/],
|
||
[project({ 'rdm.state': 'provisioning' }), template(), /正在准备/],
|
||
] as const) {
|
||
const { port, calls } = harness()
|
||
await expect(commitTemplateInstantiate(port, { host, operationId: 'op', template: tpl, docType: 'SRS', title: '' })).rejects.toThrow(pattern)
|
||
expect(calls).toEqual([])
|
||
}
|
||
})
|
||
|
||
it('来源消息未提交或被识别为命令:不分发', async () => {
|
||
const asCommand = harness({ sendMessage: vi.fn(async () => ({ command: '/x' })) })
|
||
await expect(commitTemplateInstantiate(asCommand.port, { host: active, operationId: 'op', template: template(), docType: 'SRS', title: '' }))
|
||
.rejects.toThrow('识别为命令')
|
||
expect(asCommand.port.rdmCommand).not.toHaveBeenCalled()
|
||
const wrongConv = harness({ sendMessage: vi.fn(async () => ({ message: userMsg('m-2', 'other') })) })
|
||
await expect(commitTemplateInstantiate(wrongConv.port, { host: active, operationId: 'op', template: template(), docType: 'SRS', title: '' }))
|
||
.rejects.toThrow('未返回已提交的对话消息')
|
||
expect(wrongConv.port.rdmCommand).not.toHaveBeenCalled()
|
||
})
|
||
})
|