@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
493 lines • 24.8 kB
JavaScript
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