From ec2033fe26f5064d218bee5f694d8f4873e9f8a5 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sun, 5 Jul 2026 09:37:08 +0200 Subject: [PATCH] test: cover current behavior --- test/WebComponent.test.mjs | 251 +++++++++++++++++++++++++++- test/exports.test.mjs | 28 ++++ test/html.test.mjs | 49 ++++++ test/utils/case-conversion.test.mjs | 35 ++++ test/utils/create-element.test.mjs | 61 +++++++ test/utils/deserialize.test.mjs | 39 +++++ vitest.config.mjs | 2 + 7 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 test/exports.test.mjs create mode 100644 test/html.test.mjs create mode 100644 test/utils/case-conversion.test.mjs create mode 100644 test/utils/create-element.test.mjs create mode 100644 test/utils/deserialize.test.mjs diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs index f8d972f..5eb56e0 100644 --- a/test/WebComponent.test.mjs +++ b/test/WebComponent.test.mjs @@ -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 { html } from '../src/html.js' let componentUnderTest @@ -27,3 +28,251 @@ describe('WebComponent', () => { 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 `hi` + } + } + mount(Hooked) + expect(order).toEqual(['onInit', 'afterViewInit']) + }) + + it('renders the template into the DOM on connect', () => { + class Rendered extends WebComponent { + get template() { + return `

Hello

` + } + } + 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 `

yo

` + } + } + 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`

yo

` + } + } + 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 `${this._count}` + } + } + 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 = 'custom' + } + } + 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`
x
` + } + } + 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`

hi

` + } + } + 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`

in shadow

` + } + } + 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`

in light

` + } + } + 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 `

Hello ${this.props.myName}

` + } + } + + 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', + }) + }) +}) diff --git a/test/exports.test.mjs b/test/exports.test.mjs new file mode 100644 index 0000000..a95249e --- /dev/null +++ b/test/exports.test.mjs @@ -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') + } + }) +}) diff --git a/test/html.test.mjs b/test/html.test.mjs new file mode 100644 index 0000000..5e181f4 --- /dev/null +++ b/test/html.test.mjs @@ -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`

hi

` + expect(vnode).toMatchObject({ type: 'p', children: ['hi'] }) + }) + + it('reads static attributes into props', () => { + const vnode = html`y` + expect(vnode.props).toEqual({ href: '/x' }) + }) + + it('reads interpolated attribute values into props', () => { + const href = '/dynamic' + const vnode = html`y` + expect(vnode.props.href).toBe('/dynamic') + }) + + it('treats bare attributes as boolean true', () => { + const vnode = html`` + expect(vnode.type).toBe('input') + expect(vnode.props).toEqual({ disabled: true }) + }) + + it('supports spread props', () => { + const vnode = html`y` + expect(vnode.props).toMatchObject({ href: '/x', title: 't' }) + }) + + it('nests child vnodes', () => { + const vnode = html`` + 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`

Hello ${'World'}

` + expect(vnode.type).toBe('p') + expect(vnode.children.join('')).toBe('Hello World') + }) + + it('returns an array for multiple root nodes', () => { + const vnodes = html`

a

b

` + expect(Array.isArray(vnodes)).toBe(true) + expect(vnodes).toHaveLength(2) + }) +}) diff --git a/test/utils/case-conversion.test.mjs b/test/utils/case-conversion.test.mjs new file mode 100644 index 0000000..de8f56f --- /dev/null +++ b/test/utils/case-conversion.test.mjs @@ -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) + } + }) +}) diff --git a/test/utils/create-element.test.mjs b/test/utils/create-element.test.mjs new file mode 100644 index 0000000..ee805b0 --- /dev/null +++ b/test/utils/create-element.test.mjs @@ -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`
x
`) + 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``) + btn.click() + expect(onClick).toHaveBeenCalledOnce() + }) + + it('appends nested element and text children', () => { + const el = createElement(html``) + 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`

a

b

`) + expect(frag.querySelectorAll('p')).toHaveLength(2) + }) +}) diff --git a/test/utils/deserialize.test.mjs b/test/utils/deserialize.test.mjs new file mode 100644 index 0000000..28aca12 --- /dev/null +++ b/test/utils/deserialize.test.mjs @@ -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) + } + }) +}) diff --git a/vitest.config.mjs b/vitest.config.mjs index 433e938..6f2ecbc 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -13,6 +13,8 @@ export default defineConfig({ provider: 'v8', reporter: ['html', 'text'], include: ['src'], + // barrel files only re-export; they carry no logic to cover + exclude: ['**/index.js'], }, }, })