// 项目成员页检索(R2 契约):服务端先过滤后分页,前端只拼参数、映射候选与追加分页。 // node 环境无 DOM——决策逻辑全部落在 src/rdmManagement.ts 的纯函数上逐项断言。 import { describe, expect, it } from 'vitest' import type { Project } from '../src/contracts' import { JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, RDM_MEMBER_COMMANDS, accountListParams, accountsToPickItems, appendMemberPage, memberListParams, personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload, type RdmMemberRow, } from '../src/rdmManagement' const row = (owner: string, name?: string): RdmMemberRow => ({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' }) describe('accountListParams', () => { it('passes roleId, match and cursor to rdm.account.list', () => { expect(accountListParams('p1', { query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' })) .toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', match: 'exact' }) }) it('omits empty filters and defaults to fuzzy matching', () => { expect(accountListParams('p1', { query: ' ', cursor: '', limit: 20, roleId: '' })) .toEqual({ projectId: 'p1', limit: 20, match: 'fuzzy' }) }) it('labels both match modes in Chinese', () => { expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' }) }) }) describe('memberListParams', () => { it('sends query, roleId and cursor with a page size of 50', () => { expect(memberListParams('p1', { query: 'li', roleId: 'r-qa', cursor: 'n1' })) .toEqual({ projectId: 'p1', query: 'li', roleId: 'r-qa', cursor: 'n1', limit: MEMBER_PAGE_LIMIT }) expect(MEMBER_PAGE_LIMIT).toBe(50) }) it('loads the unfiltered first page when no filter is set', () => { expect(memberListParams('p1', { query: '', roleId: '', cursor: '' })).toEqual({ projectId: 'p1', limit: 50 }) }) it('forwards the optional match and projectRole filters', () => { expect(memberListParams('p1', { match: 'exact', projectRole: 'owner', limit: 10 })) .toEqual({ projectId: 'p1', limit: 10, match: 'exact', projectRole: 'owner' }) }) }) describe('roleOptionsPayload', () => { it('asks for member-purpose role options', () => { expect(roleOptionsPayload('p1')).toEqual({ projectId: 'p1', purpose: 'members' }) }) }) describe('accountsToPickItems', () => { it('drops invalid accounts', () => { const items = accountsToPickItems([ { owner: 'a', name: '甲', valid: true, joined: false }, { owner: 'gone', name: '离职', valid: false, joined: false }, ]) expect(items.map(it => it.id)).toEqual(['a']) }) it('disables joined candidates with the 已加入项目 reason instead of a badge', () => { const items = accountsToPickItems([ { owner: 'a', name: '甲', valid: true, joined: true }, { owner: 'b', valid: true, joined: false }, { owner: 'c', name: '丙', valid: true }, ], ['b']) expect(items).toEqual([ { id: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON }, { id: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' }, { id: 'c', title: '丙', subtitle: 'c' }, ]) expect(items.some(it => 'badge' in it)).toBe(false) }) it('keeps name as title and account as subtitle so the selection shows both', () => { const [it] = accountsToPickItems([{ owner: 'zhangsan', name: '张三', valid: true, joined: false }]) expect(it.title).toBe('张三') expect(it.subtitle).toBe('zhangsan') }) it('tolerates a missing account list', () => { expect(accountsToPickItems(undefined)).toEqual([]) }) }) describe('personLabel', () => { it('renders name · account', () => { expect(personLabel({ owner: 'zhangsan', name: '张三' })).toBe('张三 · zhangsan') }) it('falls back to the account when there is no name', () => { expect(personLabel({ owner: 'zhangsan' })).toBe('zhangsan') expect(personLabel({ owner: 'zhangsan', name: ' ' })).toBe('zhangsan') expect(personLabel({ owner: 'zhangsan', name: 'zhangsan' })).toBe('zhangsan') }) }) describe('appendMemberPage', () => { it('appends the next page in order without duplicating accounts', () => { const first = [row('a', '甲'), row('b')] const got = appendMemberPage(first, [row('b', '乙-重复'), row('c', '丙')]) expect(got.map(m => m.owner)).toEqual(['a', 'b', 'c']) expect(got[1].name).toBeUndefined() expect(first.map(m => m.owner)).toEqual(['a', 'b']) }) it('starts a fresh list from null and dedupes within a page', () => { expect(appendMemberPage(null, [row('a'), row('a')]).map(m => m.owner)).toEqual(['a']) expect(appendMemberPage(null, undefined)).toEqual([]) }) }) describe('rdm.role.options command gating', () => { it('is a project read command, so archived projects can still load role filters', () => { const archived = { id: 'p1', settings: { 'rdm.state': 'archived', 'rdm.projectId': 'rdm-1' } } as unknown as Project expect(rdmProjectCommandError(archived, 'rdm.role.options')).toBeNull() }) it('does not gate member page readiness', () => { expect(RDM_MEMBER_COMMANDS).not.toContain('rdm.role.options') expect(rdmMembersReady(RDM_MEMBER_COMMANDS.map(id => ({ id, enabled: true })))).toBe(true) }) })