项目文件夹

文件
wehub-resource-sync 2771cff92b
CI / lint (push) Failing after 1s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 1s
CI / build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:22:56 +08:00

407 行
11 KiB
TypeScript

import { createMockEl } from '@test/helpers/mockElement';
import { SelectableDropdown, type SelectableDropdownRenderOptions } from '@/shared/components/SelectableDropdown';
function createRenderOptions<T>(overrides: Partial<SelectableDropdownRenderOptions<T>> = {}): SelectableDropdownRenderOptions<T> {
return {
items: [] as T[],
selectedIndex: 0,
emptyText: 'No items',
renderItem: jest.fn(),
...overrides,
};
}
describe('SelectableDropdown', () => {
let containerEl: any;
let dropdown: SelectableDropdown<string>;
beforeEach(() => {
containerEl = createMockEl();
dropdown = new SelectableDropdown(containerEl, {
listClassName: 'dropdown-list',
itemClassName: 'dropdown-item',
emptyClassName: 'dropdown-empty',
});
});
afterEach(() => {
dropdown.destroy();
});
describe('initial state', () => {
it('is not visible before render', () => {
expect(dropdown.isVisible()).toBe(false);
});
it('has no element before render', () => {
expect(dropdown.getElement()).toBeNull();
});
it('returns 0 for selectedIndex', () => {
expect(dropdown.getSelectedIndex()).toBe(0);
});
it('returns null for selectedItem', () => {
expect(dropdown.getSelectedItem()).toBeNull();
});
it('returns empty items array', () => {
expect(dropdown.getItems()).toEqual([]);
});
});
describe('render with items', () => {
it('creates dropdown element and marks it visible', () => {
dropdown.render(createRenderOptions({
items: ['alpha', 'beta'],
selectedIndex: 0,
renderItem: (item, el) => el.setText(item),
}));
const el = dropdown.getElement();
expect(el).not.toBeNull();
expect(el!.hasClass('visible')).toBe(true);
});
it('stores items and exposes them via getItems', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b', 'c'],
renderItem: jest.fn(),
}));
expect(dropdown.getItems()).toEqual(['a', 'b', 'c']);
});
it('calls renderItem for each item', () => {
const renderItem = jest.fn();
dropdown.render(createRenderOptions({
items: ['x', 'y'],
renderItem,
}));
expect(renderItem).toHaveBeenCalledTimes(2);
expect(renderItem).toHaveBeenCalledWith('x', expect.anything());
expect(renderItem).toHaveBeenCalledWith('y', expect.anything());
});
it('marks selected item with selected class', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b', 'c'],
selectedIndex: 1,
renderItem: jest.fn(),
}));
expect(dropdown.getSelectedIndex()).toBe(1);
expect(dropdown.getSelectedItem()).toBe('b');
});
it('creates item elements with itemClassName', () => {
dropdown.render(createRenderOptions({
items: ['one'],
renderItem: jest.fn(),
}));
const el = dropdown.getElement()!;
const items = el.querySelectorAll('dropdown-item');
expect(items.length).toBe(1);
});
});
describe('render with empty items', () => {
it('shows empty text when items array is empty', () => {
dropdown.render(createRenderOptions({
items: [],
emptyText: 'Nothing here',
renderItem: jest.fn(),
}));
const el = dropdown.getElement()!;
const emptyEl = el.querySelector('dropdown-empty');
expect(emptyEl).not.toBeNull();
expect(emptyEl!.textContent).toBe('Nothing here');
});
});
describe('getItemClass support', () => {
it('applies string class to items', () => {
dropdown.render(createRenderOptions({
items: ['a'],
renderItem: jest.fn(),
getItemClass: () => 'extra-class',
}));
const el = dropdown.getElement()!;
const items = el.querySelectorAll('dropdown-item');
expect(items[0].hasClass('extra-class')).toBe(true);
});
it('applies array of classes to items', () => {
dropdown.render(createRenderOptions({
items: ['a'],
renderItem: jest.fn(),
getItemClass: () => ['cls-a', 'cls-b'],
}));
const el = dropdown.getElement()!;
const items = el.querySelectorAll('dropdown-item');
expect(items[0].hasClass('cls-a')).toBe(true);
expect(items[0].hasClass('cls-b')).toBe(true);
});
it('handles undefined class gracefully', () => {
dropdown.render(createRenderOptions({
items: ['a'],
renderItem: jest.fn(),
getItemClass: () => undefined,
}));
const el = dropdown.getElement()!;
const items = el.querySelectorAll('dropdown-item');
expect(items.length).toBe(1);
});
});
describe('click handler', () => {
it('calls onItemClick when item is clicked', () => {
const onItemClick = jest.fn();
dropdown.render(createRenderOptions({
items: ['a', 'b'],
renderItem: jest.fn(),
onItemClick,
}));
const el = dropdown.getElement()!;
const items = el.querySelectorAll('dropdown-item');
items[1].dispatchEvent({ type: 'click', target: items[1] } as any);
expect(onItemClick).toHaveBeenCalledWith('b', 1, expect.anything());
});
it('updates selectedIndex on click', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b', 'c'],
selectedIndex: 0,
renderItem: jest.fn(),
}));
const el = dropdown.getElement()!;
const items = el.querySelectorAll('dropdown-item');
items[2].dispatchEvent({ type: 'click', target: items[2] } as any);
expect(dropdown.getSelectedIndex()).toBe(2);
});
});
describe('hover handler', () => {
it('calls onItemHover when item is hovered', () => {
const onItemHover = jest.fn();
dropdown.render(createRenderOptions({
items: ['a', 'b'],
renderItem: jest.fn(),
onItemHover,
}));
const el = dropdown.getElement()!;
const items = el.querySelectorAll('dropdown-item');
items[0].dispatchEvent({ type: 'mouseenter' } as any);
expect(onItemHover).toHaveBeenCalledWith('a', 0);
});
it('updates selectedIndex on hover', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b', 'c'],
selectedIndex: 0,
renderItem: jest.fn(),
}));
const el = dropdown.getElement()!;
const items = el.querySelectorAll('dropdown-item');
items[1].dispatchEvent({ type: 'mouseenter' } as any);
expect(dropdown.getSelectedIndex()).toBe(1);
});
});
describe('moveSelection', () => {
it('moves selection down', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b', 'c'],
selectedIndex: 0,
renderItem: jest.fn(),
}));
dropdown.moveSelection(1);
expect(dropdown.getSelectedIndex()).toBe(1);
});
it('moves selection up', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b', 'c'],
selectedIndex: 2,
renderItem: jest.fn(),
}));
dropdown.moveSelection(-1);
expect(dropdown.getSelectedIndex()).toBe(1);
});
it('clamps at 0 when moving up past beginning', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b'],
selectedIndex: 0,
renderItem: jest.fn(),
}));
dropdown.moveSelection(-1);
expect(dropdown.getSelectedIndex()).toBe(0);
});
it('clamps at max index when moving down past end', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b'],
selectedIndex: 1,
renderItem: jest.fn(),
}));
dropdown.moveSelection(1);
expect(dropdown.getSelectedIndex()).toBe(1);
});
});
describe('updateSelection', () => {
it('adds selected class to current item and removes from others', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b', 'c'],
selectedIndex: 0,
renderItem: jest.fn(),
}));
dropdown.moveSelection(2);
const el = dropdown.getElement()!;
const items = el.querySelectorAll('dropdown-item');
expect(items[0].hasClass('selected')).toBe(false);
expect(items[1].hasClass('selected')).toBe(false);
expect(items[2].hasClass('selected')).toBe(true);
});
});
describe('hide', () => {
it('removes visible class from dropdown', () => {
dropdown.render(createRenderOptions({
items: ['a'],
renderItem: jest.fn(),
}));
expect(dropdown.isVisible()).toBe(true);
dropdown.hide();
expect(dropdown.isVisible()).toBe(false);
});
it('is safe to call before render', () => {
expect(() => dropdown.hide()).not.toThrow();
});
});
describe('destroy', () => {
it('removes the dropdown element', () => {
dropdown.render(createRenderOptions({
items: ['a'],
renderItem: jest.fn(),
}));
expect(dropdown.getElement()).not.toBeNull();
dropdown.destroy();
expect(dropdown.getElement()).toBeNull();
});
it('is safe to call before render', () => {
expect(() => dropdown.destroy()).not.toThrow();
});
it('is safe to call multiple times', () => {
dropdown.render(createRenderOptions({ items: ['a'], renderItem: jest.fn() }));
dropdown.destroy();
expect(() => dropdown.destroy()).not.toThrow();
});
});
describe('re-render', () => {
it('reuses existing dropdown element on subsequent renders', () => {
dropdown.render(createRenderOptions({
items: ['a'],
renderItem: jest.fn(),
}));
const firstEl = dropdown.getElement();
dropdown.render(createRenderOptions({
items: ['b', 'c'],
renderItem: jest.fn(),
}));
expect(dropdown.getElement()).toBe(firstEl);
});
it('clears old items and renders new ones', () => {
dropdown.render(createRenderOptions({
items: ['a', 'b'],
renderItem: jest.fn(),
}));
dropdown.render(createRenderOptions({
items: ['x'],
renderItem: jest.fn(),
}));
expect(dropdown.getItems()).toEqual(['x']);
});
});
describe('fixed className', () => {
it('applies fixedClassName when fixed option is true', () => {
const fixedDropdown = new SelectableDropdown<string>(containerEl, {
listClassName: 'dropdown-list',
itemClassName: 'dropdown-item',
emptyClassName: 'dropdown-empty',
fixed: true,
fixedClassName: 'dropdown-fixed',
});
fixedDropdown.render(createRenderOptions({
items: ['a'],
renderItem: jest.fn(),
}));
const el = fixedDropdown.getElement()!;
expect(el.hasClass('dropdown-list')).toBe(true);
expect(el.hasClass('dropdown-fixed')).toBe(true);
fixedDropdown.destroy();
});
it('does not apply fixedClassName when fixed is false', () => {
const nonFixedDropdown = new SelectableDropdown<string>(containerEl, {
listClassName: 'dropdown-list',
itemClassName: 'dropdown-item',
emptyClassName: 'dropdown-empty',
fixed: false,
fixedClassName: 'dropdown-fixed',
});
nonFixedDropdown.render(createRenderOptions({
items: ['a'],
renderItem: jest.fn(),
}));
const el = nonFixedDropdown.getElement()!;
expect(el.hasClass('dropdown-list')).toBe(true);
expect(el.hasClass('dropdown-fixed')).toBe(false);
nonFixedDropdown.destroy();
});
});
});