Files
Agent-System-Frontend/test/pal-u6-ui.test.ts
T

278 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 计划 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()
})
})