llm-semantic-visualizer / tests /CollapsibleDock.test.js
hbauzan's picture
feat(ui): add collapsible left/right docks (v1.1.0 Etapa A)
89d1e6f
Raw
History Blame Contribute Delete
8.88 kB
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();
});
});