cc-haha/desktop/src/components/shared/MultiSelectDropdown.test.tsx
小橙子 f3d245cbbf
feat(skills): allow activating a skill in multiple projects at once (#77)
The "Project" scope on a skill's activation panel was single-select:
choosing a different project from the dropdown silently moved the
activation, so a skill could only be active in one project at a time.
Make it multi-select instead — a skill can now be active in any
combination of projects independently.

UI
- New shared MultiSelectDropdown component (role="menuitemcheckbox",
  toggle keeps the popover open, Escape / outside click close).
- SkillActivationScope's project picker uses it. The trigger now shows
  the count: "Select projects…" / one path / "{n} projects".
- The high-level Off / Global / Project buttons keep working: tapping
  "Project" while no project is active default-activates the current
  one; tapping it again is a no-op so it can't wipe a multi-selection.
- Activating any project also clears the global flag (mutually
  exclusive); toggling a project off does not touch global.

State
- activeProjects: Set<projectPath> replaces the old single
  selectedProjectPath + projectSkills pair.
- Loaded by fanning out one getActiveSkills('project', cwd) per
  resolved project, and collecting paths whose list contains this
  skill into the Set.
- Each toggle is read-modify-write on a single project's
  activeSkills, so a failure on one project never corrupts the others.

No backend, jsonl format, or migration changes — purely a
front-end aggregation over the existing per-project API.

Tests
- 4 new tests for MultiSelectDropdown (visibility, ARIA, multi-toggle
  doesn't auto-close, Escape).
- 1 new SkillDetail test asserting the "{count} projects" trigger
  surfaces when multiple projects are active.
- All 4 existing SkillDetail tests still pass — verification subagent
  confirmed the multi-select gate (no-op when size>0) and global/
  project mutual exclusion.
- 5 locales each gain 2 new keys (selectProjects + projectCount).

Co-authored-by: 你的姓名 <you@example.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-18 19:31:08 +08:00

82 lines
2.5 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import { MultiSelectDropdown } from './MultiSelectDropdown'
type Item = { value: string; label: string }
function renderDropdown(props: {
values: string[]
items: Item[]
onToggle?: (v: string) => void
}) {
return render(
<MultiSelectDropdown
values={props.values}
onToggle={props.onToggle ?? (() => {})}
items={props.items}
trigger={<button>open</button>}
/>,
)
}
describe('MultiSelectDropdown', () => {
it('hides items until the trigger is clicked', () => {
renderDropdown({
values: [],
items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
})
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('open'))
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
})
it('exposes each row as menuitemcheckbox with correct aria-checked', () => {
renderDropdown({
values: ['a'],
items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
})
fireEvent.click(screen.getByText('open'))
const rows = screen.getAllByRole('menuitemcheckbox')
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveAttribute('aria-checked', 'true')
expect(rows[1]).toHaveAttribute('aria-checked', 'false')
})
it('keeps the popover open after toggling so multiple items can be chosen', () => {
const onToggle = vi.fn()
renderDropdown({
values: [],
items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }],
onToggle,
})
fireEvent.click(screen.getByText('open'))
fireEvent.click(screen.getByText('Alpha'))
fireEvent.click(screen.getByText('Beta'))
// Both fires AND the menu items are still on screen — popover never auto-closed.
expect(onToggle).toHaveBeenCalledTimes(2)
expect(onToggle).toHaveBeenNthCalledWith(1, 'a')
expect(onToggle).toHaveBeenNthCalledWith(2, 'b')
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
})
it('closes when Escape is pressed', () => {
renderDropdown({
values: [],
items: [{ value: 'a', label: 'Alpha' }],
})
fireEvent.click(screen.getByText('open'))
expect(screen.getByText('Alpha')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
})
})