test: cover current behavior

This commit is contained in:
ayo 2026-07-05 09:37:08 +02:00
parent 8af38226b4
commit ec2033fe26
7 changed files with 464 additions and 1 deletions

View file

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WebComponent } from '../src/WebComponent.js' import { WebComponent } from '../src/WebComponent.js'
import { html } from '../src/html.js'
let componentUnderTest let componentUnderTest
@ -27,3 +28,251 @@ describe('WebComponent', () => {
expect(() => assignToReadonly()).toThrowError() expect(() => assignToReadonly()).toThrowError()
}) })
}) })
/**
* Registers a one-off custom element for `Class` under a unique tag,
* appends an instance to the document (triggering connectedCallback),
* and returns it. Remove it with `el.remove()` to trigger disconnect.
*/
let tagSeq = 0
function mount(Class, attributes = {}) {
const tag = `wc-test-${tagSeq++}`
window.customElements.define(tag, Class)
const el = document.createElement(tag)
for (const [name, value] of Object.entries(attributes)) {
el.setAttribute(name, value)
}
document.body.appendChild(el)
return el
}
afterEach(() => {
document.body.replaceChildren()
})
describe('observedAttributes', () => {
it('is derived from static props as kebab-case names', () => {
class Observed extends WebComponent {
static props = { myName: 'World', isActive: false }
}
expect(Observed.observedAttributes).toEqual(['my-name', 'is-active'])
})
it('is empty when no static props are declared', () => {
class Bare extends WebComponent {}
expect(Bare.observedAttributes).toEqual([])
})
})
describe('life-cycle hooks', () => {
it('calls onInit when connected, then afterViewInit after first render', () => {
const order = []
class Hooked extends WebComponent {
onInit() {
order.push('onInit')
}
afterViewInit() {
order.push('afterViewInit')
}
get template() {
return `<span>hi</span>`
}
}
mount(Hooked)
expect(order).toEqual(['onInit', 'afterViewInit'])
})
it('renders the template into the DOM on connect', () => {
class Rendered extends WebComponent {
get template() {
return `<h1>Hello</h1>`
}
}
const el = mount(Rendered)
expect(el.querySelector('h1')?.textContent).toBe('Hello')
})
it('calls onDestroy when disconnected', () => {
const onDestroy = vi.fn()
class Destroyed extends WebComponent {
onDestroy() {
onDestroy()
}
}
const el = mount(Destroyed)
el.remove()
expect(onDestroy).toHaveBeenCalledOnce()
})
})
describe('render()', () => {
it('renders a string template into innerHTML', () => {
class StringTemplate extends WebComponent {
get template() {
return `<p class="from-string">yo</p>`
}
}
const el = mount(StringTemplate)
expect(el.querySelector('p.from-string')?.textContent).toBe('yo')
})
it('renders an object (html) template into real DOM nodes', () => {
class ObjectTemplate extends WebComponent {
get template() {
return html`<p class="from-html">yo</p>`
}
}
const el = mount(ObjectTemplate)
const p = el.querySelector('p.from-html')
expect(p).not.toBeNull()
expect(p.textContent).toBe('yo')
})
it('can be called manually to re-render unobserved state', () => {
class Manual extends WebComponent {
_count = 0
get template() {
return `<span>${this._count}</span>`
}
}
const el = mount(Manual)
expect(el.querySelector('span').textContent).toBe('0')
el._count = 5
el.render()
expect(el.querySelector('span').textContent).toBe('5')
})
it('can be overridden to use a custom rendering approach', () => {
class Custom extends WebComponent {
render() {
this.innerHTML = '<em>custom</em>'
}
}
const el = mount(Custom)
expect(el.querySelector('em')?.textContent).toBe('custom')
})
})
describe('styling', () => {
it('applies a style object to an element via the html template', () => {
class StyleObject extends WebComponent {
get template() {
return html`<div style=${{ color: 'red', padding: '1em' }}>x</div>`
}
}
const el = mount(StyleObject)
const div = el.querySelector('div')
expect(div.style.color).toBe('red')
expect(div.style.padding).toBe('1em')
})
it('adopts a static styles stylesheet in the shadow root', () => {
class ShadowStyled extends WebComponent {
static shadowRootInit = { mode: 'open' }
static styles = `p { color: red; }`
get template() {
return html`<p>hi</p>`
}
}
const el = mount(ShadowStyled)
expect(el.shadowRoot.adoptedStyleSheets).toHaveLength(1)
})
})
describe('shadow DOM', () => {
it('renders into a shadow root when shadowRootInit is set', () => {
class Shadowed extends WebComponent {
static shadowRootInit = { mode: 'open' }
get template() {
return html`<p>in shadow</p>`
}
}
const el = mount(Shadowed)
expect(el.shadowRoot).not.toBeNull()
expect(el.shadowRoot.querySelector('p')?.textContent).toBe('in shadow')
// light DOM stays empty
expect(el.querySelector('p')).toBeNull()
})
it('renders into the light DOM by default', () => {
class Light extends WebComponent {
get template() {
return html`<p>in light</p>`
}
}
const el = mount(Light)
expect(el.shadowRoot).toBeNull()
expect(el.querySelector('p')?.textContent).toBe('in light')
})
})
/**
* Reactive-prop contract as documented on the docs site
* (guides/usage, guides/prop-access, guides/life-cycle-hooks).
*
* These are marked `it.fails` because the current implementation cannot
* construct any component that declares `static props`: the constructor
* reflects each default with `setAttribute`, which synchronously fires
* `attributeChangedCallback` -> `#handleUpdateProp` before the `#props`
* proxy is assigned, throwing a TypeError. This is the Phase-0 correctness
* work called out at the top of the README (no attribute writes in the
* constructor; empty-string / removed-attribute handling). When those
* fixes land, flip these back to `it` vitest will flag each one that
* starts passing.
*/
describe('reactive props (documented contract, pending Phase 0 fixes)', () => {
class HelloWorld extends WebComponent {
static props = { myName: 'World', count: 0, active: false, user: { a: 1 } }
get template() {
return `<h1>Hello ${this.props.myName}</h1>`
}
}
it.fails('reflects static props defaults onto attributes', () => {
const el = mount(HelloWorld)
expect(el.getAttribute('my-name')).toBe('World')
})
it.fails('exposes camelCase props with their declared types', () => {
const el = mount(HelloWorld)
expect(el.props.myName).toBe('World')
expect(el.props.count).toBe(0)
expect(el.props.active).toBe(false)
expect(el.props.user).toEqual({ a: 1 })
})
it.fails('re-renders and updates props when an attribute changes', () => {
const el = mount(HelloWorld)
el.setAttribute('my-name', 'Ayo')
expect(el.props.myName).toBe('Ayo')
expect(el.querySelector('h1').textContent).toBe('Hello Ayo')
})
it.fails('reflects prop writes back to the attribute (props === setAttribute)', () => {
const el = mount(HelloWorld)
el.props.myName = 'Ayo'
expect(el.getAttribute('my-name')).toBe('Ayo')
})
it.fails('preserves the declared type when updating a number prop', () => {
const el = mount(HelloWorld)
el.setAttribute('count', '5')
expect(el.props.count).toBe(5)
})
it.fails('fires onChanges with property/previousValue/currentValue', () => {
const changes = []
class WithChanges extends WebComponent {
static props = { myName: 'World' }
onChanges(change) {
changes.push(change)
}
}
const el = mount(WithChanges)
el.setAttribute('my-name', 'Ayo')
expect(changes.at(-1)).toMatchObject({
property: 'my-name',
currentValue: 'Ayo',
})
})
})

28
test/exports.test.mjs Normal file
View file

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import * as main from '../src/index.js'
import * as utils from '../src/utils/index.js'
describe('main exports', () => {
it('exposes WebComponent and html', () => {
expect(typeof main.WebComponent).toBe('function')
expect(typeof main.html).toBe('function')
})
it('WebComponent extends HTMLElement', () => {
expect(main.WebComponent.prototype).toBeInstanceOf(HTMLElement)
})
})
describe('utils exports', () => {
it('exposes every documented utility', () => {
for (const name of [
'serialize',
'deserialize',
'getCamelCase',
'getKebabCase',
'createElement',
]) {
expect(typeof utils[name], name).toBe('function')
}
})
})

49
test/html.test.mjs Normal file
View file

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { html } from '../src/html.js'
describe('html tagged template', () => {
it('produces a {type, props, children} vnode', () => {
const vnode = html`<p>hi</p>`
expect(vnode).toMatchObject({ type: 'p', children: ['hi'] })
})
it('reads static attributes into props', () => {
const vnode = html`<a href="/x">y</a>`
expect(vnode.props).toEqual({ href: '/x' })
})
it('reads interpolated attribute values into props', () => {
const href = '/dynamic'
const vnode = html`<a href=${href}>y</a>`
expect(vnode.props.href).toBe('/dynamic')
})
it('treats bare attributes as boolean true', () => {
const vnode = html`<input disabled />`
expect(vnode.type).toBe('input')
expect(vnode.props).toEqual({ disabled: true })
})
it('supports spread props', () => {
const vnode = html`<a ...${{ href: '/x', title: 't' }}>y</a>`
expect(vnode.props).toMatchObject({ href: '/x', title: 't' })
})
it('nests child vnodes', () => {
const vnode = html`<ul><li>a</li></ul>`
expect(vnode.type).toBe('ul')
expect(vnode.children[0]).toMatchObject({ type: 'li', children: ['a'] })
})
it('interleaves text and interpolated values as children', () => {
const vnode = html`<p>Hello ${'World'}</p>`
expect(vnode.type).toBe('p')
expect(vnode.children.join('')).toBe('Hello World')
})
it('returns an array for multiple root nodes', () => {
const vnodes = html`<p>a</p><p>b</p>`
expect(Array.isArray(vnodes)).toBe(true)
expect(vnodes).toHaveLength(2)
})
})

View file

@ -0,0 +1,35 @@
import { describe, expect, test } from 'vitest'
import { getCamelCase } from '../../src/utils/get-camel-case.mjs'
import { getKebabCase } from '../../src/utils/get-kebab-case.mjs'
describe('getKebabCase', () => {
test('converts camelCase to kebab-case', () => {
expect(getKebabCase('myName')).toBe('my-name')
expect(getKebabCase('isActive')).toBe('is-active')
expect(getKebabCase('dataFooBar')).toBe('data-foo-bar')
})
test('leaves single-word names unchanged', () => {
expect(getKebabCase('emotion')).toBe('emotion')
})
})
describe('getCamelCase', () => {
test('converts kebab-case to camelCase', () => {
expect(getCamelCase('my-name')).toBe('myName')
expect(getCamelCase('is-active')).toBe('isActive')
expect(getCamelCase('data-foo-bar')).toBe('dataFooBar')
})
test('leaves single-word names unchanged', () => {
expect(getCamelCase('emotion')).toBe('emotion')
})
})
describe('case conversion round-trip', () => {
test('camel -> kebab -> camel is stable', () => {
for (const name of ['myName', 'isActive', 'dataFooBar', 'emotion']) {
expect(getCamelCase(getKebabCase(name))).toBe(name)
}
})
})

View file

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { createElement } from '../../src/utils/create-element.mjs'
import { html } from '../../src/html.js'
describe('createElement', () => {
it('turns a string leaf into a text node', () => {
const node = createElement('hello')
expect(node.nodeType).toBe(Node.TEXT_NODE)
expect(node.textContent).toBe('hello')
})
it('builds an element from a vnode', () => {
const el = createElement({ type: 'p', props: null, children: ['hi'] })
expect(el.tagName).toBe('P')
expect(el.textContent).toBe('hi')
})
it('assigns known DOM properties directly', () => {
const el = createElement({ type: 'div', props: { id: 'foo' }, children: [] })
expect(el.id).toBe('foo')
})
it('falls back to setAttribute for unknown props', () => {
const el = createElement({ type: 'div', props: { 'data-x': 'y' }, children: [] })
expect(el.getAttribute('data-x')).toBe('y')
})
it('applies style objects', () => {
const el = createElement(html`<div style=${{ color: 'red', padding: '1em' }}>x</div>`)
expect(el.style.color).toBe('red')
expect(el.style.padding).toBe('1em')
})
it('attaches event handlers passed as props', () => {
const onClick = vi.fn()
const btn = createElement(html`<button onClick=${onClick}>hey</button>`)
btn.click()
expect(onClick).toHaveBeenCalledOnce()
})
it('appends nested element and text children', () => {
const el = createElement(html`<ul><li>a</li><li>b</li></ul>`)
expect(el.tagName).toBe('UL')
expect(el.querySelectorAll('li')).toHaveLength(2)
expect(el.textContent).toBe('ab')
})
it('wraps an array of vnodes in a document fragment', () => {
const frag = createElement([
{ type: 'p', props: null, children: ['a'] },
{ type: 'p', props: null, children: ['b'] },
])
expect(frag.nodeType).toBe(Node.DOCUMENT_FRAGMENT_NODE)
expect(frag.childNodes).toHaveLength(2)
})
it('handles a multi-root html template as a fragment', () => {
const frag = createElement(html`<p>a</p><p>b</p>`)
expect(frag.querySelectorAll('p')).toHaveLength(2)
})
})

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from 'vitest'
import { serialize } from '../../src/utils/serialize.mjs'
import { deserialize } from '../../src/utils/deserialize.mjs'
describe('deserialize', () => {
test('parses number strings', () => {
expect(deserialize('3', 'number')).toBe(3)
expect(deserialize('-4.5', 'number')).toBe(-4.5)
})
test('parses boolean strings', () => {
expect(deserialize('true', 'boolean')).toBe(true)
expect(deserialize('false', 'boolean')).toBe(false)
})
test('parses object strings', () => {
expect(deserialize('{"hello":"world"}', 'object')).toEqual({ hello: 'world' })
expect(deserialize('[1,2,3]', 'object')).toEqual([1, 2, 3])
expect(deserialize('null', 'object')).toBeNull()
})
test('passes strings through untouched', () => {
expect(deserialize('World', 'string')).toBe('World')
// an unknown/default type is treated as a plain string
expect(deserialize('World', 'symbol')).toBe('World')
})
test('round-trips values through serialize', () => {
const cases = [
[3, 'number'],
[false, 'boolean'],
[{ a: 1, b: [2, 3] }, 'object'],
['hello', 'string'],
]
for (const [value, type] of cases) {
expect(deserialize(serialize(value), type)).toEqual(value)
}
})
})

View file

@ -13,6 +13,8 @@ export default defineConfig({
provider: 'v8', provider: 'v8',
reporter: ['html', 'text'], reporter: ['html', 'text'],
include: ['src'], include: ['src'],
// barrel files only re-export; they carry no logic to cover
exclude: ['**/index.js'],
}, },
}, },
}) })