diff --git a/README.md b/README.md index 74848b91..6c61ee19 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![npm](https://img.shields.io/npm/v/hyperparam)](https://www.npmjs.com/package/hyperparam) [![workflow status](https://github.com/hyparam/hyperparam-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/hyparam/hyperparam-cli/actions) [![mit license](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -![coverage](https://img.shields.io/badge/Coverage-56-darkred) +![coverage](https://img.shields.io/badge/Coverage-59-darkred) This is the hyperparam cli tool. diff --git a/src/components/Folder.tsx b/src/components/Folder.tsx index 16058c39..d783b29c 100644 --- a/src/components/Folder.tsx +++ b/src/components/Folder.tsx @@ -66,7 +66,7 @@ export default function Folder({ source, config }: FolderProps) { searchElement?.addEventListener('keyup', handleKeyup) // Clean up event listener return () => searchElement?.removeEventListener('keyup', handleKeyup) - }) + }, [filtered, source.prefix]) // Jump to search box if user types '/' useEffect(() => { @@ -80,7 +80,7 @@ export default function Folder({ source, config }: FolderProps) { } document.addEventListener('keydown', handleKeydown) return () => { document.removeEventListener('keydown', handleKeydown) } - }) + }, []) return diff --git a/src/components/viewers/SlidePanel.tsx b/src/components/viewers/SlidePanel.tsx index cd3207e8..65659678 100644 --- a/src/components/viewers/SlidePanel.tsx +++ b/src/components/viewers/SlidePanel.tsx @@ -39,7 +39,8 @@ export default function SlidePanel({ // Load initial panel width from localStorage if available const [panelWidth, setPanelWidth] = useState(() => { const savedWidth = typeof window !== 'undefined' ? localStorage.getItem('panelWidth') : null - return savedWidth ? parseInt(savedWidth, 10) : defaultWidth + const parsedWidth = savedWidth ? parseInt(savedWidth, 10) : NaN + return !isNaN(parsedWidth) ? parsedWidth : defaultWidth }) useEffect(() => { diff --git a/test/components/Folder.test.tsx b/test/components/Folder.test.tsx index e6e8fef5..241003c3 100644 --- a/test/components/Folder.test.tsx +++ b/test/components/Folder.test.tsx @@ -46,7 +46,8 @@ describe('Folder Component', () => { it('displays the spinner while loading', async () => { vi.mocked(fetch).mockResolvedValueOnce({ - json: () => Promise.resolve([]), + // resolve in 50ms + json: () => new Promise(resolve => setTimeout(() => { resolve([]) }, 50)), ok: true, } as Response) @@ -54,7 +55,7 @@ describe('Folder Component', () => { assert(source?.kind === 'directory') const { container } = await act(() => render()) - expect(container.querySelector('.spinner')).toBeDefined() + expect(container.querySelector('.spinner')).toBeTruthy() }) it('handles file listing errors', async () => { @@ -111,8 +112,10 @@ describe('Folder Component', () => { const { getByPlaceholderText, getByText, queryByText } = render() // Type a search query - const searchInput = getByPlaceholderText('Search...') - fireEvent.keyUp(searchInput, { target: { value: 'file1' } }) + const searchInput = getByPlaceholderText('Search...') as HTMLInputElement + act(() => { + fireEvent.keyUp(searchInput, { target: { value: 'file1' } }) + }) // Only matching files are displayed await waitFor(() => { @@ -122,7 +125,10 @@ describe('Folder Component', () => { }) // Clear search with escape key - fireEvent.keyUp(searchInput, { key: 'Escape' }) + act(() => { + fireEvent.keyUp(searchInput, { key: 'Escape' }) + }) + await waitFor(() => { expect(getByText('file1.txt')).toBeDefined() expect(getByText('folder1/')).toBeDefined() @@ -151,7 +157,7 @@ describe('Folder Component', () => { const { getByPlaceholderText, getByText } = render() // Type a search query and hit enter - const searchInput = getByPlaceholderText('Search...') + const searchInput = getByPlaceholderText('Search...') as HTMLInputElement act(() => { fireEvent.keyUp(searchInput, { target: { value: 'file1' } }) }) @@ -159,11 +165,15 @@ describe('Folder Component', () => { await waitFor(() => { expect(getByText('file1.txt')).toBeDefined() }) - fireEvent.keyUp(searchInput, { key: 'Enter' }) + + act(() => { + fireEvent.keyUp(searchInput, { key: 'Enter' }) + }) + expect(location.href).toBe('/files?key=file1.txt') }) - it('jumps to search box when user types /', () => { + it('jumps to search box when user types /', async () => { const dirSource: DirSource = { sourceId: 'test-source', sourceParts: [{ text: 'test-source', sourceId: 'test-source' }], @@ -173,21 +183,34 @@ describe('Folder Component', () => { } const { getByPlaceholderText } = render() + // Wait for component to settle + await waitFor(() => { + expect(fetch).toHaveBeenCalled() + }) + const searchInput = getByPlaceholderText('Search...') as HTMLInputElement + // Typing / should focus the search box - fireEvent.keyDown(document.body, { key: '/' }) + act(() => { + fireEvent.keyDown(document.body, { key: '/' }) + }) expect(document.activeElement).toBe(searchInput) // Typing inside the search box should work including / act(() => { fireEvent.keyUp(searchInput, { target: { value: 'file1/' } }) - expect(searchInput.value).toBe('file1/') }) + expect(searchInput.value).toBe('file1/') // Unfocus and re-focus should select all text in search box - searchInput.blur() + act(() => { + searchInput.blur() + }) expect(document.activeElement).not.toBe(searchInput) - fireEvent.keyDown(document.body, { key: '/' }) + + act(() => { + fireEvent.keyDown(document.body, { key: '/' }) + }) expect(document.activeElement).toBe(searchInput) expect(searchInput.selectionStart).toBe(0) expect(searchInput.selectionEnd).toBe(searchInput.value.length) diff --git a/test/components/Markdown.test.tsx b/test/components/Markdown.test.tsx index 5eb633ed..dade4f1b 100644 --- a/test/components/Markdown.test.tsx +++ b/test/components/Markdown.test.tsx @@ -283,7 +283,6 @@ describe('Markdown with nested elements', () => { const text = '![alt' const { container } = render() - console.log(container.innerHTML) expect(container.textContent).toBe('![alt') expect(container.querySelector('img')).toBeNull() }) diff --git a/test/components/viewers/SlidePanel.test.tsx b/test/components/viewers/SlidePanel.test.tsx new file mode 100644 index 00000000..d2f45dce --- /dev/null +++ b/test/components/viewers/SlidePanel.test.tsx @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/non-nullable-type-assertion-style */ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render } from '@testing-library/react' +import SlidePanel from '../../../src/components/viewers/SlidePanel.js' + +describe('SlidePanel', () => { + // Minimal localStorage mock + const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + clear: () => { store = {} }, + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + removeItem: (key: string) => { delete store[key] }, + } + })() + + beforeEach(() => { + vi.stubGlobal('localStorage', localStorageMock) + localStorage.clear() + }) + + it('renders main and panel content', () => { + const { getByText } = render( + Main} + panelContent={
Panel
} + isPanelOpen + /> + ) + expect(getByText('Main')).toBeDefined() + expect(getByText('Panel')).toBeDefined() + }) + + it('does not render the resizer if panel is closed', () => { + const { container } = render( + Main} + panelContent={
Panel
} + isPanelOpen={false} + /> + ) + const resizer = container.querySelector('.resizer') + expect(resizer).toBeNull() + }) + + it('uses default width of 400 when localStorage is empty', () => { + const { container } = render( + Main} + panelContent={
Panel
} + isPanelOpen + /> + ) + const panel = container.querySelector('.slidePanel') as HTMLElement + expect(panel.style.width).toBe('400px') + }) + + it('loads width from localStorage if present', () => { + localStorage.setItem('panelWidth', '250') + const { container } = render( + Main} + panelContent={
Panel
} + isPanelOpen + /> + ) + const panel = container.querySelector('.slidePanel') as HTMLElement + expect(panel.style.width).toBe('250px') + }) + + it('falls back to default width if localStorage width is invalid', () => { + localStorage.setItem('panelWidth', 'not-a-number') + const { container } = render( + Main} + panelContent={
Panel
} + isPanelOpen + /> + ) + const panel = container.querySelector('.slidePanel') as HTMLElement + // parseInt of 'not-a-number' yields NaN so default width of 400 is expected + expect(panel.style.width).toBe('400px') + }) + + it('respects minWidth from config', () => { + const { container } = render( + Main} + panelContent={
Panel
} + isPanelOpen + config={{ slidePanel: { minWidth: 300 } }} + /> + ) + const resizer = container.querySelector('.resizer') as HTMLElement + const panel = container.querySelector('.slidePanel') as HTMLElement + expect(panel.style.width).toBe('400px') + + // Simulate mousedown on resizer with clientX 800 + act(() => { + fireEvent.mouseDown(resizer, { clientX: 800 }) + }) + + // Simulate mousemove on document with clientX such that new width is less than minWidth + act(() => { + fireEvent.mouseMove(document, { clientX: 950 }) + fireEvent.mouseUp(document) + }) + + // resizingClientX was set to 800 + 400 = 1200 so new width = max(300, 1200 - 950) = 300 + expect(panel.style.width).toBe('300px') + }) + + it('handles dragging to resize', () => { + const { container } = render( + Main} + panelContent={
Panel
} + isPanelOpen + /> + ) + const resizer = container.querySelector('.resizer') as HTMLElement + const panel = container.querySelector('.slidePanel') as HTMLElement + expect(panel.style.width).toBe('400px') + + // Mock panel's offsetWidth to be 400px + Object.defineProperty(panel, 'offsetWidth', { value: 400, configurable: true }) + + // Simulate mousedown + act(() => { + fireEvent.mouseDown(resizer, { clientX: 800 }) + }) + + // Simulate dragging + act(() => { + fireEvent.mouseMove(document, { clientX: 750 }) + }) + + // End dragging + act(() => { + fireEvent.mouseUp(document) + }) + + // Expected new width = 1200 - 750 = 450 + expect(panel.style.width).toBe('450px') + expect(localStorage.getItem('panelWidth')).toBe('450') + }) + + it('uses config defaultWidth if valid', () => { + const { container } = render( + Main} + panelContent={
Panel
} + isPanelOpen + config={{ slidePanel: { defaultWidth: 500 } }} + /> + ) + const panel = container.querySelector('.slidePanel') as HTMLElement + expect(panel.style.width).toBe('500px') + }) + + it('ignores negative config.defaultWidth and uses 400 instead', () => { + const { container } = render( + Main} + panelContent={
Panel
} + isPanelOpen + config={{ slidePanel: { defaultWidth: -10 } }} + /> + ) + const panel = container.querySelector('.slidePanel') as HTMLElement + expect(panel.style.width).toBe('400px') + }) +})