// @vitest-environment jsdom import { act, createElement, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow'; import { makeSheet } from '../../src/importers/sheet-factory'; import { useFloatingWindowLayer } from '../../src/components/use-floating-window-layer'; import { FloatingDataWindow } from '../../src/components/FloatingDataWindow/FloatingDataWindow'; import { resultClusterParkingPosition, resultClusterCoverHeight, detachResultWindow, minimizeResultWindow, prepareForNewClusterWindow, restoreResultWindow, toggleResultWindowMinimized, type ClusterAwareResultWindow } from '../../src/features/linked-results/result-window-layout'; function windowState(recordId: string, minimized: boolean, detached = false): ClusterAwareResultWindow { return { recordId, minimized, detached }; } describe('Result-Fenster-Cluster', () => { it.each(['light', 'dark'])('keeps restored Results above every minimized sibling after pointer and keyboard activation (%s)', theme => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; document.documentElement.dataset.theme = theme; vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); function Cluster() { const [windows, setWindows] = useState([windowState('first', true), windowState('second', true), windowState('third', false)]); const { layer, activate } = useFloatingWindowLayer(); return createElement('div', null, ...windows.map(item => createElement(FloatingResultsWindow, { key: item.recordId, id: item.recordId, title: `Results (1) – ${item.recordId}`, sheets: [], sections: [{ title: 'Test result', value: `Original evidence ${item.recordId}` }], color: 'var(--status-high)', zIndex: 2100, offset: 0, search: '', minimized: item.minimized, clusterPosition: { left: 40, top: 132 }, clusterIndex: item.minimized ? windows.filter(w => w.minimized).indexOf(item) : 0, clusterCount: windows.length, clusterLayer: layer, onActivateCluster: activate, onActivate: () => undefined, onClose: () => undefined, onToggleMinimize: () => setWindows(current => toggleResultWindowMinimized(current, item.recordId)) })), createElement(FloatingDataWindow, { title: 'Other window', onClose: () => undefined, children: 'Other content' })); } const dialog = (id: string) => host.querySelector(`#${id}`) ?? host.querySelector(`.results-window[aria-label="Results (1) – ${id}"]`)!; const z = (element: HTMLElement) => Number(element.style.zIndex); const assertForeground = (id: string) => { const active = dialog(id); expect(active.classList.contains('is-minimized')).toBe(false); expect(active.querySelector('.results-window-body')?.textContent).toContain(`Original evidence ${id}`); for (const sibling of host.querySelectorAll('.results-window.is-minimized')) expect(z(active)).toBeGreaterThan(z(sibling)); expect(active.querySelector('.results-window-head')?.lastElementChild?.lastElementChild?.getAttribute('aria-label')).toBe('Close results window'); }; try { act(() => root.render(createElement(Cluster))); assertForeground('third'); for (const id of ['first', 'second', 'third', 'first']) { act(() => dialog(id).dispatchEvent(new Event('pointerdown', { bubbles: true }))); act(() => dialog(id).querySelector('.results-window-toggle')!.click()); assertForeground(id); const sibling = host.querySelector('.results-window.is-minimized')!; act(() => sibling.querySelector('.results-window-toggle')!.focus()); assertForeground(id); const other = document.querySelector('.floating-data-window')!; act(() => other.dispatchEvent(new Event('pointerdown', { bubbles: true }))); expect(z(other)).toBeGreaterThan(z(dialog(id))); act(() => dialog(id).dispatchEvent(new Event('pointerdown', { bubbles: true }))); expect(z(dialog(id))).toBeGreaterThan(z(other)); } act(() => dialog('first').querySelector('.results-window-head')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))); expect(dialog('first').classList.contains('is-maximized')).toBe(true); assertForeground('first'); } finally { act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); delete document.documentElement.dataset.theme; } }); it('parkt minimierte Results oben rechts wie in der Referenzansicht', () => { expect(resultClusterParkingPosition(1848, 760, 640, 142)).toEqual({ left: 1008, top: 188 }); expect(resultClusterParkingPosition(440, 428, 42, 142)).toEqual({ left: 0, top: 42 }); }); it('berechnet die Mindesthoehe des aktiven Fensters bis unter den letzten minimierten Cluster-Eintrag', () => { expect(resultClusterCoverHeight(1, 600)).toBe(0); expect(resultClusterCoverHeight(7, 600)).toBe(204); expect(resultClusterCoverHeight(20, 588)).toBe(588); }); it('laesst ein herausgezogenes maximiertes Result beim Oeffnen eines weiteren Results unangetastet', () => { const windows = [ windowState('detached', false, true), windowState('cluster-active', false), windowState('cluster-minimized', true) ]; expect(prepareForNewClusterWindow(windows)).toEqual([ windowState('detached', false, true), windowState('cluster-active', true), windowState('cluster-minimized', true) ]); }); it('kann mehrere herausgezogene Results gleichzeitig maximiert halten', () => { const windows = [ windowState('first', false, true), windowState('second', true), windowState('third', false, true) ]; expect(restoreResultWindow(windows, 'second')).toEqual([ windowState('first', false, true), windowState('second', false), windowState('third', false, true) ]); }); it('clustert ein herausgezogenes Result erst beim Minimieren wieder ein', () => { const windows = [windowState('result', false, true)]; expect(minimizeResultWindow(windows, 'result')).toEqual([ windowState('result', true, false) ]); }); it('stellt ein minimiertes Result im Cluster wieder her und minimiert nur den bisherigen Cluster-Fokus', () => { const windows = [ windowState('detached', false, true), windowState('cluster-active', false), windowState('target', true) ]; expect(toggleResultWindowMinimized(windows, 'target')).toEqual([ windowState('detached', false, true), windowState('cluster-active', true), windowState('target', false) ]); }); it('markiert nur ein maximiertes Result als herausgezogen', () => { const windows = [windowState('minimized', true), windowState('maximized', false)]; const afterMinimizedDrag = detachResultWindow(windows, 'minimized'); expect(afterMinimizedDrag).toEqual(windows); expect(detachResultWindow(afterMinimizedDrag, 'maximized')).toEqual([ windowState('minimized', true), windowState('maximized', false, true) ]); }); it('trennt die Ergebnisanzahl vom Titel ohne Restzeichen', () => { const markup = renderToStaticMarkup(createElement(FloatingResultsWindow, { id: 'separated-title', title: 'Results (1) \u2013 Synthetic result', sheets: [], color: '#7c3aed', zIndex: 2100, offset: 0, search: '', onActivate: () => undefined, onClose: () => undefined, onToggleMinimize: () => undefined })); expect(markup).toContain('Synthetic result'); expect(markup).not.toContain('results-window-minimize'); }); it('zeigt hinter dem aktiven Fenster nur schmale vertikale Farbreiter und bleibt im Viewport', () => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: 800 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 }); const markup = renderToStaticMarkup(createElement(FloatingResultsWindow, { id: 'synthetic-result', title: 'Results (1) – Synthetic result', sheets: [], color: '#7c3aed', zIndex: 2100, offset: 0, search: '', onActivate: () => undefined, onClose: () => undefined, clusterIndex: 0, clusterCount: 3, clusterPosition: { left: 40, top: 132 }, clusterWidth: 760 })); expect(markup).toContain('left:54px'); expect(markup).toContain('width:746px'); expect(markup).toContain('max-width:718px'); expect(markup).toContain('max-height:440px'); expect(markup).not.toContain('left:84px'); }); it('streckt ein kurzes aktives Clusterfenster ueber alle minimierten Results dahinter', () => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: 700 }); const markup = renderToStaticMarkup(createElement(FloatingResultsWindow, { id: 'short-active-result', title: 'Results (1) – Short synthetic result', sheets: [], color: '#7c3aed', zIndex: 2100, offset: 0, search: '', onActivate: () => undefined, onClose: () => undefined, clusterIndex: 0, clusterCount: 8, clusterPosition: { left: 100, top: 160 }, clusterWidth: 760 })); expect(markup).toContain('min-height:238px'); }); it('kennzeichnet minimierte Cluster-Results als sichtbare Farbreiter', () => { const markup = renderToStaticMarkup(createElement(FloatingResultsWindow, { id: 'minimized-result', title: 'Results (1) – Synthetic result', sheets: [], color: '#7c3aed', zIndex: 2100, offset: 0, search: '', minimized: true, onActivate: () => undefined, onClose: () => undefined, clusterIndex: 0, clusterPosition: { left: 40, top: 132 }, clusterWidth: 760 })); expect(markup).toContain('results-window is-minimized'); expect(markup).toContain('--result-color:#7c3aed'); expect(markup).toContain('triangle-icon-right'); expect(markup).not.toContain('results-window-result-icon'); }); it('zeigt das farbige Zustandsdreieck und die Anzahl ohne Wuerfelsymbol', () => { const markup = renderToStaticMarkup(createElement(FloatingResultsWindow, { id: 'result-summary', title: 'Results (12) ??? Synthetic result', sheets: [], color: '#7c3aed', zIndex: 2100, offset: 0, search: '', onActivate: () => undefined, onClose: () => undefined })); expect(markup).toContain('triangle-icon-down'); expect(markup).toContain('results-window-toggle-icon'); expect(markup).not.toContain('results-window-result-icon'); expect(markup).toContain('>12'); expect(markup).not.toContain('results-window-badge'); }); it('stellt minimierte Results wieder her und maximiert geoeffnete Results per Kopfzeilen-Doppelklick', () => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))); const onToggleMinimize = vi.fn(); const resizableSheet = makeSheet('Synthetic table', [['First', 'Second'], ['One', 'Two']], '', 'synthetic.xlsx'); const container = document.createElement('div'); const root = createRoot(container); const renderWindow = (minimized: boolean, detached = false, clustered = true) => createElement(FloatingResultsWindow, { id: 'double-click-result', title: 'Results (1) – Synthetic result', sheets: [resizableSheet], color: '#7c3aed', zIndex: 2100, offset: 0, search: '', minimized, detached, clusterIndex: clustered ? 0 : undefined, clusterPosition: clustered ? { left: 120, top: 160 } : undefined, onActivate: () => undefined, onClose: () => undefined, onToggleMinimize }); act(() => root.render(renderWindow(true))); expect(container.querySelector('.results-window-toggle .triangle-icon-right')).not.toBeNull(); act(() => container.querySelector('.results-window-toggle')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); expect(onToggleMinimize).toHaveBeenCalledTimes(1); act(() => container.querySelector('.results-window-head')?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))); expect(onToggleMinimize).toHaveBeenCalledTimes(2); act(() => root.render(renderWindow(false))); expect(container.querySelector('.results-window-toggle .triangle-icon-down')).not.toBeNull(); expect(container.querySelector('.result-column-resizer')).toBeNull(); act(() => container.querySelector('.results-window-head')?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))); expect(onToggleMinimize).toHaveBeenCalledTimes(2); expect(container.querySelector('.results-window.is-maximized')).not.toBeNull(); expect(container.querySelector('.result-column-resizer')).not.toBeNull(); expect((container.querySelector('.results-window') as HTMLElement).style.left).toBe('8px'); expect((container.querySelector('.results-window') as HTMLElement).style.maxWidth).toBe(`${window.innerWidth - 16}px`); act(() => container.querySelector('.results-window-head')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); expect(onToggleMinimize).toHaveBeenCalledTimes(2); expect(container.querySelector('.results-window.is-maximized')).toBeNull(); expect(container.querySelector('.result-column-resizer')).toBeNull(); // Der zum Klick gehoerende dblclick darf nicht unmittelbar erneut maximieren. act(() => container.querySelector('.results-window-head')?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))); expect(container.querySelector('.results-window.is-maximized')).toBeNull(); act(() => root.render(renderWindow(false, true))); expect(container.querySelector('.results-window.is-maximized')).toBeNull(); expect(container.querySelector('.result-column-resizer')).not.toBeNull(); act(() => root.render(renderWindow(false, false, false))); expect(container.querySelector('.result-column-resizer')).not.toBeNull(); act(() => root.render(renderWindow(true, false, true))); expect(container.querySelector('.result-column-resizer')).toBeNull(); act(() => container.querySelector('.results-window-close')?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))); expect(onToggleMinimize).toHaveBeenCalledTimes(2); act(() => root.unmount()); vi.unstubAllGlobals(); }); });