@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
416 lines (341 loc) • 13.8 kB
text/typescript
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()
})
})
})