469 lines
14 KiB
TypeScript
469 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildMemberDraftColorMap,
|
|
buildMembersFromDrafts,
|
|
createMemberDraft,
|
|
createMemberDraftsFromInputs,
|
|
filterEditableMemberInputs,
|
|
normalizeLeadProviderForMode,
|
|
} from '@renderer/components/team/members/MembersEditorSection';
|
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
|
import type { ResolvedTeamMember } from '@shared/types';
|
|
|
|
describe('members editor editable input filtering', () => {
|
|
it('keeps OpenCode available for the team lead only when multimodel is enabled', () => {
|
|
expect(normalizeLeadProviderForMode('opencode', true)).toBe('opencode');
|
|
expect(normalizeLeadProviderForMode('codex', true)).toBe('codex');
|
|
expect(normalizeLeadProviderForMode('anthropic', true)).toBe('anthropic');
|
|
expect(normalizeLeadProviderForMode('opencode', false)).toBe('anthropic');
|
|
});
|
|
|
|
it('filters the canonical team lead out of editable member inputs', () => {
|
|
const members = [
|
|
{
|
|
name: 'team-lead',
|
|
agentType: 'team-lead',
|
|
},
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
},
|
|
{
|
|
name: 'bob',
|
|
agentType: 'developer',
|
|
},
|
|
] satisfies Pick<ResolvedTeamMember, 'name' | 'agentType'>[];
|
|
|
|
expect(filterEditableMemberInputs(members).map((member) => member.name)).toEqual([
|
|
'alice',
|
|
'bob',
|
|
]);
|
|
});
|
|
|
|
it('keeps teammate runtime overrides intact after filtering out the lead', () => {
|
|
const members = [
|
|
{
|
|
name: 'team-lead',
|
|
agentType: 'team-lead',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.4',
|
|
},
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.4-mini',
|
|
effort: 'medium',
|
|
},
|
|
] satisfies Pick<
|
|
ResolvedTeamMember,
|
|
'name' | 'agentType' | 'providerId' | 'model' | 'effort'
|
|
>[];
|
|
|
|
const drafts = createMemberDraftsFromInputs(filterEditableMemberInputs(members));
|
|
expect(drafts).toHaveLength(1);
|
|
expect(drafts[0]).toMatchObject({
|
|
name: 'alice',
|
|
originalName: 'alice',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.4-mini',
|
|
effort: 'medium',
|
|
});
|
|
});
|
|
|
|
it('round-trips hidden teammate backend and fast mode metadata', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4-mini',
|
|
effort: 'medium',
|
|
fastMode: 'on',
|
|
},
|
|
] as any)
|
|
);
|
|
|
|
expect(drafts[0]).toMatchObject({
|
|
providerBackendId: 'codex-native',
|
|
fastMode: 'on',
|
|
});
|
|
expect(buildMembersFromDrafts(drafts)).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4-mini',
|
|
effort: 'medium',
|
|
fastMode: 'on',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('drops hidden stale teammate backend when exporting against a new inherited provider', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
providerBackendId: 'codex-native',
|
|
model: 'haiku',
|
|
},
|
|
] as any)
|
|
);
|
|
|
|
const exported = buildMembersFromDrafts(drafts, {
|
|
inheritedProviderId: 'anthropic',
|
|
});
|
|
|
|
expect(exported).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
model: 'haiku',
|
|
}),
|
|
]);
|
|
expect(exported[0]).not.toHaveProperty('providerId');
|
|
expect(exported[0]).not.toHaveProperty('providerBackendId');
|
|
});
|
|
|
|
it('keeps hidden teammate backend when it matches the inherited provider', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4-mini',
|
|
},
|
|
] as any)
|
|
);
|
|
|
|
const exported = buildMembersFromDrafts(drafts, {
|
|
inheritedProviderId: 'codex',
|
|
});
|
|
|
|
expect(exported).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4-mini',
|
|
}),
|
|
]);
|
|
expect(exported[0]).not.toHaveProperty('providerId');
|
|
});
|
|
|
|
it('does not synthesize hidden teammate backend from inherited provider defaults', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
model: 'gpt-5.4-mini',
|
|
},
|
|
] as any)
|
|
);
|
|
|
|
const exported = buildMembersFromDrafts(drafts, {
|
|
inheritedProviderId: 'codex',
|
|
});
|
|
|
|
expect(exported).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
model: 'gpt-5.4-mini',
|
|
}),
|
|
]);
|
|
expect(exported[0]).not.toHaveProperty('providerId');
|
|
expect(exported[0]).not.toHaveProperty('providerBackendId');
|
|
});
|
|
|
|
it('drops inherited teammate model when its inferred provider conflicts', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
model: 'gpt-5.4-mini',
|
|
effort: 'max',
|
|
},
|
|
] as any)
|
|
);
|
|
|
|
const exported = buildMembersFromDrafts(drafts, {
|
|
inheritedProviderId: 'anthropic',
|
|
});
|
|
|
|
expect(exported).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
effort: 'max',
|
|
}),
|
|
]);
|
|
expect(exported[0]).not.toHaveProperty('model');
|
|
});
|
|
|
|
it('drops inherited teammate effort when selected provider does not support it', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
model: 'gpt-5.4-mini',
|
|
effort: 'max',
|
|
},
|
|
] as any)
|
|
);
|
|
|
|
const exported = buildMembersFromDrafts(drafts, {
|
|
inheritedProviderId: 'codex',
|
|
});
|
|
|
|
expect(exported).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
model: 'gpt-5.4-mini',
|
|
}),
|
|
]);
|
|
expect(exported[0]).not.toHaveProperty('effort');
|
|
});
|
|
|
|
it('preserves legacy no-context effort export for callers without inherited provider', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
effort: 'max',
|
|
},
|
|
] as any)
|
|
);
|
|
|
|
expect(buildMembersFromDrafts(drafts)).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
effort: 'max',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('uses explicit teammate provider before inherited provider while sanitizing export', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4-mini',
|
|
effort: 'max',
|
|
},
|
|
] as any)
|
|
);
|
|
|
|
const exported = buildMembersFromDrafts(drafts, {
|
|
inheritedProviderId: 'anthropic',
|
|
});
|
|
|
|
expect(exported).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
providerId: 'codex',
|
|
providerBackendId: 'codex-native',
|
|
model: 'gpt-5.4-mini',
|
|
}),
|
|
]);
|
|
expect(exported[0]).not.toHaveProperty('effort');
|
|
});
|
|
|
|
it('keeps OpenCode custom teammate models that are not inferred as another provider', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
providerId: 'opencode',
|
|
providerBackendId: 'opencode-cli',
|
|
model: 'qwen3-coder',
|
|
effort: 'medium',
|
|
},
|
|
] as any)
|
|
);
|
|
|
|
const exported = buildMembersFromDrafts(drafts, {
|
|
inheritedProviderId: 'anthropic',
|
|
});
|
|
|
|
expect(exported).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
providerBackendId: 'opencode-cli',
|
|
model: 'qwen3-coder',
|
|
effort: 'medium',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('preserves explicit codex models when exporting member inputs', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'reviewer',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.4-mini',
|
|
effort: 'medium',
|
|
},
|
|
] satisfies Pick<
|
|
ResolvedTeamMember,
|
|
'name' | 'agentType' | 'providerId' | 'model' | 'effort'
|
|
>[])
|
|
);
|
|
|
|
expect(buildMembersFromDrafts(drafts)).toEqual([
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.4-mini',
|
|
effort: 'medium',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('preserves worktree isolation when importing and exporting member drafts', () => {
|
|
const drafts = createMemberDraftsFromInputs(
|
|
filterEditableMemberInputs([
|
|
{
|
|
name: 'alice',
|
|
agentType: 'developer',
|
|
isolation: 'worktree',
|
|
},
|
|
{
|
|
name: 'bob',
|
|
agentType: 'reviewer',
|
|
},
|
|
] satisfies Pick<ResolvedTeamMember, 'name' | 'agentType' | 'isolation'>[])
|
|
);
|
|
|
|
const exported = buildMembersFromDrafts(drafts);
|
|
|
|
expect(drafts[0]).toMatchObject({ name: 'alice', isolation: 'worktree' });
|
|
expect(exported[0]).toMatchObject({ name: 'alice', isolation: 'worktree' });
|
|
expect(exported[1]).toMatchObject({ name: 'bob' });
|
|
expect(exported[1]).not.toHaveProperty('isolation');
|
|
});
|
|
|
|
it('reuses existing member colors for matching draft names', () => {
|
|
const existingMembers = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }];
|
|
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
|
|
|
const expectedColors = buildTeamMemberColorMap(existingMembers, {
|
|
preferProvidedColors: false,
|
|
});
|
|
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
|
|
|
expect(draftColors.get(drafts[0].id)).toBe(expectedColors.get('alice'));
|
|
expect(draftColors.get(drafts[1].id)).toBe(expectedColors.get('tom'));
|
|
expect(draftColors.get(drafts[2].id)).toBe(expectedColors.get('bob'));
|
|
});
|
|
|
|
it('assigns new draft members after reserving existing team colors', () => {
|
|
const existingMembers = [{ name: 'alice' }, { name: 'tom' }];
|
|
const drafts = [
|
|
createMemberDraft({ name: 'alice' }),
|
|
createMemberDraft({ name: 'tom' }),
|
|
createMemberDraft({ name: 'bob' }),
|
|
];
|
|
|
|
const expectedColors = buildTeamMemberColorMap([...existingMembers, { name: 'bob' }], {
|
|
preferProvidedColors: false,
|
|
});
|
|
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
|
|
|
expect(draftColors.get(drafts[0].id)).toBe(expectedColors.get('alice'));
|
|
expect(draftColors.get(drafts[1].id)).toBe(expectedColors.get('tom'));
|
|
expect(draftColors.get(drafts[2].id)).toBe(expectedColors.get('bob'));
|
|
});
|
|
|
|
it('predicts the same colors as the team page for brand-new draft members', () => {
|
|
const drafts = ['alice', 'tom', 'bob'].map((name) => createMemberDraft({ name }));
|
|
|
|
const expectedColors = buildTeamMemberColorMap(
|
|
drafts.map((draft) => ({
|
|
name: `draft:${draft.id}`,
|
|
})),
|
|
{ preferProvidedColors: false }
|
|
);
|
|
const draftColors = buildMemberDraftColorMap(drafts);
|
|
|
|
expect(draftColors.get(drafts[0].id)).toBe(expectedColors.get(`draft:${drafts[0].id}`));
|
|
expect(draftColors.get(drafts[1].id)).toBe(expectedColors.get(`draft:${drafts[1].id}`));
|
|
expect(draftColors.get(drafts[2].id)).toBe(expectedColors.get(`draft:${drafts[2].id}`));
|
|
});
|
|
|
|
it('preserves the resolved team colors in edit and launch dialogs', () => {
|
|
const existingMembers = [
|
|
{ name: 'alice', color: getMemberColorByName('alice') },
|
|
{ name: 'bob', color: getMemberColorByName('bob') },
|
|
{ name: 'tom', color: getMemberColorByName('tom') },
|
|
];
|
|
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
|
|
|
const draftColors = buildMemberDraftColorMap(drafts, existingMembers);
|
|
|
|
expect(draftColors.get(drafts[0].id)).toBe(existingMembers[0].color);
|
|
expect(draftColors.get(drafts[1].id)).toBe(existingMembers[1].color);
|
|
expect(draftColors.get(drafts[2].id)).toBe(existingMembers[2].color);
|
|
});
|
|
|
|
it('prefers an explicit resolved member color map from the team screen', () => {
|
|
const existingMembers = [
|
|
{ name: 'alice', color: 'brick' },
|
|
{ name: 'tom', color: 'forest' },
|
|
];
|
|
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));
|
|
const resolvedColorMap = new Map<string, string>([
|
|
['alice', 'blue'],
|
|
['tom', 'saffron'],
|
|
]);
|
|
|
|
const draftColors = buildMemberDraftColorMap(drafts, existingMembers, resolvedColorMap);
|
|
|
|
expect(draftColors.get(drafts[0].id)).toBe('blue');
|
|
expect(draftColors.get(drafts[1].id)).toBe('saffron');
|
|
});
|
|
|
|
it('keeps an existing teammate color stable while the name is being edited', () => {
|
|
const existingMembers = [
|
|
{ name: 'alice', color: 'blue' },
|
|
{ name: 'tom', color: 'saffron' },
|
|
];
|
|
const renamedAliceDraft = createMemberDraft({
|
|
id: 'draft-alice',
|
|
name: 'alice-renamed',
|
|
originalName: 'alice',
|
|
});
|
|
const tomDraft = createMemberDraft({
|
|
id: 'draft-tom',
|
|
name: 'tom',
|
|
originalName: 'tom',
|
|
});
|
|
|
|
const draftColors = buildMemberDraftColorMap([renamedAliceDraft, tomDraft], existingMembers);
|
|
|
|
expect(draftColors.get(renamedAliceDraft.id)).toBe('blue');
|
|
expect(draftColors.get(tomDraft.id)).toBe('saffron');
|
|
});
|
|
|
|
it('keeps a brand-new draft color stable while its name is edited', () => {
|
|
const draft = createMemberDraft({ id: 'draft-new', name: 'alice' });
|
|
const beforeRename = buildMemberDraftColorMap([draft]);
|
|
const afterRename = buildMemberDraftColorMap([{ ...draft, name: 'charlie' }]);
|
|
|
|
expect(afterRename.get(draft.id)).toBe(beforeRename.get(draft.id));
|
|
});
|
|
});
|