Merge branch 'main' into fix/multiuser-project-collab-r2
governance / governance (pull_request) Successful in 16s
ci / test-and-build (pull_request) Successful in 1m13s

This commit is contained in:
2026-10-20 07:01:35 +00:00
8 changed files with 255 additions and 0 deletions
+20
View File
@@ -49,4 +49,24 @@ describe('CreatorOwnerMemberDuties capability source', () => {
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={[]} />))
.toContain('被加入项目后由成员关系提供基础协作')
})
it('lists each project creation source separately when only one is present', () => {
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理', permissions: [{ actionId: 'project.create', allowed: true }] }} grants={[]} />))
.toContain('创建项目来源:职位角色「项目经理」')
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={null} grants={['project.create']} />))
.toContain('创建项目来源:明确系统授权')
})
})
describe('CapabilityGroup empty preview', () => {
it('renders a no-change preview when baseline equals grants', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={['system.users.view']} grants={['system.users.view']} labels={labels} owner="bob" />)
expect(html).toContain('系统授权没有变化')
})
it('still names the affected user when nothing visible changes', () => {
const html = renderToStaticMarkup(<GrantChangePreview baseline={[]} grants={[]} labels={labels} owner="bob" />)
expect(html).toContain('系统授权没有变化')
expect(html).not.toContain('影响来源')
})
})
@@ -62,4 +62,31 @@ describe('CapabilityGroup role editor', () => {
.toMatch(/关联能力冲突.*整笔拒绝.*本地草稿已保留/)
expect(rolePermissionSaveError(new Error('500: boom'))).toContain('本地草稿已保留')
})
it('keeps every action label visible inside the linked-source list once project creation opens', () => {
const create = groups.find(group => group.id === 'project-create')!
const draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityGroupEditor groups={groups} values={draft.values} labels={labels} disabled onToggle={vi.fn()} />)
expect(html).toContain('管理项目成员:已联动(来源:创建项目,仅本人创建的项目)')
expect(html).toContain('项目设置:已联动')
expect(html).toContain('管理项目权限:已联动')
expect(html).toContain('分配文档三权:已联动')
})
it('splits every preview row by business group when multiple groups change together', () => {
const create = groups.find(group => group.id === 'project-create')!
const duties = groups.find(group => group.id === 'document-duties')!
let draft = setCapabilityGroup(createRolePermissionDraft(values), create, 'allowed', true)
draft = setCapabilityGroup(draft, duties, 'allowed', true)
const html = renderToStaticMarkup(<CapabilityChangePreview changes={capabilityGroupChanges(draft, groups)} breaks={linkedChainBreaks(draft.values)}
labels={labels} roleName="项目经理" bindingCount={7} />)
expect(html).toContain('创建与管理本人项目:默认 拒绝 → 允许')
expect(html).toContain('文档编制与审签:默认 拒绝 → 允许')
})
it('treats a 403 the same as a 400 for linked-capability rejection phrasing', () => {
expect(rolePermissionSaveError(new Error('403: linked capability conflict'))).toMatch(/关联能力冲突/)
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).toContain('本地草稿已保留')
expect(rolePermissionSaveError(new Error('500: unrelated boom'))).not.toMatch(/关联能力冲突/)
})
})
+11
View File
@@ -57,4 +57,15 @@ describe('AutoDiscover sidebar status', () => {
expect(html).toMatch(/<button class="tsb-newtask-main" title="在当前项目新建对话/)
expect(html).toContain('从「新建对话」开始')
})
it('falls back to a project-less sidebar when the current discovery has no project', () => {
const html = render({ projects: [], currentProjectId: null })
expect(html).toContain('尚无项目')
expect(html).not.toContain('同步异常')
})
it('does not render a sync notice when the listing succeeded', () => {
expect(render({ projectSync: { status: 'ok', failures: 0 } })).not.toContain('同步异常')
expect(render({ projectSync: { status: 'idle', failures: 0 } })).not.toContain('同步异常')
})
})
+29
View File
@@ -42,4 +42,33 @@ describe('CreatorOwnerMemberDuties member table', () => {
expect(member).toContain('基础协作')
expect(member).not.toContain('成员增删')
})
it('falls back to plain member duty when the project has no creator recorded', () => {
const owner = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'bob', role: 'owner' }} creator={undefined} />)
expect(owner).toContain('项目负责人')
expect(owner).not.toContain('<span class="ad-chip ad-chip-admin">创建者')
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator={undefined} />)
expect(member).toContain('基础协作')
})
it('marks the creator cell as an admin chip and the plain member as a neutral chip', () => {
const creator = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'alice', role: 'owner' }} creator="alice" />)
const member = renderToStaticMarkup(<MemberDutyCell member={{ owner: 'carol', role: 'member' }} creator="alice" />)
expect(creator).toContain('ad-chip-admin')
expect(member).toMatch(/ad-chip(?!-admin)/)
})
})
describe('MemberManagementEntryHidden notice clarity', () => {
it('names the project-wide stage error as the reason to gate management entries', () => {
const result = memberManagementView([{ action: 'member.manage', allowed: true }], '项目已归档,请联系项目负责人恢复后再新建对话')
expect(result).toEqual({ canManage: false, notice: null })
})
it('distinguishes pending self-check from a confirmed no-permission state', () => {
const pending = memberManagementView(null, null)
const denied = memberManagementView([{ action: 'project.members.manage', allowed: false }], null)
expect(pending.notice).toMatch(/正在核对/)
expect(denied.notice).toMatch(/成员增删由项目创建者负责/)
})
})
+39
View File
@@ -65,4 +65,43 @@ describe('MemberManagementEntryHidden capability', () => {
expect(canManageProjectMembers([{ action: 'member.manage', allowed: true }])).toBe(true)
expect(canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])).toBe(true)
})
it('treats the two old and new capability ids as equivalent for backward compatibility', () => {
const onlyLegacy = canManageProjectMembers([{ action: 'member.manage', allowed: true }])
const onlyModern = canManageProjectMembers([{ action: 'project.members.manage', allowed: true }])
const both = canManageProjectMembers([
{ action: 'member.manage', allowed: true },
{ action: 'project.members.manage', allowed: true },
])
expect(onlyLegacy).toBe(onlyModern)
expect(onlyModern).toBe(true)
expect(both).toBe(true)
})
})
describe('CreatorOwnerMemberDuties member identity', () => {
it('falls back to plain member when the creator record is missing', () => {
const member = memberDuty({ owner: 'carol', role: 'member' }, undefined)
expect(member).toMatchObject({ kind: 'member', label: '项目成员' })
expect(member.duties).toContain('基础协作')
})
it('names the source of each member kind distinctly', () => {
expect(memberDuty({ owner: 'alice', role: 'owner' }, 'alice').source).toMatch(/创建项目/)
expect(memberDuty({ owner: 'bob', role: 'owner' }, 'alice').source).toMatch(/成员关系/)
expect(memberDuty({ owner: 'carol', role: 'member' }, 'alice').source).toBe('成员关系')
})
})
describe('DefaultProjectDisabled empty state messaging', () => {
it('keeps both helpers pointing the member to wait for an invite', () => {
expect(emptyProjectsNotice(true, false)).toContain('等待项目负责人邀请')
expect(noProjectError(true, false)).toContain('等待项目负责人邀请')
})
it('keeps the loading notice distinct from a real empty list', () => {
expect(emptyProjectsNotice(false, false)).toMatch(/加载/)
expect(noProjectError(false, false)).toMatch(/加载/)
expect(emptyProjectsNotice(false, false)).not.toBe(noProjectError(false, false))
})
})
+28
View File
@@ -84,4 +84,32 @@ describe('CapabilityGroup model', () => {
expect(projectCreateSources(role, ['project.create'])).toEqual(['职位角色「项目经理」', '明确系统授权'])
expect(projectCreateSources(null, [])).toEqual([])
})
it('reports each business-group toggle position alongside the linked chain readiness', () => {
const governance = groups.find(group => group.id === 'project-governance')!
const duties = groups.find(group => group.id === 'document-duties')!
expect(capabilityGroupState(values(), governance)).toEqual({ allowed: 'off', capAllowed: 'off', linkedReady: true })
expect(capabilityGroupState(values({ 'document.author': { allowed: true, capAllowed: true },
'document.review': { allowed: false, capAllowed: true } }), duties)).toEqual({
allowed: 'mixed', capAllowed: 'on', linkedReady: true,
})
})
it('keeps the linked additions accurate when an unrelated group also changes', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'capAllowed', true)
draft.values['project.archive'] = { allowed: true, capAllowed: true }
const governanceId = 'project-governance'
const changes = capabilityGroupChanges(draft, groups)
const governance = changes.find(change => change.group.id === governanceId)
expect(governance).toBeDefined()
expect(governance!.linkedAdded).toEqual([])
})
it('keeps draft baseline identity during non-conflict rebases', () => {
const draft = setCapabilityGroup(createRolePermissionDraft(values()), create, 'allowed', true)
const same = rebaseRoleDraft(draft, draft.values)
expect(same.values).toEqual(draft.values)
expect(same.baseline).toEqual(draft.values)
expect(same.generation).toBeGreaterThan(draft.generation)
})
})
+48
View File
@@ -339,4 +339,52 @@ describe('DefaultProjectDisabled', () => {
await expect(store.createTask()).rejects.toThrow(/请先新建项目/)
expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([])
})
it('surfaces the stage-specific project error before any POST is sent', async () => {
projects = [{ ...project('host-archived'), settings: { 'rdm.state': 'archived' } } as Project]
await store.refreshProjects()
requests = []
await expect(store.createTask()).rejects.toThrow(/请先新建项目|归档/)
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
})
describe('AutoDiscover sync ownership on lifecycle events', () => {
it('surfaces sync start and stop without leaking listeners across re-starts', () => {
const env = fakeEnv()
const release: Array<() => void> = []
const port: ProjectSyncEnv = { ...env.port, subscribe: handlers => {
const releaseFn = () => {}
release.push(releaseFn)
env.port.subscribe(handlers)
return () => { releaseFn() }
} }
store.startProjectSync(port)
store.stopProjectSync()
store.startProjectSync(port)
expect(release.length).toBe(2)
store.stopProjectSync()
})
it('keeps the store sync state idle after a clean stop and ready to re-start', () => {
store.startProjectSync(fakeEnv().port)
store.stopProjectSync()
expect(store.getState().projectSync.status).toBe('idle')
expect(store.getState().projectSync.failures).toBe(0)
})
it('drops the late response when an identity reset interleaves a sync', async () => {
vi.useFakeTimers()
const env = fakeEnv()
const pending = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? pending.promise : previous(path, init)
store.startProjectSync(env.port)
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
store.resetIdentity()
pending.resolve(response([project('host-late')]))
await vi.advanceTimersByTimeAsync(0)
expect(store.getState().projects.find(p => p.id === 'host-late')).toBeUndefined()
store.stopProjectSync()
})
})
+53
View File
@@ -329,3 +329,56 @@ describe('conversation work reading', () => {
expect(render()).toContain('会覆盖旧草稿,原件保留')
})
})
describe('ProjectLifecycleConversation work record', () => {
it('opens a project lifecycle conversation with a single focused work card and a closed history', () => {
snapshot.state!.cards = [card({ title: '整理材料', status: 'active' }), card({
id: 'history_1', status: 'done', tone: 'ok', title: '旧一轮记录',
body: [{ type: 'markdown', markdown: '项目准备阶段的总结报告' }],
updatedAt: '2026-10-12T08:00:00Z',
})]
const html = renderWork()
expect(html).toContain('整理材料')
expect(html).not.toContain('项目准备阶段的总结报告')
expect(html).toContain('更多工作与成果 · 1 项')
})
it('names a project lifecycle conversation by its own work record when the right work pane is closed', () => {
snapshot.state!.workRecord = { cardId: 'card_1', runId: 'run_1', conversationId: 'cnv_1', title: '材料整理' }
snapshot.state!.cards = [card()]
const html = render()
expect(html).toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
})
it('falls back to a project lifecycle conversation level when the auxiliary work pane already owns the detail', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'work'
snapshot.state!.cards = [card()]
const html = render()
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
})
it('keeps the project lifecycle conversation summary headline visible regardless of the right pane state', () => {
snapshot.state!.cards = [card({ status: 'done', work: { result: 'project-lifecycle-summary-result', resultVersion: 1, readVersion: 0 } })]
snapshot.state!.ui.rightCollapsed = false
const html = render()
expect(html).toContain('1 项工作有新结果')
expect(html).not.toContain('project-lifecycle-summary-result')
})
it('lists only the project lifecycle conversation\'s active work alongside its completed history', () => {
snapshot.state!.cards = [
card({ status: 'active' }),
card({ id: 'other_project', projectId: 'prj_2', conversationId: 'cnv_2', title: '其它项目的工作',
body: [{ type: 'kv', kv: [['正在做', '其它项目的工作细节']] }] }),
card({ id: 'other_conv', projectId: 'prj_1', conversationId: 'cnv_9', title: '其它会话的工作',
body: [{ type: 'kv', kv: [['正在做', '其它会话的工作细节']] }] }),
]
const html = renderWork()
expect(html).toContain('核对会议结论')
expect(html).not.toContain('其它项目的工作细节')
expect(html).not.toContain('其它会话的工作细节')
})
})