UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

493 lines 24.8 kB
import { describe, expect, it, vi } from 'vitest'; import { SVG_NS } from './svg.js'; import { createVNode, EXISTING_NODE, flattenVChildren, FRAGMENT, isVNode, isVTextNode, mountChild, patchChildren, patchProps, shallowEqual, toVChildArray, unmountChild, } from './vnode.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const vtext = (text) => ({ _brand: 'vtext', text }); const vel = (tag, props, ...children) => ({ _brand: 'vnode', type: tag, props: props ?? {}, children, }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('vnode', () => { describe('createVNode', () => { it('should create an intrinsic element VNode', () => { const vnode = createVNode('div', { id: 'test' }, 'hello'); expect(vnode._brand).toBe('vnode'); expect(vnode.type).toBe('div'); expect(vnode.props).toEqual({ id: 'test' }); expect(vnode.children).toHaveLength(1); expect(vnode.children[0]).toEqual({ _brand: 'vtext', text: 'hello' }); }); it('should create a fragment VNode', () => { const child = createVNode('p', null, 'text'); const vnode = createVNode(null, null, child); expect(vnode.type).toBe(FRAGMENT); expect(vnode.children).toEqual([child]); }); it('should flatten nested arrays', () => { const vnode = createVNode('ul', null, [createVNode('li', null, 'a'), createVNode('li', null, 'b')]); expect(vnode.children).toHaveLength(2); }); it('should skip null and boolean children', () => { const vnode = createVNode('div', null, null, false, true, undefined, 'kept'); expect(vnode.children).toHaveLength(1); expect(vnode.children[0].text).toBe('kept'); }); it('should inline fragment children', () => { const fragment = createVNode(null, null, 'a', 'b'); const vnode = createVNode('div', null, fragment); expect(vnode.children).toHaveLength(2); expect(vnode.children[0].text).toBe('a'); expect(vnode.children[1].text).toBe('b'); }); it('should convert numbers to text nodes', () => { const vnode = createVNode('span', null, 42); expect(vnode.children).toHaveLength(1); expect(vnode.children[0].text).toBe('42'); }); it('should normalize null props to empty object', () => { const vnode = createVNode('div', null); expect(vnode.props).toEqual({}); expect(vnode.props).not.toBeNull(); }); }); describe('flattenVChildren', () => { it('should wrap real DOM nodes as EXISTING_NODE VNodes', () => { const div = document.createElement('div'); const result = flattenVChildren([div]); expect(result).toHaveLength(1); expect(isVNode(result[0])).toBe(true); expect(result[0].type).toBe(EXISTING_NODE); expect(result[0]._el).toBe(div); }); }); describe('type guards', () => { it('isVNode should identify VNodes', () => { expect(isVNode(createVNode('div', null))).toBe(true); expect(isVNode({ _brand: 'vtext', text: 'hello' })).toBe(false); expect(isVNode(null)).toBe(false); expect(isVNode('string')).toBe(false); }); it('isVTextNode should identify VTextNodes', () => { expect(isVTextNode({ _brand: 'vtext', text: 'hi' })).toBe(true); expect(isVTextNode(createVNode('div', null))).toBe(false); }); }); describe('shallowEqual', () => { it('should return true for identical references', () => { const obj = { a: 1 }; expect(shallowEqual(obj, obj)).toBe(true); }); it('should return true for equal props', () => { expect(shallowEqual({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true); }); it('should return false for different values', () => { expect(shallowEqual({ a: 1 }, { a: 2 })).toBe(false); }); it('should return false for different key counts', () => { expect(shallowEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); }); it('should return true for two empty objects', () => { expect(shallowEqual({}, {})).toBe(true); }); }); describe('toVChildArray', () => { it('should return empty array for null', () => { expect(toVChildArray(null)).toEqual([]); }); it('should wrap string as VTextNode', () => { const result = toVChildArray('hello'); expect(result).toHaveLength(1); expect(isVTextNode(result[0])).toBe(true); }); it('should unwrap fragment VNode children', () => { const fragment = createVNode(null, null, createVNode('p', null, 'a'), createVNode('p', null, 'b')); const result = toVChildArray(fragment); expect(result).toHaveLength(2); }); it('should wrap single VNode in array', () => { const vnode = createVNode('div', null, 'text'); const result = toVChildArray(vnode); expect(result).toEqual([vnode]); }); it('should wrap real DOM element as EXISTING_NODE', () => { const el = document.createElement('div'); const result = toVChildArray(el); expect(result).toHaveLength(1); expect(result[0].type).toBe(EXISTING_NODE); expect(result[0]._el).toBe(el); }); it('should wrap DocumentFragment children as EXISTING_NODE VNodes', () => { const fragment = document.createDocumentFragment(); fragment.appendChild(document.createElement('span')); fragment.appendChild(document.createTextNode('text')); const result = toVChildArray(fragment); expect(result).toHaveLength(2); expect(result[0].type).toBe(EXISTING_NODE); expect(result[0]._el).toBeInstanceOf(HTMLSpanElement); expect(result[1].type).toBe(EXISTING_NODE); expect(result[1]._el).toBeInstanceOf(Text); }); it('should convert number to VTextNode', () => { const result = toVChildArray(42); expect(result).toHaveLength(1); expect(isVTextNode(result[0])).toBe(true); expect(result[0].text).toBe('42'); }); it('should return empty array for undefined', () => { expect(toVChildArray(undefined)).toEqual([]); }); }); describe('mountChild', () => { it('should mount a text node', () => { const parent = document.createElement('div'); mountChild(vtext('hello'), parent); expect(parent.textContent).toBe('hello'); }); it('should mount an intrinsic element with props', () => { const parent = document.createElement('div'); const child = vel('span', { id: 'test', className: 'cls' }, vtext('content')); mountChild(child, parent); const span = parent.querySelector('span'); expect(span.id).toBe('test'); expect(span.className).toBe('cls'); expect(span.textContent).toBe('content'); expect(child._el).toBe(span); }); it('should mount nested children', () => { const parent = document.createElement('div'); const child = vel('ul', null, vel('li', null, vtext('a')), vel('li', null, vtext('b'))); mountChild(child, parent); expect(parent.innerHTML).toBe('<ul><li>a</li><li>b</li></ul>'); }); it('should mount an EXISTING_NODE by appending the real element', () => { const parent = document.createElement('div'); const existing = document.createElement('span'); existing.textContent = 'existing'; const child = { _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: existing }; mountChild(child, parent); expect(parent.firstChild).toBe(existing); }); it('should set _el on text nodes', () => { const parent = document.createElement('div'); const child = vtext('hi'); mountChild(child, parent); expect(child._el).toBeInstanceOf(Text); expect(child._el?.textContent).toBe('hi'); }); it('should create SVG elements with createElementNS', () => { const parent = document.createElement('div'); const child = vel('svg', { viewBox: '0 0 100 100' }, vel('circle', { cx: '50', cy: '50', r: '40' })); mountChild(child, parent); const svg = parent.querySelector('svg'); expect(svg).toBeInstanceOf(SVGElement); expect(svg?.namespaceURI).toBe(SVG_NS); expect(svg?.getAttribute('viewBox')).toBe('0 0 100 100'); const circle = svg?.querySelector('circle'); expect(circle).toBeInstanceOf(SVGElement); expect(circle?.namespaceURI).toBe(SVG_NS); expect(circle?.getAttribute('cx')).toBe('50'); }); it('should set className as class attribute on SVG elements', () => { const parent = document.createElement('div'); const child = vel('g', { className: 'my-group' }); mountChild(child, parent); const g = parent.querySelector('g'); expect(g?.getAttribute('class')).toBe('my-group'); }); it('should attach event handlers as properties on SVG elements', () => { const parent = document.createElement('div'); const handler = vi.fn(); const child = vel('rect', { onclick: handler }); mountChild(child, parent); const rect = parent.querySelector('rect'); expect(rect.onclick).toBe(handler); }); it('should handle SVG elements with style props', () => { const parent = document.createElement('div'); const child = vel('rect', { style: { fill: 'red', strokeWidth: '2px' } }); mountChild(child, parent); const rect = parent.querySelector('rect'); expect(rect.style.fill).toBe('red'); expect(rect.style.strokeWidth).toBe('2px'); }); it('should not mount EXISTING_NODE when _el is undefined', () => { const parent = document.createElement('div'); const child = { _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [] }; const result = mountChild(child, parent); expect(result).toBeUndefined(); expect(parent.childNodes.length).toBe(0); }); it('should set ref on mounted intrinsic elements', () => { const parent = document.createElement('div'); const ref = { current: null }; const child = vel('input', { ref }); mountChild(child, parent); expect(ref.current).toBeInstanceOf(HTMLInputElement); expect(ref.current).toBe(parent.querySelector('input')); }); }); describe('unmountChild', () => { it('should remove a mounted element from the DOM', () => { const parent = document.createElement('div'); const child = vel('span', null, vtext('bye')); mountChild(child, parent); expect(parent.children.length).toBe(1); unmountChild(child); expect(parent.children.length).toBe(0); }); it('should remove a mounted text node from the DOM', () => { const parent = document.createElement('div'); const child = vtext('bye'); mountChild(child, parent); expect(parent.childNodes.length).toBe(1); unmountChild(child); expect(parent.childNodes.length).toBe(0); }); }); describe('patchProps', () => { it('should add new props', () => { const el = document.createElement('div'); patchProps(el, {}, { id: 'new' }); expect(el.id).toBe('new'); }); it('should update changed props', () => { const el = document.createElement('div'); el.id = 'old'; patchProps(el, { id: 'old' }, { id: 'new' }); expect(el.id).toBe('new'); }); it('should remove stale event handlers', () => { const el = document.createElement('div'); const handler = vi.fn(); el.onclick = handler; patchProps(el, { onclick: handler }, {}); expect(el.onclick).toBeNull(); }); it('should update event handlers', () => { const el = document.createElement('button'); const handler1 = vi.fn(); const handler2 = vi.fn(); patchProps(el, { onclick: handler1 }, { onclick: handler2 }); expect(el.onclick).toBe(handler2); }); it('should patch styles', () => { const el = document.createElement('div'); patchProps(el, { style: { color: 'red', fontSize: '14px' } }, { style: { color: 'blue' } }); expect(el.style.color).toBe('blue'); expect(el.style.fontSize).toBe(''); }); it('should set data attributes', () => { const el = document.createElement('div'); patchProps(el, {}, { 'data-testid': 'foo' }); expect(el.getAttribute('data-testid')).toBe('foo'); }); it('should remove data attributes', () => { const el = document.createElement('div'); el.setAttribute('data-testid', 'foo'); patchProps(el, { 'data-testid': 'foo' }, {}); expect(el.hasAttribute('data-testid')).toBe(false); }); describe('SVG elements', () => { it('should set attributes via setAttribute on SVG elements', () => { const el = document.createElementNS(SVG_NS, 'rect'); patchProps(el, {}, { width: '100', height: '50', rx: '5' }); expect(el.getAttribute('width')).toBe('100'); expect(el.getAttribute('height')).toBe('50'); expect(el.getAttribute('rx')).toBe('5'); }); it('should set className as class attribute on SVG elements', () => { const el = document.createElementNS(SVG_NS, 'g'); patchProps(el, {}, { className: 'my-group' }); expect(el.getAttribute('class')).toBe('my-group'); }); it('should remove attributes from SVG elements', () => { const el = document.createElementNS(SVG_NS, 'circle'); el.setAttribute('fill', 'red'); patchProps(el, { fill: 'red' }, {}); expect(el.hasAttribute('fill')).toBe(false); }); it('should remove className as class from SVG elements', () => { const el = document.createElementNS(SVG_NS, 'g'); el.setAttribute('class', 'old'); patchProps(el, { className: 'old' }, {}); expect(el.hasAttribute('class')).toBe(false); }); it('should remove attributes when value is null/undefined/false on SVG elements', () => { const el = document.createElementNS(SVG_NS, 'rect'); el.setAttribute('fill', 'red'); patchProps(el, { fill: 'red' }, { fill: null }); expect(el.hasAttribute('fill')).toBe(false); }); it('should set event handlers as properties on SVG elements', () => { const el = document.createElementNS(SVG_NS, 'rect'); const handler = vi.fn(); patchProps(el, {}, { onclick: handler }); expect(el.onclick).toBe(handler); }); }); }); describe('patchChildren', () => { it('should mount all children when old is empty', () => { const parent = document.createElement('div'); const newChildren = [vel('span', null, vtext('a')), vel('span', null, vtext('b'))]; patchChildren(parent, [], newChildren); expect(parent.children.length).toBe(2); expect(parent.children[0].textContent).toBe('a'); expect(parent.children[1].textContent).toBe('b'); }); it('should remove all children when new is empty', () => { const parent = document.createElement('div'); const oldChildren = [vel('span', null, vtext('a'))]; patchChildren(parent, [], oldChildren); // mount first patchChildren(parent, oldChildren, []); expect(parent.children.length).toBe(0); }); it('should patch matching text nodes', () => { const parent = document.createElement('div'); const old = [vtext('old')]; patchChildren(parent, [], old); const textNode = parent.firstChild; const updated = [vtext('new')]; patchChildren(parent, old, updated); expect(parent.firstChild).toBe(textNode); expect(parent.textContent).toBe('new'); }); it('should patch matching intrinsic elements in place', () => { const parent = document.createElement('div'); const old = [vel('span', { id: 'a' }, vtext('old'))]; patchChildren(parent, [], old); const span = parent.querySelector('span'); const updated = [vel('span', { id: 'b' }, vtext('new'))]; patchChildren(parent, old, updated); expect(parent.querySelector('span')).toBe(span); expect(span.id).toBe('b'); expect(span.textContent).toBe('new'); }); it('should replace when types differ', () => { const parent = document.createElement('div'); const old = [vel('div', null, vtext('div'))]; patchChildren(parent, [], old); const updated = [vel('span', null, vtext('span'))]; patchChildren(parent, old, updated); expect(parent.children[0].tagName).toBe('SPAN'); expect(parent.textContent).toBe('span'); }); it('should add excess new children', () => { const parent = document.createElement('div'); const old = [vel('p', null, vtext('a'))]; patchChildren(parent, [], old); const updated = [vel('p', null, vtext('a')), vel('p', null, vtext('b'))]; patchChildren(parent, old, updated); expect(parent.children.length).toBe(2); }); it('should remove excess old children', () => { const parent = document.createElement('div'); const old = [vel('p', null, vtext('a')), vel('p', null, vtext('b'))]; patchChildren(parent, [], old); const updated = [vel('p', null, vtext('only'))]; patchChildren(parent, old, updated); expect(parent.children.length).toBe(1); expect(parent.textContent).toBe('only'); }); it('should preserve element identity across patches', () => { const parent = document.createElement('div'); const old = [vel('input', { type: 'text' })]; patchChildren(parent, [], old); const input = parent.querySelector('input'); const updated = [vel('input', { type: 'text', id: 'updated' })]; patchChildren(parent, old, updated); expect(parent.querySelector('input')).toBe(input); expect(input.id).toBe('updated'); }); it('should handle EXISTING_NODE patching (same reference)', () => { const parent = document.createElement('div'); const real = document.createElement('span'); real.textContent = 'real'; const old = [{ _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: real }]; patchChildren(parent, [], old); expect(parent.firstChild).toBe(real); const updated = [{ _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: real }]; patchChildren(parent, old, updated); expect(parent.firstChild).toBe(real); }); it('should mount new child into parent when types differ and old node is detached', () => { const parent = document.createElement('div'); const old = [vel('div', null, vtext('div'))]; patchChildren(parent, [], old); // Detach old node manually to simulate a detached state const oldNode = old[0]._el; oldNode.parentNode.removeChild(oldNode); const updated = [vel('span', null, vtext('span'))]; patchChildren(parent, old, updated); expect(parent.children.length).toBe(1); expect(parent.children[0].tagName).toBe('SPAN'); }); it('should clear ref on unmount', () => { const parent = document.createElement('div'); const ref = { current: null }; const child = vel('input', { ref }); patchChildren(parent, [], [child]); expect(ref.current).toBeInstanceOf(HTMLInputElement); patchChildren(parent, [child], []); expect(ref.current).toBeNull(); }); describe('Shade component boundaries', () => { it('should call updateComponentSync on child Shade when props change', () => { const parent = document.createElement('div'); const fakeShadeEl = document.createElement('my-shade'); const updateFn = vi.fn(); fakeShadeEl.updateComponentSync = updateFn; fakeShadeEl.props = { count: 1 }; fakeShadeEl.shadeChildren = undefined; const factory = vi.fn(() => fakeShadeEl); const old = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }]; parent.appendChild(fakeShadeEl); const updated = [{ _brand: 'vnode', type: factory, props: { count: 2 }, children: [] }]; patchChildren(parent, old, updated); expect(updateFn).toHaveBeenCalledOnce(); expect(fakeShadeEl.props).toEqual({ count: 2 }); }); it('should set empty object (not null) on Shade when props transition to none', () => { const parent = document.createElement('div'); const fakeShadeEl = document.createElement('my-shade-3'); const updateFn = vi.fn(); fakeShadeEl.updateComponentSync = updateFn; fakeShadeEl.props = { elevation: 2 }; fakeShadeEl.shadeChildren = undefined; const factory = vi.fn(() => fakeShadeEl); const old = [ { _brand: 'vnode', type: factory, props: { elevation: 2 }, children: [], _el: fakeShadeEl }, ]; parent.appendChild(fakeShadeEl); const updated = [{ _brand: 'vnode', type: factory, props: {}, children: [] }]; patchChildren(parent, old, updated); expect(updateFn).toHaveBeenCalledOnce(); expect(fakeShadeEl.props).toEqual({}); expect(fakeShadeEl.props).not.toBeNull(); }); it('should NOT call updateComponentSync when props are unchanged', () => { const parent = document.createElement('div'); const fakeShadeEl = document.createElement('my-shade-2'); const updateFn = vi.fn(); const props = { count: 1 }; fakeShadeEl.updateComponentSync = updateFn; fakeShadeEl.props = props; fakeShadeEl.shadeChildren = undefined; const factory = vi.fn(() => fakeShadeEl); const old = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }]; parent.appendChild(fakeShadeEl); const updated = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [] }]; patchChildren(parent, old, updated); expect(updateFn).not.toHaveBeenCalled(); }); }); }); }); //# sourceMappingURL=vnode.spec.js.map