UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

607 lines (508 loc) 23 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, type VChild, type VNode, type VTextNode, } from './vnode.js' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const vtext = (text: string): VTextNode => ({ _brand: 'vtext', text }) const vel = (tag: string, props: Record<string, unknown> | null, ...children: VChild[]): VNode => ({ _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] as VTextNode).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] as VTextNode).text).toBe('a') expect((vnode.children[1] as VTextNode).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] as VTextNode).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] as VNode).type).toBe(EXISTING_NODE) expect((result[0] as VNode)._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] as VNode).type).toBe(EXISTING_NODE) expect((result[0] as VNode)._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] as VNode).type).toBe(EXISTING_NODE) expect((result[0] as VNode)._el).toBeInstanceOf(HTMLSpanElement) expect((result[1] as VNode).type).toBe(EXISTING_NODE) expect((result[1] as VNode)._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] as VTextNode).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: VNode = { _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 as unknown as Record<string, unknown>).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') as SVGElement 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: VNode = { _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 } as { current: Element | 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 as unknown as Record<string, unknown>).onclick).toBe(handler) }) }) }) describe('patchChildren', () => { it('should mount all children when old is empty', () => { const parent = document.createElement('div') const newChildren: VChild[] = [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: VChild[] = [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: VChild[] = [vtext('old')] patchChildren(parent, [], old) const textNode = parent.firstChild! const updated: VChild[] = [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: VChild[] = [vel('span', { id: 'a' }, vtext('old'))] patchChildren(parent, [], old) const span = parent.querySelector('span')! const updated: VChild[] = [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: VChild[] = [vel('div', null, vtext('div'))] patchChildren(parent, [], old) const updated: VChild[] = [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: VChild[] = [vel('p', null, vtext('a'))] patchChildren(parent, [], old) const updated: VChild[] = [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: VChild[] = [vel('p', null, vtext('a')), vel('p', null, vtext('b'))] patchChildren(parent, [], old) const updated: VChild[] = [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: VChild[] = [vel('input', { type: 'text' })] patchChildren(parent, [], old) const input = parent.querySelector('input')! const updated: VChild[] = [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: VChild[] = [{ _brand: 'vnode', type: EXISTING_NODE, props: {}, children: [], _el: real }] patchChildren(parent, [], old) expect(parent.firstChild).toBe(real) const updated: VChild[] = [{ _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: VChild[] = [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: VChild[] = [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 } as { current: Element | 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') as unknown as JSX.Element const updateFn = vi.fn() ;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn ;(fakeShadeEl as unknown as Record<string, unknown>).props = { count: 1 } ;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined const factory = vi.fn(() => fakeShadeEl) const old: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }] parent.appendChild(fakeShadeEl) const updated: VChild[] = [{ _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') as unknown as JSX.Element const updateFn = vi.fn() ;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn ;(fakeShadeEl as unknown as Record<string, unknown>).props = { elevation: 2 } ;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined const factory = vi.fn(() => fakeShadeEl) const old: VChild[] = [ { _brand: 'vnode', type: factory, props: { elevation: 2 }, children: [], _el: fakeShadeEl }, ] parent.appendChild(fakeShadeEl) const updated: VChild[] = [{ _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') as unknown as JSX.Element const updateFn = vi.fn() const props = { count: 1 } ;(fakeShadeEl as unknown as Record<string, unknown>).updateComponentSync = updateFn ;(fakeShadeEl as unknown as Record<string, unknown>).props = props ;(fakeShadeEl as unknown as Record<string, unknown>).shadeChildren = undefined const factory = vi.fn(() => fakeShadeEl) const old: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [], _el: fakeShadeEl }] parent.appendChild(fakeShadeEl) const updated: VChild[] = [{ _brand: 'vnode', type: factory, props: { count: 1 }, children: [] }] patchChildren(parent, old, updated) expect(updateFn).not.toHaveBeenCalled() }) }) }) })