Spaces:
Sleeping
Sleeping
| import { describe, it, expect, beforeEach } from 'vitest'; | |
| import { | |
| CollapsibleDock, | |
| readCollapsedPreference, | |
| writeCollapsedPreference, | |
| tabGlyphFor, | |
| tabLabelFor, | |
| isMobileViewport, | |
| } from '../src/ui/CollapsibleDock.js'; | |
| function createMockElement(tagName = 'div') { | |
| const children = []; | |
| const attrs = {}; | |
| const classList = new Set(); | |
| let _className = ''; | |
| let _textContent = ''; | |
| let _innerHTML = ''; | |
| const element = { | |
| tagName: tagName.toUpperCase(), | |
| children, | |
| parentNode: null, | |
| style: {}, | |
| dataset: {}, | |
| classList: { | |
| add: (...cls) => { | |
| cls.forEach((c) => { if (c) classList.add(c); }); | |
| _className = Array.from(classList).join(' '); | |
| }, | |
| remove: (...cls) => { | |
| cls.forEach((c) => classList.delete(c)); | |
| _className = Array.from(classList).join(' '); | |
| }, | |
| toggle: (cls, force) => { | |
| if (force === true) classList.add(cls); | |
| else if (force === false) classList.delete(cls); | |
| else if (classList.has(cls)) classList.delete(cls); | |
| else classList.add(cls); | |
| _className = Array.from(classList).join(' '); | |
| return classList.has(cls); | |
| }, | |
| contains: (cls) => classList.has(cls), | |
| }, | |
| appendChild: (child) => { | |
| children.push(child); | |
| child.parentNode = element; | |
| return child; | |
| }, | |
| removeChild: (child) => { | |
| const i = children.indexOf(child); | |
| if (i >= 0) children.splice(i, 1); | |
| child.parentNode = null; | |
| return child; | |
| }, | |
| addEventListener: (type, fn) => { | |
| if (!element._listeners) element._listeners = {}; | |
| if (!element._listeners[type]) element._listeners[type] = []; | |
| element._listeners[type].push(fn); | |
| }, | |
| removeEventListener: (type, fn) => { | |
| if (!element._listeners || !element._listeners[type]) return; | |
| element._listeners[type] = element._listeners[type].filter((f) => f !== fn); | |
| }, | |
| click: () => { | |
| (element._listeners?.click || []).forEach((fn) => fn({ type: 'click' })); | |
| }, | |
| setAttribute: (k, v) => { attrs[k] = String(v); }, | |
| getAttribute: (k) => (attrs[k] !== undefined ? attrs[k] : null), | |
| querySelector: (selector) => { | |
| const id = selector.startsWith('#') ? selector.slice(1) : null; | |
| const cls = selector.startsWith('.') ? selector.slice(1) : null; | |
| for (const child of children) { | |
| if (id && child.id === id) return child; | |
| if (cls && child.classList.contains(cls)) return child; | |
| const found = child.querySelector ? child.querySelector(selector) : null; | |
| if (found) return found; | |
| } | |
| return null; | |
| }, | |
| set innerHTML(val) { | |
| _innerHTML = val; | |
| if (val === '') children.length = 0; | |
| }, | |
| get innerHTML() { | |
| return _innerHTML; | |
| }, | |
| set className(val) { | |
| classList.clear(); | |
| _className = val || ''; | |
| String(val).split(/\s+/).forEach((c) => { if (c) classList.add(c); }); | |
| }, | |
| get className() { | |
| return _className; | |
| }, | |
| set textContent(val) { | |
| _textContent = val == null ? '' : String(val); | |
| }, | |
| get textContent() { | |
| return _textContent; | |
| }, | |
| id: '', | |
| type: '', | |
| }; | |
| return element; | |
| } | |
| function createMemoryStorage() { | |
| const map = new Map(); | |
| return { | |
| getItem: (k) => (map.has(k) ? map.get(k) : null), | |
| setItem: (k, v) => { map.set(k, String(v)); }, | |
| removeItem: (k) => { map.delete(k); }, | |
| clear: () => map.clear(), | |
| _map: map, | |
| }; | |
| } | |
| const mockDoc = { | |
| createElement: (tagName) => { | |
| const el = createMockElement(tagName); | |
| if (tagName === 'button') el.type = 'button'; | |
| return el; | |
| }, | |
| }; | |
| describe('collapsed preference helpers', () => { | |
| it('reads desktop stored value; defaults when missing', () => { | |
| const storage = createMemoryStorage(); | |
| expect(readCollapsedPreference(storage, 'k', { isMobile: false, defaultCollapsed: false })).toBe(false); | |
| storage.setItem('k', '1'); | |
| expect(readCollapsedPreference(storage, 'k', { isMobile: false })).toBe(true); | |
| storage.setItem('k', '0'); | |
| expect(readCollapsedPreference(storage, 'k', { isMobile: false })).toBe(false); | |
| }); | |
| it('mobile always returns collapsed and skips writes (D4 hook)', () => { | |
| const storage = createMemoryStorage(); | |
| storage.setItem('k', '0'); | |
| expect(readCollapsedPreference(storage, 'k', { isMobile: true, defaultCollapsed: false })).toBe(true); | |
| writeCollapsedPreference(storage, 'k', false, { isMobile: true }); | |
| expect(storage.getItem('k')).toBe('0'); | |
| }); | |
| it('writes desktop preference as 1/0', () => { | |
| const storage = createMemoryStorage(); | |
| writeCollapsedPreference(storage, 'k', true, { isMobile: false }); | |
| expect(storage.getItem('k')).toBe('1'); | |
| writeCollapsedPreference(storage, 'k', false, { isMobile: false }); | |
| expect(storage.getItem('k')).toBe('0'); | |
| }); | |
| }); | |
| describe('tab glyphs / labels', () => { | |
| it('left collapsed shows ▶; expanded ◀', () => { | |
| expect(tabGlyphFor('left', true)).toBe('▶'); | |
| expect(tabGlyphFor('left', false)).toBe('◀'); | |
| }); | |
| it('right collapsed shows ◀; expanded ▶', () => { | |
| expect(tabGlyphFor('right', true)).toBe('◀'); | |
| expect(tabGlyphFor('right', false)).toBe('▶'); | |
| }); | |
| it('aria labels flip with collapsed state', () => { | |
| expect(tabLabelFor('left', true)).toMatch(/Expand/i); | |
| expect(tabLabelFor('left', false)).toMatch(/Collapse/i); | |
| }); | |
| }); | |
| describe('isMobileViewport', () => { | |
| it('uses max-width 768px media query', () => { | |
| expect(isMobileViewport(() => ({ matches: true }))).toBe(true); | |
| expect(isMobileViewport(() => ({ matches: false }))).toBe(false); | |
| expect(isMobileViewport(undefined)).toBe(false); | |
| }); | |
| }); | |
| describe('CollapsibleDock', () => { | |
| let parent; | |
| let storage; | |
| beforeEach(() => { | |
| parent = createMockElement('div'); | |
| storage = createMemoryStorage(); | |
| }); | |
| function makeDock(overrides = {}) { | |
| return new CollapsibleDock({ | |
| parent, | |
| side: 'left', | |
| id: 'left-dock', | |
| storageKey: 'vl3d.dock.left.collapsed', | |
| storage, | |
| isMobile: () => false, | |
| doc: mockDoc, | |
| ...overrides, | |
| }); | |
| } | |
| it('mounts body + tab without unmounting children on toggle', () => { | |
| const dock = makeDock(); | |
| const panel = createMockElement('div'); | |
| panel.id = 'sidebar-panel'; | |
| dock.body.appendChild(panel); | |
| expect(parent.children).toContain(dock.root); | |
| expect(dock.tab.getAttribute('aria-expanded')).toBe('true'); | |
| expect(dock.isCollapsed).toBe(false); | |
| dock.toggle(); | |
| expect(dock.isCollapsed).toBe(true); | |
| expect(dock.root.classList.contains('is-collapsed')).toBe(true); | |
| expect(dock.tab.getAttribute('aria-expanded')).toBe('false'); | |
| expect(dock.tab.textContent).toBe('▶'); | |
| expect(dock.body.children).toContain(panel); | |
| dock.toggle(); | |
| expect(dock.isCollapsed).toBe(false); | |
| expect(dock.tab.getAttribute('aria-expanded')).toBe('true'); | |
| expect(dock.body.children).toContain(panel); | |
| }); | |
| it('tab click toggles collapsed state', () => { | |
| const dock = makeDock({ side: 'right', id: 'right-dock', storageKey: 'vl3d.dock.right.collapsed' }); | |
| expect(dock.isCollapsed).toBe(false); | |
| dock.tab.click(); | |
| expect(dock.isCollapsed).toBe(true); | |
| expect(dock.tab.textContent).toBe('◀'); | |
| dock.tab.click(); | |
| expect(dock.isCollapsed).toBe(false); | |
| }); | |
| it('persists desktop collapsed to localStorage', () => { | |
| const dock = makeDock(); | |
| dock.setCollapsed(true); | |
| expect(storage.getItem('vl3d.dock.left.collapsed')).toBe('1'); | |
| const dock2 = makeDock({ id: 'left-dock-2' }); | |
| expect(dock2.isCollapsed).toBe(true); | |
| expect(dock2.tab.getAttribute('aria-expanded')).toBe('false'); | |
| }); | |
| it('MODE policy: shared left collapsed flag survives re-host (Arithmetic↔Compare)', () => { | |
| const dock = makeDock(); | |
| const arithmetic = createMockElement('div'); | |
| arithmetic.id = 'sidebar-panel'; | |
| const compare = createMockElement('div'); | |
| compare.id = 'compare-panel'; | |
| compare.classList.add('hidden'); | |
| dock.body.appendChild(arithmetic); | |
| dock.body.appendChild(compare); | |
| dock.setCollapsed(true); | |
| // Simulate MODE switch: hide arithmetic, show compare — dock flag untouched | |
| arithmetic.classList.add('hidden'); | |
| compare.classList.remove('hidden'); | |
| expect(dock.isCollapsed).toBe(true); | |
| expect(dock.root.classList.contains('is-collapsed')).toBe(true); | |
| expect(storage.getItem('vl3d.dock.left.collapsed')).toBe('1'); | |
| expect(dock.body.children).toEqual([arithmetic, compare]); | |
| }); | |
| it('mobile probe forces initial collapsed and does not persist toggle', () => { | |
| const dock = makeDock({ | |
| isMobile: () => true, | |
| defaultCollapsed: false, | |
| }); | |
| expect(dock.isCollapsed).toBe(true); | |
| dock.setCollapsed(false); | |
| expect(storage.getItem('vl3d.dock.left.collapsed')).toBeNull(); | |
| }); | |
| }); | |