UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

416 lines (341 loc) 13.8 kB
import { using } from '@furystack/utils' import { describe, expect, it } from 'vitest' import { TreeService } from './tree-service.js' type TestNode = { id: number; name: string; children?: TestNode[] } const getChildren = (node: TestNode): TestNode[] => node.children ?? [] describe('TreeService', () => { const createTreeData = (): TestNode[] => [ { id: 1, name: 'Root 1', children: [ { id: 11, name: 'Child 1-1', children: [ { id: 111, name: 'Leaf 1-1-1' }, { id: 112, name: 'Leaf 1-1-2' }, ], }, { id: 12, name: 'Child 1-2' }, ], }, { id: 2, name: 'Root 2' }, { id: 3, name: 'Root 3', children: [{ id: 31, name: 'Child 3-1' }], }, ] const createTestService = () => { const rootItems = createTreeData() const service = new TreeService<TestNode>({ getChildren, }) service.rootItems.setValue(rootItems) service.updateFlattenedNodes() return { service, rootItems } } describe('flattenedNodes', () => { it('should flatten only root items when none are expanded', () => { const { service, rootItems } = createTestService() using(service, () => { const flattened = service.flattenedNodes.getValue() expect(flattened.length).toBe(3) expect(flattened[0].item).toBe(rootItems[0]) expect(flattened[0].level).toBe(0) expect(flattened[0].hasChildren).toBe(true) expect(flattened[0].isExpanded).toBe(false) expect(flattened[1].item).toBe(rootItems[1]) expect(flattened[1].hasChildren).toBe(false) expect(flattened[2].item).toBe(rootItems[2]) }) }) it('should flatten children when a node is expanded', () => { const { service, rootItems } = createTestService() using(service, () => { service.expand(rootItems[0]) const flattened = service.flattenedNodes.getValue() expect(flattened.length).toBe(5) expect(flattened[0].item).toBe(rootItems[0]) expect(flattened[0].isExpanded).toBe(true) expect(flattened[1].item).toBe(rootItems[0].children![0]) expect(flattened[1].level).toBe(1) expect(flattened[2].item).toBe(rootItems[0].children![1]) expect(flattened[2].level).toBe(1) expect(flattened[3].item).toBe(rootItems[1]) expect(flattened[4].item).toBe(rootItems[2]) }) }) it('should flatten deeply nested children', () => { const { service, rootItems } = createTestService() using(service, () => { service.expand(rootItems[0]) service.expand(rootItems[0].children![0]) const flattened = service.flattenedNodes.getValue() expect(flattened.length).toBe(7) expect(flattened[2].item.id).toBe(111) expect(flattened[2].level).toBe(2) expect(flattened[3].item.id).toBe(112) expect(flattened[3].level).toBe(2) }) }) it('should sync items with flattened nodes', () => { const { service, rootItems } = createTestService() using(service, () => { service.expand(rootItems[0]) const items = service.items.getValue() const flattened = service.flattenedNodes.getValue() expect(items.length).toBe(flattened.length) for (let i = 0; i < items.length; i++) { expect(items[i]).toBe(flattened[i].item) } }) }) }) describe('expand/collapse', () => { it('should expand a node', () => { const { service, rootItems } = createTestService() using(service, () => { service.expand(rootItems[0]) expect(service.isExpanded(rootItems[0])).toBe(true) }) }) it('should collapse a node', () => { const { service, rootItems } = createTestService() using(service, () => { service.expand(rootItems[0]) service.collapse(rootItems[0]) expect(service.isExpanded(rootItems[0])).toBe(false) }) }) it('should toggle expand on a collapsed node with children', () => { const { service, rootItems } = createTestService() using(service, () => { service.toggleExpanded(rootItems[0]) expect(service.isExpanded(rootItems[0])).toBe(true) }) }) it('should toggle collapse on an expanded node', () => { const { service, rootItems } = createTestService() using(service, () => { service.expand(rootItems[0]) service.toggleExpanded(rootItems[0]) expect(service.isExpanded(rootItems[0])).toBe(false) }) }) it('should not expand a leaf node', () => { const { service, rootItems } = createTestService() using(service, () => { service.toggleExpanded(rootItems[1]) expect(service.isExpanded(rootItems[1])).toBe(false) }) }) it('should hide children when collapsing a node', () => { const { service, rootItems } = createTestService() using(service, () => { service.expand(rootItems[0]) expect(service.flattenedNodes.getValue().length).toBe(5) service.collapse(rootItems[0]) expect(service.flattenedNodes.getValue().length).toBe(3) }) }) }) describe('getParent', () => { it('should return undefined for root items', () => { const { service, rootItems } = createTestService() using(service, () => { expect(service.getParent(rootItems[0])).toBeUndefined() expect(service.getParent(rootItems[1])).toBeUndefined() }) }) it('should return the parent of a child node', () => { const { service, rootItems } = createTestService() using(service, () => { const parent = service.getParent(rootItems[0].children![0]) expect(parent).toBe(rootItems[0]) }) }) it('should return the parent of a deeply nested node', () => { const { service, rootItems } = createTestService() using(service, () => { const parent = service.getParent(rootItems[0].children![0].children![0]) expect(parent).toBe(rootItems[0].children![0]) }) }) }) describe('getNodeInfo', () => { it('should return node info for a visible item', () => { const { service, rootItems } = createTestService() using(service, () => { const info = service.getNodeInfo(rootItems[0]) expect(info).toBeDefined() expect(info?.level).toBe(0) expect(info?.hasChildren).toBe(true) expect(info?.isExpanded).toBe(false) }) }) it('should return undefined for a hidden item', () => { const { service, rootItems } = createTestService() using(service, () => { const info = service.getNodeInfo(rootItems[0].children![0]) expect(info).toBeUndefined() }) }) }) describe('handleKeyDown - tree navigation', () => { it('should expand a collapsed node on ArrowRight', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.focusedItem.setValue(rootItems[0]) service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowRight' })) expect(service.isExpanded(rootItems[0])).toBe(true) }) }) it('should not move focus on ArrowRight when already expanded (delegated to spatial navigation)', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.expand(rootItems[0]) service.focusedItem.setValue(rootItems[0]) const ev = new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true }) service.handleKeyDown(ev) expect(service.focusedItem.getValue()).toBe(rootItems[0]) }) }) it('should do nothing on ArrowRight on a leaf node', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.focusedItem.setValue(rootItems[1]) service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowRight' })) expect(service.focusedItem.getValue()).toBe(rootItems[1]) }) }) it('should collapse an expanded node on ArrowLeft', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.expand(rootItems[0]) service.focusedItem.setValue(rootItems[0]) service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowLeft' })) expect(service.isExpanded(rootItems[0])).toBe(false) }) }) it('should move focus to parent on ArrowLeft when node is collapsed', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.expand(rootItems[0]) service.focusedItem.setValue(rootItems[0].children![0]) service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowLeft' })) expect(service.focusedItem.getValue()).toBe(rootItems[0]) }) }) it('should do nothing on ArrowLeft on a root node that is collapsed', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.focusedItem.setValue(rootItems[1]) service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowLeft' })) expect(service.focusedItem.getValue()).toBe(rootItems[1]) }) }) it('should not handle keys when not focused', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(false) service.focusedItem.setValue(rootItems[0]) service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowRight' })) expect(service.isExpanded(rootItems[0])).toBe(false) }) }) }) describe('inherited ListService keyboard navigation', () => { it('should not handle ArrowDown (delegated to spatial navigation)', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.focusedItem.setValue(rootItems[0]) const ev = new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }) service.handleKeyDown(ev) expect(service.focusedItem.getValue()).toBe(rootItems[0]) }) }) it('should not handle ArrowUp (delegated to spatial navigation)', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.focusedItem.setValue(rootItems[1]) const ev = new KeyboardEvent('keydown', { key: 'ArrowUp', cancelable: true }) service.handleKeyDown(ev) expect(service.focusedItem.getValue()).toBe(rootItems[1]) }) }) it('should handle Home to move focus to first item', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.focusedItem.setValue(rootItems[2]) service.handleKeyDown(new KeyboardEvent('keydown', { key: 'Home' })) expect(service.focusedItem.getValue()).toBe(rootItems[0]) }) }) it('should handle End to move focus to last item', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.focusedItem.setValue(rootItems[0]) service.handleKeyDown(new KeyboardEvent('keydown', { key: 'End' })) expect(service.focusedItem.getValue()).toBe(rootItems[2]) }) }) it('should handle Space to toggle selection of focused item', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.focusedItem.setValue(rootItems[0]) service.handleKeyDown(new KeyboardEvent('keydown', { key: ' ' })) expect(service.selection.getValue()).toContain(rootItems[0]) service.handleKeyDown(new KeyboardEvent('keydown', { key: ' ' })) expect(service.selection.getValue()).not.toContain(rootItems[0]) }) }) it('should handle Escape to clear selection and search term', () => { const { service, rootItems } = createTestService() using(service, () => { service.hasFocus.setValue(true) service.selection.setValue([rootItems[0]]) service.searchTerm.setValue('test') service.handleKeyDown(new KeyboardEvent('keydown', { key: 'Escape' })) expect(service.selection.getValue()).toEqual([]) expect(service.searchTerm.getValue()).toBe('') }) }) }) describe('handleItemDoubleClick', () => { it('should toggle expansion on double-click of a node with children', () => { const { service, rootItems } = createTestService() using(service, () => { service.handleItemDoubleClick(rootItems[0]) expect(service.isExpanded(rootItems[0])).toBe(true) service.handleItemDoubleClick(rootItems[0]) expect(service.isExpanded(rootItems[0])).toBe(false) }) }) it('should not expand on double-click of a leaf node', () => { const { service, rootItems } = createTestService() using(service, () => { service.handleItemDoubleClick(rootItems[1]) expect(service.isExpanded(rootItems[1])).toBe(false) }) }) }) describe('dispose', () => { it('should dispose all observables', () => { const { service } = createTestService() expect(() => service[Symbol.dispose]()).not.toThrow() }) }) })