Files
Agent-System-Frontend/test/rdm-members-search.test.ts
T

207 lines
9.9 KiB
TypeScript

// 项目成员页检索(R2 契约):服务端先过滤后分页,前端只拼参数、映射候选与追加分页。
// node 环境无 DOM——决策逻辑全部落在 src/rdmManagement.ts 的纯函数上逐项断言。
import { describe, expect, it } from 'vitest'
import type { Project } from '../src/contracts'
import {
INVALID_REASON, JOINED_REASON, MATCH_MODE_LABELS, MEMBER_PAGE_LIMIT, NOT_MEMBER_REASON, RDM_MEMBER_COMMANDS,
SEARCH_FIELDS, accountCandidatePager, accountListParams, accountSearchKey, accountsToPickItems, appendMemberPage,
memberListParams, personLabel, rdmMembersReady, rdmProjectCommandError, roleOptionsPayload,
type RdmAccountPage, type RdmMemberRow,
} from '../src/rdmManagement'
const row = (owner: string, name?: string): RdmMemberRow =>
({ owner, name, role: 'member', addedBy: 'admin', addedAt: '' })
describe('accountListParams', () => {
it('passes mode, searchFields, roleId and cursor to rdm.account.list', () => {
expect(accountListParams('p1', {
query: ' zhang ', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'],
})).toEqual({ projectId: 'p1', query: 'zhang', cursor: 'c2', limit: 20, roleId: 'r-eng', mode: 'exact', searchFields: ['name'] })
})
it('passes docId for document duty candidates', () => {
expect(accountListParams('p1', { query: 'li', limit: 20, docId: 'd1', searchFields: ['owner', 'name'] }))
.toEqual({ projectId: 'p1', query: 'li', limit: 20, mode: 'fuzzy', searchFields: ['owner', 'name'], docId: 'd1' })
})
it('never sends the legacy match alias next to mode', () => {
const params = accountListParams('p1', { query: 'li', limit: 20, mode: 'exact' })
expect(params).not.toHaveProperty('match')
expect(params.mode).toBe('exact')
})
it('omits empty filters, defaults to fuzzy and caps the page at 50', () => {
expect(accountListParams('p1', { query: ' ', cursor: '', limit: 200, roleId: '', docId: '', searchFields: [] }))
.toEqual({ projectId: 'p1', limit: 50, mode: 'fuzzy' })
})
it('labels both match modes and the search field choices in Chinese', () => {
expect(MATCH_MODE_LABELS).toEqual({ fuzzy: '模糊匹配', exact: '精确账号或姓名' })
expect(SEARCH_FIELDS.both.fields).toEqual(['owner', 'name'])
expect(SEARCH_FIELDS.owner.fields).toEqual(['owner'])
expect(SEARCH_FIELDS.name.fields).toEqual(['name'])
})
})
describe('accountCandidatePager', () => {
const recorder = (pages: RdmAccountPage[]) => {
const calls: Record<string, unknown>[] = []
const run = async (params: Record<string, unknown>) => { calls.push(params); return pages.shift() ?? {} }
return { calls, run }
}
const acct = (userId: string, owner: string) => ({ userId, owner, valid: true, joined: false })
it('does not query the directory for an empty search', async () => {
const { calls, run } = recorder([])
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
expect(await page(' ', '', 20)).toEqual({ items: [] })
expect(calls).toEqual([])
})
it('pages with the opaque next cursor under the same query and options', async () => {
const { calls, run } = recorder([{ accounts: [acct('u1', 'a1')], next: 'n1' }, { accounts: [acct('u2', 'a2')] }])
const page = accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner'], docId: 'd1' })
const first = await page('zh', '', 20)
expect(first.nextCursor).toBe('n1')
await page('zh', 'n1', 20)
expect(calls).toEqual([
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1' },
{ projectId: 'p1', query: 'zh', limit: 20, mode: 'exact', searchFields: ['owner'], docId: 'd1', cursor: 'n1' },
])
})
it('resets the cursor when the query changes', async () => {
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }])
const page = accountCandidatePager(run, 'p1', { mode: 'fuzzy' })
await page('zh', '', 20)
await page('zhang', 'n1', 20)
expect(calls[1]).not.toHaveProperty('cursor')
})
it('resets the cursor when mode or search fields change (new pager, new reload key)', async () => {
const { calls, run } = recorder([{ accounts: [], next: 'n1' }, { accounts: [] }, { accounts: [] }])
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['owner', 'name'] })('zh', '', 20)
await accountCandidatePager(run, 'p1', { mode: 'exact', searchFields: ['owner', 'name'] })('zh', 'n1', 20)
await accountCandidatePager(run, 'p1', { mode: 'fuzzy', searchFields: ['name'] })('zh', 'n1', 20)
expect(calls[1]).not.toHaveProperty('cursor')
expect(calls[2]).not.toHaveProperty('cursor')
const key = (mode: 'fuzzy' | 'exact', fields: ('owner' | 'name')[], docId?: string) =>
accountSearchKey('p1', { mode, searchFields: fields, docId })
expect(new Set([key('fuzzy', ['owner', 'name']), key('exact', ['owner', 'name']), key('fuzzy', ['name']),
key('fuzzy', ['owner', 'name'], 'd1')]).size).toBe(4)
})
it('maps duty candidates so only current members are selectable', async () => {
const { run } = recorder([{ accounts: [
{ userId: 'u1', owner: 'm1', name: '成员', valid: true, joined: true },
{ userId: 'u2', owner: 'x1', valid: true, joined: false },
] }])
const { items } = await accountCandidatePager(run, 'p1', { docId: 'd1' })('m', '', 20)
expect(items).toEqual([
{ id: 'u1', owner: 'm1', title: '成员', subtitle: 'm1' },
{ id: 'u2', owner: 'x1', title: 'x1', subtitle: 'x1', disabled: true, disabledReason: NOT_MEMBER_REASON },
])
})
})
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('keys candidates by stable userId and drops rows without one', () => {
const items = accountsToPickItems([
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: false },
{ userId: '', owner: 'anon', valid: true, joined: false },
])
expect(items).toEqual([{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a' }])
})
it('shows invalid accounts as not selectable', () => {
const [it] = accountsToPickItems([{ userId: 'u-g', owner: 'gone', name: '离职', valid: false, joined: false }])
expect(it).toMatchObject({ id: 'u-g', disabled: true, disabledReason: INVALID_REASON })
})
it('disables joined candidates with the 已加入项目 reason instead of a badge', () => {
const items = accountsToPickItems([
{ userId: 'u-a', owner: 'a', name: '甲', valid: true, joined: true },
{ userId: 'u-b', owner: 'b', valid: true, joined: false },
{ userId: 'u-c', owner: 'c', name: '丙', valid: true },
], ['b'])
expect(items).toEqual([
{ id: 'u-a', owner: 'a', title: '甲', subtitle: 'a', disabled: true, disabledReason: JOINED_REASON },
{ id: 'u-b', owner: 'b', title: 'b', subtitle: 'b', disabled: true, disabledReason: '已加入项目' },
{ id: 'u-c', owner: '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([{ userId: 'u-z', 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)
})
})