From 6cf2e7e6e99155f80fee862c82aadb5dbdc8f710 Mon Sep 17 00:00:00 2001 From: Jack Lukic Date: Fri, 1 May 2026 15:05:42 -0400 Subject: [PATCH 01/43] Test: Add Template coverage tests with failing bug pins Adds 454 tests across 8 surfaces of the Template runtime (packages/templating/src/template.js): events DSL, lifecycle, callback params, key bindings, DOM scoping, data context, subtemplate settings, tree traversal. 30 tests are expected-fail pins documenting confirmed bugs surfaced by the coverage campaign. Subsequent commits fix each bug; the corresponding pin tests turn green commit by commit. Includes shared scaffolding at packages/templating/test/_helpers/: stub engine, fresh-Template fixture, browser shadow-DOM mounting, registry cleanup, synthetic event/key dispatch. Surface 2 also revives the previously-commented lifecycle integration tests in packages/component/test/browser/component.test.js (replacing lines 461-608's TODO block with a 14-test Lifecycle Events describe). --- .../component/test/browser/component.test.js | 505 +++++--- packages/templating/test/_helpers/README.md | 43 + .../test/_helpers/browser-fixture.js | 154 +++ packages/templating/test/_helpers/dispatch.js | 122 ++ .../test/_helpers/fresh-template.js | 124 ++ .../test/_helpers/registry-cleanup.js | 61 + .../templating/test/_helpers/stub-engine.js | 82 ++ .../test/browser/callback-params.test.js | 884 ++++++++++++++ .../test/browser/data-context-render.test.js | 330 ++++++ .../test/browser/dom-scoping.test.js | 443 +++++++ .../templating/test/browser/events.test.js | 963 ++++++++++++++++ .../templating/test/browser/lifecycle.test.js | 915 +++++++++++++++ .../browser/subtemplate-composition.test.js | 220 ++++ .../test/browser/tree-traversal-dom.test.js | 728 ++++++++++++ packages/templating/test/data-context.test.js | 931 +++++++++++++++ .../templating/test/dom/key-bindings.test.js | 782 +++++++++++++ .../test/subtemplate-settings.test.js | 1023 +++++++++++++++++ .../templating/test/tree-traversal.test.js | 976 ++++++++++++++++ 18 files changed, 9158 insertions(+), 128 deletions(-) create mode 100644 packages/templating/test/_helpers/README.md create mode 100644 packages/templating/test/_helpers/browser-fixture.js create mode 100644 packages/templating/test/_helpers/dispatch.js create mode 100644 packages/templating/test/_helpers/fresh-template.js create mode 100644 packages/templating/test/_helpers/registry-cleanup.js create mode 100644 packages/templating/test/_helpers/stub-engine.js create mode 100644 packages/templating/test/browser/callback-params.test.js create mode 100644 packages/templating/test/browser/data-context-render.test.js create mode 100644 packages/templating/test/browser/dom-scoping.test.js create mode 100644 packages/templating/test/browser/events.test.js create mode 100644 packages/templating/test/browser/lifecycle.test.js create mode 100644 packages/templating/test/browser/subtemplate-composition.test.js create mode 100644 packages/templating/test/browser/tree-traversal-dom.test.js create mode 100644 packages/templating/test/data-context.test.js create mode 100644 packages/templating/test/dom/key-bindings.test.js create mode 100644 packages/templating/test/subtemplate-settings.test.js create mode 100644 packages/templating/test/tree-traversal.test.js diff --git a/packages/component/test/browser/component.test.js b/packages/component/test/browser/component.test.js index 166bf96a5..9b71adb6c 100644 --- a/packages/component/test/browser/component.test.js +++ b/packages/component/test/browser/component.test.js @@ -458,154 +458,403 @@ describe('Component', () => { expect(TestComponent.template.createComponent).toBe(createComponentWithSignal); }); }); - /* - Unclear expected functionality here so removing tests for now - - // Test lifecycle events behavior + // Test lifecycle events behavior — Surface 2 (Stage 2 of coverage campaign). describe('Lifecycle Events', () => { - it('should ensure each lifecycle event only fires once and does not bubble from nested components', async () => { - // Track all lifecycle events for parent and child - const parentCreated = vi.fn(); - const parentRendered = vi.fn(); - const parentUpdated = vi.fn(); - const childCreated = vi.fn(); - const childRendered = vi.fn(); - const childUpdated = vi.fn(); - - // Track if parent receives any child lifecycle events (should be 0) - const parentCreatedHandler = vi.fn(); - const parentRenderedHandler = vi.fn(); - const parentUpdatedHandler = vi.fn(); - const parentDestroyedHandler = vi.fn(); - - // Define child component + let lifecycleElements = []; + + afterEach(() => { + lifecycleElements.forEach(el => { + if (el.parentNode) { + el.parentNode.removeChild(el); + } + }); + lifecycleElements = []; + }); + + /******************************* + Hook order + *******************************/ + + it('runs onCreated then onRendered in that order on first mount', async () => { + const order = []; + const tag = 'test-lc-create-render-order'; defineComponent({ - tagName: 'test-lifecycle-child-bubble', - template: '
Child Content
', - onCreated: childCreated, - onRendered: childRendered, - onUpdated: childUpdated + tagName: tag, + template: '
', + onCreated: () => order.push('created'), + onRendered: () => order.push('rendered'), }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + expect(order).toEqual(['created', 'rendered']); + }); - // Define parent component with nested child + it('does not bubble created/rendered events from a child component into a parent listener (composed:false)', async () => { + const parentTag = 'test-lc-parent-no-bubble'; + const childTag = 'test-lc-child-no-bubble'; + defineComponent({ + tagName: childTag, + template: '', + }); defineComponent({ - tagName: 'test-lifecycle-parent-bubble', - template: ` -
- Parent Content - -
- `, - onCreated: parentCreated, - onRendered: parentRendered, - onUpdated: parentUpdated + tagName: parentTag, + template: `
<${childTag}>
`, }); - // Create parent element and add lifecycle event listeners - const parentElement = document.createElement('test-lifecycle-parent-bubble'); - parentElement.addEventListener('created', parentCreatedHandler); - parentElement.addEventListener('rendered', parentRenderedHandler); - parentElement.addEventListener('updated', parentUpdatedHandler); - parentElement.addEventListener('destroyed', parentDestroyedHandler); - - // Add to DOM to trigger creation and rendering - document.body.appendChild(parentElement); - - // Wait for lifecycle events to fire - await $(parentElement).onNext('rendered'); - - // Verify each component's lifecycle callbacks fired exactly once - expect(parentCreated).toHaveBeenCalledTimes(1); - expect(parentRendered).toHaveBeenCalledTimes(1); - expect(childCreated).toHaveBeenCalledTimes(1); - expect(childRendered).toHaveBeenCalledTimes(1); - - // CRITICAL: Verify parent event listeners only received parent's own events - expect(parentCreatedHandler).toHaveBeenCalledTimes(1); - expect(parentRenderedHandler).toHaveBeenCalledTimes(1); - expect(parentUpdatedHandler).toHaveBeenCalledTimes(0); // No updates yet - expect(parentDestroyedHandler).toHaveBeenCalledTimes(0); // Not destroyed yet - - // Clean up - document.body.removeChild(parentElement); + const parentEl = document.createElement(parentTag); + const heard = vi.fn(); + parentEl.addEventListener('created', heard); + parentEl.addEventListener('rendered', heard); + const rendered = $(parentEl).onNext('rendered'); + document.body.appendChild(parentEl); + lifecycleElements.push(parentEl); + await rendered; + // The parent itself dispatches one created + one rendered to itself. + // The child's events are confined to the child element (composed:false + + // shadow boundary). A listener on parentEl should receive only the + // parent's two events. + expect(heard).toHaveBeenCalledTimes(2); }); - it('should ensure lifecycle events do not bubble when using Query library event binding', async () => { - // Import Query library for testing - const { $ } = await import('@semantic-ui/query'); + it('exposes event.detail.component on the created and rendered DOM events', async () => { + const tag = 'test-lc-event-detail-component'; + const seen = { created: null, rendered: null }; + defineComponent({ + tagName: tag, + template: '
', + createComponent: () => ({ marker: 'specific-component' }), + }); + const el = document.createElement(tag); + el.addEventListener('created', (e) => { + seen.created = e.detail.component; + }); + el.addEventListener('rendered', (e) => { + seen.rendered = e.detail.component; + }); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + expect(seen.created).toBeDefined(); + expect(seen.created.marker).toBe('specific-component'); + expect(seen.rendered).toBeDefined(); + expect(seen.rendered.marker).toBe('specific-component'); + // same instance object across both events + expect(seen.created).toBe(seen.rendered); + }); - // Track lifecycle events - const parentCreated = vi.fn(); - const parentRendered = vi.fn(); - const childCreated = vi.fn(); - const childRendered = vi.fn(); + it('fires onDestroyed and dispatches destroyed DOM event when removed from DOM', async () => { + const tag = 'test-lc-on-destroyed'; + const onDestroyed = vi.fn(); + defineComponent({ + tagName: tag, + template: '
', + onDestroyed, + }); + const el = document.createElement(tag); + const heard = vi.fn(); + el.addEventListener('destroyed', heard); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + await rendered; + document.body.removeChild(el); + // synchronous in disconnectedCallback + expect(onDestroyed).toHaveBeenCalledTimes(1); + expect(heard).toHaveBeenCalledTimes(1); + }); - // Track Query library event handlers - const queryCreatedHandler = vi.fn(); - const queryRenderedHandler = vi.fn(); - const queryUpdatedHandler = vi.fn(); - const queryDestroyedHandler = vi.fn(); + /******************************* + onUpdated (intentional silence, + F-B implementation vocab) + *******************************/ + + it('does not invoke the user-supplied onUpdated callback when the updated DOM event fires', async () => { + // F-B note: the onUpdated user callback is reached only via this.call() + // from the wrapper; the wrapper itself dispatches the 'updated' DOM + // event with triggerCallback:false. So registering onUpdated does NOT + // make it fire on every state mutation. State mutations fire the + // 'updated' DOM event but not the user callback. Pinning current + // behavior; this is part of the F-B intentional-silence surface. + const tag = 'test-lc-onupdated-not-invoked'; + const onUpdated = vi.fn(); + defineComponent({ + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, + onUpdated, + createComponent: ({ state }) => ({ + bump() { + state.count.increment(); + }, + }), + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + el.component.bump(); + await el.updateComplete; + // user callback path is not wired by the wrapper + expect(onUpdated).not.toHaveBeenCalled(); + }); - // Define child component + it('emits the updated DOM event after a state mutation that follows first render', async () => { + const tag = 'test-lc-updated-event-after-render'; defineComponent({ - tagName: 'test-query-child-component', - template: '
Query Child Content
', - onCreated: childCreated, - onRendered: childRendered + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, + createComponent: ({ state }) => ({ + bump() { + state.count.increment(); + }, + }), }); + const el = document.createElement(tag); + const heard = vi.fn(); + el.addEventListener('updated', heard); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + // first render done. mutate. Listen for the next 'updated' event; + // updateComplete cannot be polled synchronously after the mutation + // because updateScheduled is set inside the state Reaction's afterFlush + // (one microtask later), not synchronously by the mutation. + const updatedFired = $(el).onNext('updated'); + el.component.bump(); + await updatedFired; + expect(heard).toHaveBeenCalledTimes(1); + }); - // Define parent component with nested child + it('does not emit the updated DOM event on first render (per commit 5cbf23921)', async () => { + const tag = 'test-lc-no-updated-on-first-render'; defineComponent({ - tagName: 'test-query-parent-component', - template: ` -
- Query Parent Content - -
- `, - onCreated: parentCreated, - onRendered: parentRendered + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, }); + const el = document.createElement(tag); + const heard = vi.fn(); + el.addEventListener('updated', heard); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + // settle additional microtasks just in case + await Promise.resolve(); + await Promise.resolve(); + expect(heard).not.toHaveBeenCalled(); + }); - // Create parent element and add to DOM - const parentElement = document.createElement('test-query-parent-component'); - document.body.appendChild(parentElement); - - // Use Query library to bind lifecycle event listeners to parent - $('test-query-parent-component').on('created', queryCreatedHandler); - $('test-query-parent-component').on('rendered', queryRenderedHandler); - $('test-query-parent-component').on('updated', queryUpdatedHandler); - $('test-query-parent-component').on('destroyed', queryDestroyedHandler); - - // Wait for lifecycle events to fire - await new Promise(resolve => setTimeout(resolve, 100)); - - // Verify each component's lifecycle callbacks fired exactly once - expect(parentCreated).toHaveBeenCalledTimes(1); - expect(parentRendered).toHaveBeenCalledTimes(1); - expect(childCreated).toHaveBeenCalledTimes(1); - expect(childRendered).toHaveBeenCalledTimes(1); - - // CRITICAL: Verify Query library event handlers only received parent's own events - // This confirms that $('component').on('rendered', handler) only fires once - expect(queryCreatedHandler).toHaveBeenCalledTimes(1); - expect(queryRenderedHandler).toHaveBeenCalledTimes(1); - expect(queryUpdatedHandler).toHaveBeenCalledTimes(0); - expect(queryDestroyedHandler).toHaveBeenCalledTimes(0); - - // Verify event data structure from Query library - const renderedEventCall = queryRenderedHandler.mock.calls[0]; - const renderedEvent = renderedEventCall[0]; - expect(renderedEvent.type).toBe('rendered'); - expect(renderedEvent.detail).toBeDefined(); - expect(renderedEvent.detail.component).toBeDefined(); - - // Clean up - document.body.removeChild(parentElement); + it('coalesces multiple state mutations in one tick into a single updated DOM event (microtask debounce, per commit 9c8e0aee7)', async () => { + const tag = 'test-lc-updated-debounced'; + defineComponent({ + tagName: tag, + template: '{a}-{b}-{c}', + defaultState: { a: 0, b: 0, c: 0 }, + createComponent: ({ state }) => ({ + bumpAll() { + state.a.increment(); + state.b.increment(); + state.c.increment(); + }, + }), + }); + const el = document.createElement(tag); + const heard = vi.fn(); + el.addEventListener('updated', heard); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + el.component.bumpAll(); + await el.updateComplete; + // two more microtasks for the debounce settle + await Promise.resolve(); + await Promise.resolve(); + expect(heard).toHaveBeenCalledTimes(1); + }); + + /******************************* + Lifecycle promises on element + *******************************/ + + it('returns undefined for el.created and el.rendered before connectedCallback runs (no template yet)', () => { + // Before connectedCallback (i.e., before append), el.template is + // undefined. The lifecycle promise getters do `this.template?.lifecyclePromise(...)` + // so they return undefined. Pinning this behavior — it's the pre-mount + // shape callers can rely on. + const tag = 'test-lc-promise-pre-mount'; + defineComponent({ + tagName: tag, + template: '
', + }); + const el = document.createElement(tag); + lifecycleElements.push(el); // afterEach removes from DOM if attached, otherwise no-op + expect(el.created).toBeUndefined(); + expect(el.rendered).toBeUndefined(); + expect(el.destroyed).toBeUndefined(); + // updated is special: returns Promise.resolve() when no update pending. + // Without a template, updateScheduled is undefined (falsy), so the + // getter takes the resolve-immediate branch and returns Promise.resolve(). + expect(el.updated).toBeInstanceOf(Promise); + }); + + it('resolves el.updated immediately when no update is pending (Promise.resolve fast path)', async () => { + const tag = 'test-lc-updated-fastpath'; + defineComponent({ + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + // no update queued + expect(el.updateScheduled).toBeFalsy(); + const result = await Promise.race([ + el.updated.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 50)), + ]); + expect(result).toBe('resolved'); }); - }); - */ + it('resolves el.updated when an update is pending (recurring promise)', async () => { + const tag = 'test-lc-updated-pending'; + defineComponent({ + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, + createComponent: ({ state }) => ({ + bump() { + state.count.increment(); + }, + }), + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + // Use the DOM event as the synchronization point so we know an update + // was queued and processed; el.updated resolves alongside. + const updated = $(el).onNext('updated'); + el.component.bump(); + const result = await Promise.race([ + Promise.all([el.updated, updated]).then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 200)), + ]); + expect(result).toBe('resolved'); + }); + + /******************************* + B2a / B2b — expected-bug pins + *******************************/ + + /* + * B2a: el.created accessed AFTER the created event already fired, with + * no prior access to el.created, hangs forever. resolveLifecyclePromise + * no-oped (no resolver registered) and the late access lazy-creates a + * fresh resolver that nothing will call. + * + * EXPECTED-BUG-PIN — fails today, passes after the B2 fix. + */ + it('B2: el.created accessed AFTER created event already fired without prior access (expected bug pin)', async () => { + const tag = 'test-lc-b2a-late-created'; + defineComponent({ + tagName: tag, + template: '
', + }); + const el = document.createElement(tag); + // Append AND wait for rendered using a promise other than el.created/el.rendered + // so we don't accidentally pre-access the lifecycle promise. + const heardRendered = new Promise(resolve => { + el.addEventListener('rendered', resolve, { once: true }); + }); + document.body.appendChild(el); + lifecycleElements.push(el); + await heardRendered; + // 'created' event has already fired by now. Now late-access: + const result = await Promise.race([ + el.created.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 100)), + ]); + expect(result).toBe('resolved'); + }); + + /* + * B2b: el.rendered hangs during hydration because the wrapper gates the + * dispatchEvent call on !isHydrating, and dispatchEvent is what calls + * resolveLifecyclePromise. So during hydration, neither the DOM event + * nor the promise resolution fires. + * + * Driving real hydration through DSD is heavy; we exercise the path + * directly by toggling template.isHydrating and re-firing the wrapper. + * + * EXPECTED-BUG-PIN — fails today, passes after the B2 fix. + */ + it('B2: el.rendered hangs when onRendered fires during isHydrating (expected bug pin)', async () => { + const tag = 'test-lc-b2b-hydrating-rendered'; + defineComponent({ + tagName: tag, + template: '
', + }); + const el = document.createElement(tag); + const renderedEvent = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await renderedEvent; + + // simulate a hydration-suppressed second cycle: re-arm the recurring + // fresh-promise behavior is only for 'updated', so we instead exercise + // a freshly-created element + immediate hydration toggle. The cleanest + // way to pin B2b at this layer is to observe that during hydration the + // template.dispatchEvent path early-returns and so resolveLifecyclePromise + // is not called. + el.template.isHydrating = true; + // NOTE: el.rendered already resolved on the real first render — the + // cached promise is already resolved. Reset the cache to simulate the + // hydration-first-mount scenario where the promise was awaited but + // never resolved. + delete el.template.lifecyclePromises.rendered; + delete el.template.lifecycleResolvers.rendered; + const promise = el.rendered; + // re-fire the wrapper (would normally happen from setTimeout/render) + el.template.onRendered(); + const result = await Promise.race([ + promise.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 100)), + ]); + expect(result).toBe('resolved'); + }); + + /******************************* + Cleanup contract end-to-end + *******************************/ + + it('aborts the abortSignal when removed from DOM', async () => { + const tag = 'test-lc-abort-on-disconnect'; + defineComponent({ + tagName: tag, + template: '
', + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + await rendered; + const signal = el.template.abortSignal; + expect(signal.aborted).toBe(false); + document.body.removeChild(el); + expect(signal.aborted).toBe(true); + }); + }); // Test component hierarchy navigation helpers describe('Component Navigation Helpers', () => { diff --git a/packages/templating/test/_helpers/README.md b/packages/templating/test/_helpers/README.md new file mode 100644 index 000000000..8776fd411 --- /dev/null +++ b/packages/templating/test/_helpers/README.md @@ -0,0 +1,43 @@ +# Template Test Helpers + +Shared scaffolding for Template tests. Used during the Template coverage campaign (Stage 2 of `coverage-campaign` workflow). The leading underscore on the directory name signals "not a test file" — Vitest's include patterns target `**/test/{unit,dom,browser}/**/*.test.{ts,js}` and `**/test/*.test.{ts,js}`, neither of which match this directory. + +## Contents + +| File | Purpose | +|---|---| +| `stub-engine.js` | No-op rendering engine for tests that need `Template.initialize()` to succeed but don't care about renderer output. Bypasses engine registry via inline object. | +| `fresh-template.js` | `freshTemplate()` factory — `new Template({})` with sane defaults + cleanup. `subtemplateFixture()` for parent-child wired pairs. | +| `browser-fixture.js` | `mountTemplateInShadow()` — mounts a Template in a real shadow root attached to `document.body`. For events/DOM-scoping/lifecycle tests. Sidesteps WebComponentBase (which lives in the component package — circular dep). | +| `registry-cleanup.js` | `clearTemplateRegistry()` for `afterEach`. `assertRegistryEmpty()` for leak detection. | +| `dispatch.js` | Synthetic event/key dispatch helpers — `clickOn`, `fireEvent`, `fireCustomEvent`, `pressKey`, `pressKeys`, `pressKeyCombo`. | + +## When to use the stub engine + +The stub engine satisfies Template's engine contract (`setData`, `render`, `bumpDataVersion`, `buildHTMLString`, `notifyUpdate`) without actually rendering. Use it when you're testing: + +- Data context construction (Surface 6) — what gets passed to the renderer +- Callback params (Surface 3) — what gets passed to user callbacks +- Subtemplate settings Proxy (Surface 7) — proxy semantics, not rendered output +- Lifecycle hook firing (Surface 2 unit-test portion) — order and gating, not DOM updates + +Don't use the stub when you're testing: + +- Real DOM event delegation (Surface 1) — needs real shadow DOM + native renderer +- Shadow-aware queries `$`/`$$` (Surface 5) — needs real shadow DOM +- Tree traversal via DOM cascade (Surface 8) — needs real `el.shadowRoot` + nested elements + +For those, `mountTemplateInShadow` with the default stub engine works for SOME cases; for full DOM rendering, place tests in `packages/component/test/browser/` and use `defineComponent` directly so the native engine is registered. + +## Cross-package boundary + +This package can't import from `@semantic-ui/component` (circular dep). Tests requiring full WebComponentBase integration belong in `packages/component/test/browser/`. The lifecycle test set in that file's lines 461–608 (currently commented out) is the right place to revive Surface 2's full lifecycle integration tests. + +## Cleanup discipline + +Every Template that runs `onCreated` registers itself in `Template.renderedTemplates`. Tests that don't run `onDestroyed` leak entries. Either: + +- Use the cleanup function returned from `freshTemplate` / `mountTemplateInShadow`, OR +- Add `afterEach(() => clearTemplateRegistry())` to your describe block + +`assertRegistryEmpty()` at the end of a test is a useful diagnostic when chasing leaks. diff --git a/packages/templating/test/_helpers/browser-fixture.js b/packages/templating/test/_helpers/browser-fixture.js new file mode 100644 index 000000000..7ee862aee --- /dev/null +++ b/packages/templating/test/_helpers/browser-fixture.js @@ -0,0 +1,154 @@ +// Browser fixture for Template tests that need a real DOM with shadow root. +// +// Sidesteps WebComponentBase / defineComponent (which live in the component +// package — a circular dep we can't take here) by mounting a Template +// directly into a host element's shadow root. This is sufficient for +// testing Template's own contracts: +// - Events DSL (delegation, deep, global, bind keywords) +// - DOM scoping ($/$$/isNodeInTemplate) +// - Lifecycle wrappers (onCreated/onRendered/onDestroyed firing through +// Template's own dispatch path) +// - Tree traversal where the parent has an element + shadowRoot +// +// For tests that need the FULL WebComponentBase integration (custom-element +// lifecycle, attribute-changed callbacks, native settings proxy, etc.), +// place those tests in `packages/component/test/browser/` instead. The +// fixture there can use `defineComponent` directly. +// +// Usage: +// const fixture = mountTemplateInShadow({ +// template: '', +// events: { 'click .btn'({ self }) { self.clicked = true; } }, +// defaultState: { count: 0 }, +// }); +// try { +// // fixture.host is in document.body +// // fixture.shadow is the shadow root (renderRoot) +// // fixture.template is the Template instance +// // ... exercise interactions ... +// } finally { +// fixture.cleanup(); +// } + +import { Template } from '../../src/template.js'; +import { stubEngine } from './stub-engine.js'; + +/** + * Mount a Template into a fresh shadow root attached to document.body. + * Returns the host element, shadow root, and Template instance, plus a + * cleanup that tears down lifecycle and removes the host from the DOM. + * + * @param {object} [opts] + * @param {string} [opts.template] - Template HTML string + * @param {string} [opts.hostTag] - Host element tag. Default 'div'. + * @param {object} [opts.shadowMode] - Shadow root mode. Default 'open'. + * @param {object|string} [opts.renderingEngine] - Engine. Default stub. + * Pass 'native' (and ensure native engine is registered via component import) + * if you want real DOM rendering. For Template-internal contract tests, + * stub is sufficient. + * @param {object} [opts] - Any other Template options (events, keys, etc.) + * @returns {{ + * host: HTMLElement, + * shadow: ShadowRoot, + * template: Template, + * cleanup: () => void + * }} + */ +export function mountTemplateInShadow(opts = {}) { + if (typeof document === 'undefined') { + throw new Error('mountTemplateInShadow requires a DOM environment (browser/jsdom)'); + } + + const { + template: templateString = '
', + hostTag = 'div', + shadowMode = 'open', + renderingEngine = stubEngine, + ...templateOpts + } = opts; + + const host = document.createElement(hostTag); + const shadow = host.attachShadow({ mode: shadowMode }); + document.body.appendChild(host); + + const tpl = new Template({ + template: templateString, + renderingEngine, + element: host, + ...templateOpts, + }); + + // initialize must run before attach (attach calls initialize itself if + // needed, but doing it explicitly here makes the lifecycle ordering + // observable for tests) + tpl.initialize(); + tpl.attach(shadow); + + return { + host, + shadow, + template: tpl, + cleanup: () => { + try { + if (tpl.initialized && !tpl.destroyed) { + tpl.onDestroyed(); + } + } + catch (_) {} + if (host.parentNode) { + host.parentNode.removeChild(host); + } + }, + }; +} + +/** + * Same as mountTemplateInShadow but creates a parent + child Template pair + * where child is a subtemplate of parent. The parent's shadow root contains + * a region for the child to render into. + * + * Useful for Surface 8 (tree traversal) tests where DOM cascade requires + * a real shadow root with nested elements that have `.component`. + * + * @param {object} [opts] + * @returns {{ + * parentHost: HTMLElement, + * parentShadow: ShadowRoot, + * parent: Template, + * child: Template, + * cleanup: () => void + * }} + */ +export function mountSubtemplateInShadow(opts = {}) { + const { parentTemplate, childTemplate, childDefaultSettings, ...rest } = opts; + + const parentFixture = mountTemplateInShadow({ + template: parentTemplate || '
', + ...rest, + }); + + const child = new Template({ + template: childTemplate || '', + defaultSettings: childDefaultSettings, + renderingEngine: stubEngine, + element: parentFixture.host, + }); + child.setParent(parentFixture.template); + child.initialize(); + + return { + parentHost: parentFixture.host, + parentShadow: parentFixture.shadow, + parent: parentFixture.template, + child, + cleanup: () => { + try { + if (child.initialized && !child.destroyed) { + child.onDestroyed(); + } + } + catch (_) {} + parentFixture.cleanup(); + }, + }; +} diff --git a/packages/templating/test/_helpers/dispatch.js b/packages/templating/test/_helpers/dispatch.js new file mode 100644 index 000000000..7225065bc --- /dev/null +++ b/packages/templating/test/_helpers/dispatch.js @@ -0,0 +1,122 @@ +// Synthetic event/key dispatch helpers for Template tests. +// +// Used by Surfaces 1 (events), 3 (callback params event-extras), 4 (keys). +// The DOM standard `dispatchEvent` requires a real Event constructor; these +// helpers wrap that with a friendlier API for the kinds of dispatches our +// tests do (click, custom event with detail, key sequence, etc.). +// +// Note: for testing through Template's event delegation, dispatch on the +// SHADOW DOM target (e.g., `shadow.querySelector('.btn').dispatchEvent(...)`), +// not on the host. Template's `attachEvents` listens on renderRoot. + +if (typeof document === 'undefined') { + // module is allowed to load in node — only the dispatch functions + // require DOM. Tests that import these in node context should not call + // them. +} + +/** + * Dispatch a click event on an element. Bubbles + composed by default so + * it crosses shadow boundaries (matches real user clicks). + */ +export function clickOn(element, init = {}) { + element.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + composed: true, + cancelable: true, + ...init, + }), + ); +} + +/** + * Dispatch a generic event by name on an element. + */ +export function fireEvent(element, eventName, init = {}) { + element.dispatchEvent( + new Event(eventName, { + bubbles: true, + composed: true, + cancelable: true, + ...init, + }), + ); +} + +/** + * Dispatch a custom event with detail. + */ +export function fireCustomEvent(element, eventName, detail = {}, init = {}) { + element.dispatchEvent( + new CustomEvent(eventName, { + bubbles: true, + composed: true, + cancelable: true, + detail, + ...init, + }), + ); +} + +/** + * Dispatch a single keydown (and matching keyup) on document. Mirrors how + * real keyboard events flow. Use for testing Template's `keys` bindings, + * which listen on `document`. + * + * @param {string} key - The key, e.g., 'Enter', 'a', 'ArrowDown' + * @param {object} [init] - Additional KeyboardEvent properties (ctrlKey, etc.) + */ +export function pressKey(key, init = {}) { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + composed: true, + cancelable: true, + ...init, + }), + ); + document.dispatchEvent( + new KeyboardEvent('keyup', { + key, + bubbles: true, + composed: true, + cancelable: true, + ...init, + }), + ); +} + +/** + * Dispatch a sequence of keys with optional delay between them. + * For testing `keys: { 'g i': handler }` sequence semantics. + * + * @param {string[]} keys - Array of key names + * @param {object} [opts] + * @param {number} [opts.delayMs] - Delay between keys (real time, not fake-timer-aware) + */ +export async function pressKeys(keys, { delayMs = 0 } = {}) { + for (const key of keys) { + pressKey(key); + if (delayMs > 0) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } +} + +/** + * Build a KeyboardEvent for a modifier combination. Useful for testing + * `keys: { 'ctrl + f': handler }`. + */ +export function pressKeyCombo(key, modifiers = {}) { + const init = { + key, + ctrlKey: modifiers.ctrl || modifiers.ctrlKey || false, + shiftKey: modifiers.shift || modifiers.shiftKey || false, + altKey: modifiers.alt || modifiers.altKey || false, + metaKey: modifiers.meta || modifiers.metaKey || false, + }; + document.dispatchEvent(new KeyboardEvent('keydown', { ...init, bubbles: true, composed: true, cancelable: true })); + document.dispatchEvent(new KeyboardEvent('keyup', { ...init, bubbles: true, composed: true, cancelable: true })); +} diff --git a/packages/templating/test/_helpers/fresh-template.js b/packages/templating/test/_helpers/fresh-template.js new file mode 100644 index 000000000..2c8ed3633 --- /dev/null +++ b/packages/templating/test/_helpers/fresh-template.js @@ -0,0 +1,124 @@ +// Fresh-Template fixture for Template tests. +// +// Creates a Template with sane defaults (stub engine, simple template string, +// empty options) and returns both the template and a cleanup function. The +// cleanup is critical — Template registers itself in `Template.renderedTemplates` +// when `onCreated` fires, and tests must clear that to avoid bleeding into +// subsequent tests. +// +// Usage: +// const { template, cleanup } = freshTemplate({ defaultState: { count: 0 } }); +// try { +// // ... exercise the template ... +// } finally { +// cleanup(); +// } +// +// Or with afterEach: +// let template, cleanup; +// afterEach(() => cleanup?.()); +// it('...', () => { +// ({ template, cleanup } = freshTemplate()); +// // ... +// }); + +import { Template } from '../../src/template.js'; +import { stubEngine } from './stub-engine.js'; + +const DEFAULT_TEMPLATE_STRING = '
'; + +/** + * Create a fresh Template with stub engine and reasonable defaults. + * + * @param {object} [opts] + * @param {string} [opts.template] - Template string. Default: '
' + * @param {object} [opts.defaultState] - defaultState passed to Template + * @param {object} [opts.defaultSettings] - defaultSettings (subtemplate-style) + * @param {object} [opts.data] - initial data context + * @param {Function} [opts.createComponent] - createComponent factory + * @param {object} [opts.events] - events DSL object + * @param {object} [opts.keys] - keys binding object + * @param {object} [opts.subTemplates] - { name: Template } + * @param {Template} [opts.parentTemplate] - parent (for subtemplate fixtures) + * @param {Element} [opts.element] - DOM element (browser/jsdom only) + * @param {Element|ShadowRoot} [opts.renderRoot] - render root (browser only) + * @param {string} [opts.templateName] - explicit template name + * @param {Function} [opts.onCreated] - lifecycle hook + * @param {Function} [opts.onRendered] - lifecycle hook + * @param {Function} [opts.onDestroyed] - lifecycle hook + * @param {Function} [opts.onThemeChanged] - lifecycle hook + * @param {object|string} [opts.renderingEngine] - override stub engine + * @returns {{ template: Template, cleanup: () => void }} + */ +export function freshTemplate(opts = {}) { + const { + template = DEFAULT_TEMPLATE_STRING, + renderingEngine = stubEngine, + ...rest + } = opts; + + const tpl = new Template({ + template, + renderingEngine, + ...rest, + }); + + return { + template: tpl, + cleanup: () => { + try { + if (tpl.initialized && !tpl.destroyed) { + tpl.onDestroyed(); + } + } + catch (e) { + // teardown order may not be perfectly idempotent in all states; + // swallow so cleanup always succeeds even if test left things partial + } + }, + }; +} + +/** + * Create a parent + child pair wired via setParent. The parent has a fake + * element with a settings object (for subtemplate parent-fallback tests). + * Returns both Templates and a single cleanup that tears down both. + * + * @param {object} [opts] + * @param {object} [opts.parentSettings] - settings on the parent's fake element + * @param {object} [opts.childDefaultSettings] - defaultSettings on the subtemplate + * @param {object} [opts.childData] - data passed to the subtemplate (parent props) + * @returns {{ parent: Template, child: Template, cleanup: () => void }} + */ +export function subtemplateFixture(opts = {}) { + const { parentSettings = {}, childDefaultSettings, childData = {} } = opts; + + // Fake element for the parent — provides .settings for the subtemplate's + // parent-fallback Proxy (template.js:954). Real components have this set + // up via WebComponentBase; in unit tests we stub it directly. + const fakeElement = { + settings: { ...parentSettings }, + }; + + const { template: parent, cleanup: cleanupParent } = freshTemplate({ + template: '
', + element: fakeElement, + }); + + const { template: child, cleanup: cleanupChild } = freshTemplate({ + template: '', + defaultSettings: childDefaultSettings, + data: childData, + }); + + child.setParent(parent); + + return { + parent, + child, + cleanup: () => { + cleanupChild(); + cleanupParent(); + }, + }; +} diff --git a/packages/templating/test/_helpers/registry-cleanup.js b/packages/templating/test/_helpers/registry-cleanup.js new file mode 100644 index 000000000..8e292bc84 --- /dev/null +++ b/packages/templating/test/_helpers/registry-cleanup.js @@ -0,0 +1,61 @@ +// Registry cleanup for Template tests. +// +// `Template.renderedTemplates` is a static Map keyed by `templateName`. Any +// Template that runs `onCreated` adds itself; `onDestroyed` removes itself. +// Tests that don't run a complete lifecycle (e.g., assertion-only tests, or +// tests that throw mid-flow) leak registry entries into subsequent tests. +// +// `Template.templateCount` is the auto-name counter for anonymous templates. +// Tests that assert specific anonymous names (e.g., "Anonymous #3") must +// reset this between runs. +// +// Usage: +// import { clearTemplateRegistry } from '../_helpers/registry-cleanup.js'; +// afterEach(() => clearTemplateRegistry()); + +import { Template } from '../../src/template.js'; + +/** + * Clear the global Template registry and anonymous-name counter. + * Safe to call in afterEach hooks. + */ +export function clearTemplateRegistry() { + Template.renderedTemplates.clear(); + Template.templateCount = 0; +} + +/** + * Snapshot the registry state. Useful for tests that want to assert + * registry size/contents at multiple points. + * + * @returns {{ size: number, names: string[], counts: object }} + */ +export function snapshotRegistry() { + const names = [...Template.renderedTemplates.keys()]; + const counts = {}; + for (const name of names) { + counts[name] = Template.renderedTemplates.get(name).length; + } + return { + size: names.length, + names, + counts, + totalInstances: Object.values(counts).reduce((s, n) => s + n, 0), + }; +} + +/** + * Assert the registry is empty. Throws if not. Useful as a test-end check + * to catch leaks immediately rather than letting them surface in the next + * test's assertions. + */ +export function assertRegistryEmpty() { + const snap = snapshotRegistry(); + if (snap.totalInstances !== 0) { + throw new Error( + `Template registry leaked: ${snap.totalInstances} instances across ${snap.size} names. ` + + `Names: ${JSON.stringify(snap.counts)}. ` + + `Did onDestroyed fire for all created Templates?`, + ); + } +} diff --git a/packages/templating/test/_helpers/stub-engine.js b/packages/templating/test/_helpers/stub-engine.js new file mode 100644 index 000000000..7f8d37afc --- /dev/null +++ b/packages/templating/test/_helpers/stub-engine.js @@ -0,0 +1,82 @@ +// Stub rendering engine for Template tests that don't need real DOM +// rendering. The Template's `initialize()` requires an engine to instantiate +// the Renderer class. Tests targeting Template's internal contracts (data +// context construction, callback params, lifecycle hook firing, etc.) +// don't need to verify renderer output — they just need the engine to +// satisfy the construction contract. +// +// Pass via `new Template({ renderingEngine: stubEngine, ... })`. Template's +// engine resolution (template.js:271-273) accepts an inline object, +// bypassing the registry entirely — no global state changes per test. + +class StubRenderer { + constructor({ ast, data, template, subTemplates, helpers, receivesData }) { + this.ast = ast; + this.data = data; + this.template = template; + this.subTemplates = subTemplates; + this.helpers = helpers; + this.receivesData = receivesData; + this.dataVersion = 0; + this.renderCalls = 0; + this.setDataCalls = 0; + this.bumpDataVersionCalls = 0; + } + + setData(data) { + this.data = data; + this.setDataCalls++; + } + + render() { + this.renderCalls++; + // Return a no-op DocumentFragment if document is available (browser/jsdom), + // otherwise an empty object that satisfies the contract for node tests. + if (typeof document !== 'undefined') { + return document.createDocumentFragment(); + } + return { childNodes: [] }; + } + + bumpDataVersion() { + this.dataVersion++; + this.bumpDataVersionCalls++; + } + + buildHTMLString() { + return { entries: [], html: '' }; + } + + notifyUpdate() { + // no-op — Template's update reaction calls this; stub records nothing + } + + hydrateMarkers() { + // no-op — hydration path + } +} + +class StubServerRenderer extends StubRenderer { + // Server renderer is a separate class but the contract overlaps for tests + // that only need the engine to resolve and the renderer to instantiate. +} + +export const stubEngine = { + renderer: StubRenderer, + serverRenderer: StubServerRenderer, +}; + +// Convenience factory for tests that want a fresh stub renderer reference +// to assert against (e.g., "render was called once after first attach"). +export function makeStubEngine() { + // Returns a fresh engine object so multiple Templates in one test get + // independent renderer instances. Call counts are per-instance, not shared. + return { + renderer: StubRenderer, + serverRenderer: StubServerRenderer, + }; +} + +// Re-export the renderer classes for tests that want to introspect call +// counts after an action. +export { StubRenderer, StubServerRenderer }; diff --git a/packages/templating/test/browser/callback-params.test.js b/packages/templating/test/browser/callback-params.test.js new file mode 100644 index 000000000..9dcaad018 --- /dev/null +++ b/packages/templating/test/browser/callback-params.test.js @@ -0,0 +1,884 @@ +// Surface 3 — Callback params. +// +// Pins the destructurable namespace handed to every consumer-supplied +// callback (lifecycle hooks, event handlers, key handlers, createComponent). +// Source: `packages/templating/src/template.js`, `buildCallParams` (815–862), +// `call` (791–812), `createInterval`/`createTimeout` (988–998), `reaction`/ +// `signal` (1006–1012). +// +// Scope: +// - Identity, reactive layers, helpers, timers, lifecycle helpers, +// state helpers, DOM helpers, tree helpers, misc. +// - Event-callback extras (event/target/value/data/isDeep) — pin C1. +// - Key-callback extras (event/inputFocused/repeatedKey) — pin C2. +// - isPrototype short-circuit; this-binding contrast; cached callParams. +// +// B7 fix-before-merge: `value` resolution (line 572) uses `||` not `??` — +// three pinned cases (empty string, numeric 0, custom-event detail.value === 0) +// EXPECTED-TO-FAIL against current source. + +import { Reaction, Signal } from '@semantic-ui/reactivity'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TemplateHelpers } from '../../src/template-helpers.js'; +import { Template } from '../../src/template.js'; +import { mountTemplateInShadow } from '../_helpers/browser-fixture.js'; +import { clickOn, fireCustomEvent, fireEvent, pressKey } from '../_helpers/dispatch.js'; +import { freshTemplate } from '../_helpers/fresh-template.js'; +import { clearTemplateRegistry } from '../_helpers/registry-cleanup.js'; +import { stubEngine } from '../_helpers/stub-engine.js'; + +afterEach(() => { + clearTemplateRegistry(); + document.body.innerHTML = ''; +}); + +/** + * Capture the callParams object handed to `onCreated`. Returns a + * reference to that object plus the fixture's cleanup — the test owns + * teardown. + */ +function captureCreatedParams(opts = {}) { + let captured; + const fixture = mountTemplateInShadow({ + onCreated(params) { + captured = params; + }, + ...opts, + }); + return { params: captured, fixture }; +} + +/******************************* + Identity Params +*******************************/ + +describe('callback params — identity (el/self/tpl/component/template/templateName/templates)', () => { + it('exposes el as the host (component) DOM element', () => { + const { params, fixture } = captureCreatedParams(); + expect(params.el).toBe(fixture.host); + fixture.cleanup(); + }); + + it('exposes self, tpl, component as the SAME instance reference', () => { + let captured; + const fixture = mountTemplateInShadow({ + createComponent() { + return { foo: 'bar' }; + }, + onCreated(params) { + captured = params; + }, + }); + expect(captured.self).toBe(fixture.template.instance); + expect(captured.tpl).toBe(fixture.template.instance); + expect(captured.component).toBe(fixture.template.instance); + expect(captured.self).toBe(captured.tpl); + expect(captured.tpl).toBe(captured.component); + expect(captured.self.foo).toBe('bar'); + fixture.cleanup(); + }); + + it('exposes template as `this` (the Template instance)', () => { + const { params, fixture } = captureCreatedParams(); + expect(params.template).toBe(fixture.template); + fixture.cleanup(); + }); + + it('exposes templateName matching the Template`s name', () => { + const { params, fixture } = captureCreatedParams({ templateName: 'CustomName' }); + expect(params.templateName).toBe('CustomName'); + fixture.cleanup(); + }); + + it('exposes templates as the global Template.renderedTemplates registry', () => { + const { params, fixture } = captureCreatedParams(); + expect(params.templates).toBe(Template.renderedTemplates); + fixture.cleanup(); + }); +}); + +/******************************* + Reactive Layers +*******************************/ + +describe('callback params — reactive layers (data/settings/state)', () => { + it('exposes data as the live data object on the Template (not a copy)', () => { + const initial = { foo: 1 }; + const { params, fixture } = captureCreatedParams({ data: initial }); + expect(params.data).toBe(fixture.template.data); + fixture.cleanup(); + }); + + it('exposes settings (live), falling back to element.settings if no own settings', () => { + const { params, fixture } = captureCreatedParams(); + // Without own settings, falls through to element?.settings + expect(params.settings).toBe(fixture.template.settings || fixture.host.settings); + fixture.cleanup(); + }); + + it('exposes state as the reactive state object', () => { + const { params, fixture } = captureCreatedParams({ defaultState: { count: 0 } }); + expect(params.state).toBe(fixture.template.state); + expect(params.state.count).toBeInstanceOf(Signal); + expect(params.state.count.get()).toBe(0); + fixture.cleanup(); + }); + + it('mutating state through params.state.count.set(...) propagates reactively', () => { + const { params, fixture } = captureCreatedParams({ defaultState: { count: 0 } }); + let observed; + Reaction.create(() => { + observed = params.state.count.get(); + }); + params.state.count.set(7); + Reaction.flush(); + expect(observed).toBe(7); + fixture.cleanup(); + }); +}); + +/******************************* + Reactivity Helpers +*******************************/ + +describe('callback params — reactivity helpers (signal/reaction/flush/afterFlush/nonreactive)', () => { + it('signal() creates a Signal', () => { + const { params, fixture } = captureCreatedParams(); + const s = params.signal(5); + expect(s).toBeInstanceOf(Signal); + expect(s.get()).toBe(5); + fixture.cleanup(); + }); + + it('reaction() registers into template.reactions and is cleared at destroy', () => { + const { params, fixture } = captureCreatedParams(); + let runs = 0; + const sig = new Signal(0); + const beforeCount = fixture.template.reactions.length; + params.reaction(() => { + sig.get(); + runs++; + }); + Reaction.flush(); + expect(runs).toBe(1); + expect(fixture.template.reactions.length).toBe(beforeCount + 1); + // mutation triggers + sig.set(1); + Reaction.flush(); + expect(runs).toBe(2); + // destroy stops the reaction + fixture.cleanup(); + sig.set(2); + Reaction.flush(); + expect(runs).toBe(2); // no new fires after destroy + }); + + it('flush, afterFlush, nonreactive are static Reaction helpers', () => { + const { params, fixture } = captureCreatedParams(); + expect(params.flush).toBe(Reaction.flush); + expect(params.afterFlush).toBe(Reaction.afterFlush); + expect(params.nonreactive).toBe(Reaction.nonreactive); + fixture.cleanup(); + }); +}); + +/******************************* + Auto-cleanup Timers +*******************************/ + +describe('callback params — auto-cleanup timers (interval/timeout)', () => { + it('interval() returns id and fires repeatedly until destroy', async () => { + const { params, fixture } = captureCreatedParams(); + let fires = 0; + const id = params.interval(() => { + fires++; + }, 5); + expect(typeof id === 'number' || typeof id === 'object').toBe(true); + // wait long enough for at least 2 intervals + await new Promise(r => setTimeout(r, 30)); + expect(fires).toBeGreaterThanOrEqual(2); + const fireCountBeforeDestroy = fires; + fixture.cleanup(); + await new Promise(r => setTimeout(r, 30)); + // After destroy, abortController.abort() ran → clearInterval fired + expect(fires).toBe(fireCountBeforeDestroy); + }); + + it('timeout() schedules a single fire and is canceled when destroy precedes', async () => { + const { params, fixture } = captureCreatedParams(); + let fired = false; + params.timeout(() => { + fired = true; + }, 30); + fixture.cleanup(); + await new Promise(r => setTimeout(r, 60)); + expect(fired).toBe(false); + }); + + it('timeout() fires when not canceled before its delay', async () => { + const { params, fixture } = captureCreatedParams(); + let fired = false; + params.timeout(() => { + fired = true; + }, 5); + await new Promise(r => setTimeout(r, 30)); + expect(fired).toBe(true); + fixture.cleanup(); + }); +}); + +/******************************* + Lifecycle Helpers +*******************************/ + +describe('callback params — lifecycle helpers (dispatchEvent/attachEvent/bindKey/unbindKey)', () => { + it('dispatchEvent, attachEvent, bindKey, unbindKey are bound to the Template', () => { + const { params, fixture } = captureCreatedParams(); + // Each is a function (bound copy); calling them via params goes through + // the Template's `this` regardless of caller context. + expect(typeof params.dispatchEvent).toBe('function'); + expect(typeof params.attachEvent).toBe('function'); + expect(typeof params.bindKey).toBe('function'); + expect(typeof params.unbindKey).toBe('function'); + fixture.cleanup(); + }); + + it('bindKey adds to template.keys; unbindKey removes', () => { + const { params, fixture } = captureCreatedParams(); + const handler = () => {}; + params.bindKey('q', handler); + expect(fixture.template.keys.q).toBe(handler); + params.unbindKey('q'); + expect(fixture.template.keys.q).toBeUndefined(); + fixture.cleanup(); + }); +}); + +/******************************* + State Helpers +*******************************/ + +describe('callback params — state helpers (isRendered/isServer/isClient/darkMode/isHydrating)', () => { + it('isRendered() returns the Template`s rendered flag', () => { + const { params, fixture } = captureCreatedParams(); + // initialize() doesn't mark rendered yet; render() does. + expect(params.isRendered()).toBe(false); + fixture.template.markRendered(); + expect(params.isRendered()).toBe(true); + fixture.cleanup(); + }); + + it('isServer is false in the browser env, isClient is true', () => { + const { params, fixture } = captureCreatedParams(); + expect(params.isServer).toBe(false); + expect(params.isClient).toBe(true); + fixture.cleanup(); + }); + + it('darkMode is a getter — calls element.isDarkMode() lazily on each access', () => { + let calls = 0; + let returnValue = false; + const fixture = mountTemplateInShadow({}); + fixture.host.isDarkMode = () => { + calls++; + return returnValue; + }; + // Re-read params.darkMode multiple times → calls increments + const params = fixture.template.callParams; + expect(params.darkMode).toBe(false); + expect(calls).toBe(1); + returnValue = true; + expect(params.darkMode).toBe(true); + expect(calls).toBe(2); + fixture.cleanup(); + }); + + it('isHydrating is a getter — tracks template.isHydrating live', () => { + const fixture = mountTemplateInShadow({}); + const params = fixture.template.callParams; + expect(params.isHydrating).toBe(false); + fixture.template.isHydrating = true; + expect(params.isHydrating).toBe(true); + fixture.template.isHydrating = false; + expect(params.isHydrating).toBe(false); + fixture.cleanup(); + }); +}); + +/******************************* + DOM Helpers +*******************************/ + +describe('callback params — DOM helpers ($/$$)', () => { + it('$ and $$ are present and callable (bound)', () => { + const { params, fixture } = captureCreatedParams(); + expect(typeof params.$).toBe('function'); + expect(typeof params.$$).toBe('function'); + // callable without TypeError (semantics owned by Surface 5) + expect(() => params.$('div')).not.toThrow(); + expect(() => params.$$('div')).not.toThrow(); + fixture.cleanup(); + }); +}); + +/******************************* + Tree Helpers +*******************************/ + +describe('callback params — tree helpers (findTemplate/findParent/findChild/findChildren)', () => { + it('exposes findTemplate, findParent, findChild, findChildren as functions', () => { + const { params, fixture } = captureCreatedParams(); + expect(typeof params.findTemplate).toBe('function'); + expect(typeof params.findParent).toBe('function'); + expect(typeof params.findChild).toBe('function'); + expect(typeof params.findChildren).toBe('function'); + fixture.cleanup(); + }); +}); + +/******************************* + Misc Params +*******************************/ + +describe('callback params — misc (helpers/content/abortController/abortSignal/rerender)', () => { + it('exposes helpers as TemplateHelpers', () => { + const { params, fixture } = captureCreatedParams(); + expect(params.helpers).toBe(TemplateHelpers); + fixture.cleanup(); + }); + + it('exposes content as this.instance.content', () => { + let captured; + const fixture = mountTemplateInShadow({ + createComponent() { + return { content: 'hello' }; + }, + onCreated(p) { + captured = p; + }, + }); + expect(captured.content).toBe('hello'); + fixture.cleanup(); + }); + + it('exposes abortController and abortSignal — same controller pair as the Template', () => { + const { params, fixture } = captureCreatedParams(); + expect(params.abortController).toBe(fixture.template.abortController); + expect(params.abortSignal).toBe(fixture.template.abortSignal); + expect(params.abortController.signal).toBe(params.abortSignal); + fixture.cleanup(); + }); + + it('rerender() calls element.requestUpdate() if defined', () => { + let calls = 0; + const fixture = mountTemplateInShadow({}); + fixture.host.requestUpdate = () => { + calls++; + }; + fixture.template.callParams.rerender(); + expect(calls).toBe(1); + fixture.cleanup(); + }); + + it('rerender() is safe when element is undefined (no-op via optional chaining)', () => { + // Construct a Template with no element; rerender() should noop. + const { template, cleanup } = freshTemplate(); + template.initialize(); + expect(() => template.callParams.rerender()).not.toThrow(); + cleanup(); + }); +}); + +/******************************* + Source-delivered, doc-omitted +*******************************/ + +describe('callback params — source-delivered, doc-omitted (unbindKey/abortController/content/isHydrating)', () => { + it('exposes unbindKey (omitted from Standard Arguments table but delivered)', () => { + const { params, fixture } = captureCreatedParams(); + expect(typeof params.unbindKey).toBe('function'); + fixture.cleanup(); + }); + + it('exposes abortController (only abortSignal listed in doc)', () => { + const { params, fixture } = captureCreatedParams(); + expect(params.abortController).toBeInstanceOf(AbortController); + fixture.cleanup(); + }); + + it('exposes content (undocumented)', () => { + const { params, fixture } = captureCreatedParams({ + createComponent() { + return { content: 'X' }; + }, + }); + // params variable was captured via onCreated; re-grab from cached + expect(fixture.template.callParams.content).toBe('X'); + fixture.cleanup(); + }); + + it('exposes isHydrating (undocumented in Standard Arguments table)', () => { + const fixture = mountTemplateInShadow({}); + const desc = Object.getOwnPropertyDescriptor(fixture.template.callParams, 'isHydrating'); + // It's a getter, so descriptor has a `get`, not `value` + expect(typeof desc.get).toBe('function'); + fixture.cleanup(); + }); +}); + +/******************************* + Event Callback Extras (C1 + B7) +*******************************/ + +/** + * Mount a template fixture and manually inject HTML into the shadow root + * (the stub engine returns an empty fragment, so we populate the shadow + * ourselves and rely on Template.attachEvents()'s delegated listeners on + * the renderRoot). Then exercise the event handler closure that builds + * the additionalData params. + */ +function mountForEvents({ shadowHTML, events }) { + const fixture = mountTemplateInShadow({ template: '
', events }); + fixture.shadow.innerHTML = shadowHTML; + return fixture; +} + +describe('event callback extras — event, target, value, data, isDeep', () => { + it('passes event (raw DOM event) and target (matched element)', () => { + let captured; + const fixture = mountForEvents({ + shadowHTML: '', + events: { + 'click .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.shadow.querySelector('.btn'); + clickOn(btn); + expect(captured).toBeDefined(); + expect(captured.event).toBeInstanceOf(Event); + expect(captured.target).toBe(btn); + fixture.cleanup(); + }); + + it('C1 — `el` remains the component element; `target` is the dispatching element (NOT same as `el`)', () => { + let captured; + const fixture = mountForEvents({ + shadowHTML: '', + events: { + 'click .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.shadow.querySelector('.btn'); + clickOn(btn); + expect(captured.el).toBe(fixture.host); + expect(captured.target).toBe(btn); + expect(captured.el).not.toBe(captured.target); + fixture.cleanup(); + }); + + it('passes value resolved from target.value (input element)', () => { + let captured; + const fixture = mountForEvents({ + shadowHTML: '', + events: { + 'input .i'(params) { + captured = params; + }, + }, + }); + const input = fixture.shadow.querySelector('.i'); + input.value = 'hello'; + fireEvent(input, 'input'); + expect(captured.value).toBe('hello'); + fixture.cleanup(); + }); + + it('passes data merging dataset (JSON-parsed) and event.detail', () => { + let captured; + const fixture = mountForEvents({ + // data-amount="42" → JSON.parse → 42 (number) + // data-name="alpha" → JSON.parse fails → falls through as string "alpha" + shadowHTML: '', + events: { + 'tap .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.shadow.querySelector('.btn'); + fireCustomEvent(btn, 'tap', { extra: 'detail-key', amount: 99 }); + expect(captured).toBeDefined(); + // dataset numeric IS JSON-parsed (number 42), then detail.amount wins → 99 + expect(captured.data.amount).toBe(99); + // data-name fails JSON.parse → kept as raw string "alpha" + expect(captured.data.name).toBe('alpha'); + expect(captured.data.extra).toBe('detail-key'); + fixture.cleanup(); + }); + + it('dataset values are JSON-parsed when valid JSON, raw string otherwise', () => { + let captured; + const fixture = mountForEvents({ + shadowHTML: '', + events: { + 'click .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.shadow.querySelector('.btn'); + clickOn(btn); + expect(captured.data.num).toBe(7); + expect(captured.data.bool).toBe(true); + expect(captured.data.str).toBe('raw'); // 'raw' isn't valid JSON + expect(captured.data.obj).toEqual({ k: 1 }); + fixture.cleanup(); + }); + + it('isDeep is false when target is matched directly by the selector', () => { + let captured; + const fixture = mountForEvents({ + shadowHTML: '
', + events: { + 'click .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.shadow.querySelector('.btn'); + clickOn(btn); + expect(captured).toBeDefined(); + expect(captured.isDeep).toBe(false); + fixture.cleanup(); + }); + + // ─── B7 EXPECTED-BUG-PINS ───────────────────────────────────────── + // Source line 572: `targetElement?.value || event.target?.value || event?.detail?.value` + // uses `||`, so falsy-but-real values can fall through. + // + // Trace of the `||` chain reveals only ONE case actually fails today: + // - Empty : `'' || '' || undefined` → `undefined` ← FAIL + // - : `'0' || '0' || undefined` → `'0'` ← passes + // - detail.value=0: `undef || undef || 0` → `0` ← passes (last operand) + // - detail.value='': `undef || undef || ''` → `''` ← passes (last operand) + // + // We pin all four assertions for the post-fix contract: under `??`, all + // four return the underlying falsy-but-real value. Currently only the + // empty-input case fails (the others happen to pass because `||` returns + // the LAST operand when nothing is truthy). After fixing to `??`, the + // intent is preserved for all four; pin all four so a future regression + // (e.g. someone refactoring the chain) can't silently re-introduce. + + it('B7 — empty-string preserves `value: ""` (NOT undefined) [EXPECTED FAIL]', () => { + let captured; + const fixture = mountForEvents({ + shadowHTML: '', + events: { + 'input .i'(params) { + captured = params; + }, + }, + }); + const input = fixture.shadow.querySelector('.i'); + input.value = ''; + fireEvent(input, 'input'); + expect(captured.value).toBe(''); + fixture.cleanup(); + }); + + it('B7 — numeric with value "0" preserves `value: "0"` [PASSES under both ||/??]', () => { + let captured; + const fixture = mountForEvents({ + shadowHTML: '', + events: { + 'input .n'(params) { + captured = params; + }, + }, + }); + const input = fixture.shadow.querySelector('.n'); + input.value = '0'; + fireEvent(input, 'input'); + // .value reads as string "0" (truthy), so this passes under either + // operator. Pinning so a refactor that reads valueAsNumber doesn't + // silently change behavior. + expect(captured.value).toBe('0'); + fixture.cleanup(); + }); + + it('B7 — custom event with detail.value === 0 preserves `value: 0` [PASSES under ||, last operand]', () => { + let captured; + const fixture = mountForEvents({ + shadowHTML: 'x', + events: { + 'change .x'(params) { + captured = params; + }, + }, + }); + const span = fixture.shadow.querySelector('.x'); + fireCustomEvent(span, 'change', { value: 0 }); + expect(captured.value).toBe(0); + fixture.cleanup(); + }); + + it('B7 — custom event with detail.value === "" preserves `value: ""` [PASSES under ||, last operand]', () => { + let captured; + const fixture = mountForEvents({ + shadowHTML: 'x', + events: { + 'change .x'(params) { + captured = params; + }, + }, + }); + const span = fixture.shadow.querySelector('.x'); + fireCustomEvent(span, 'change', { value: '' }); + expect(captured.value).toBe(''); + fixture.cleanup(); + }); +}); + +/******************************* + Key Callback Extras (C2) +*******************************/ + +describe('key callback extras — event, inputFocused, repeatedKey', () => { + it('C2 — event (raw KeyboardEvent) IS delivered to key callbacks', () => { + let captured; + const fixture = mountTemplateInShadow({ + keys: { + a(params) { + captured = params; + }, + }, + }); + pressKey('a'); + expect(captured).toBeDefined(); + expect(captured.event).toBeInstanceOf(KeyboardEvent); + expect(captured.event.key).toBe('a'); + fixture.cleanup(); + }); + + it('inputFocused is true when an is focused', () => { + let captured; + const input = document.createElement('input'); + document.body.appendChild(input); + const fixture = mountTemplateInShadow({ + keys: { + a(params) { + captured = params; + }, + }, + }); + input.focus(); + pressKey('a'); + expect(captured).toBeDefined(); + expect(captured.inputFocused).toBeTruthy(); + fixture.cleanup(); + input.remove(); + }); + + it('inputFocused is false/falsy when no input is focused', () => { + let captured; + const fixture = mountTemplateInShadow({ + keys: { + b(params) { + captured = params; + }, + }, + }); + document.body.focus(); + pressKey('b'); + expect(captured).toBeDefined(); + // body has no tagName matching, no contentEditable + expect(Boolean(captured.inputFocused)).toBe(false); + fixture.cleanup(); + }); + + it('repeatedKey is true when same key fires consecutively without keyup interleaving', () => { + const captures = []; + const fixture = mountTemplateInShadow({ + keys: { + c(params) { + captures.push(params.repeatedKey); + }, + }, + }); + // First press: currentKey is '' so c != '', repeatedKey false + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })); + // Second press WITHOUT keyup: repeatedKey === true (currentKey === 'c') + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })); + expect(captures.length).toBe(2); + expect(captures[0]).toBe(false); + expect(captures[1]).toBe(true); + fixture.cleanup(); + }); +}); + +/******************************* + isPrototype short-circuit +*******************************/ + +describe('call() — isPrototype short-circuit', () => { + it('returns undefined and does NOT fire callback when isPrototype is true', () => { + let fired = false; + const tpl = new Template({ + template: '
', + renderingEngine: stubEngine, + isPrototype: true, + }); + // Don't initialize() — prototype templates are not initialized; just exercise call(). + const result = tpl.call(() => { + fired = true; + }); + expect(result).toBeUndefined(); + expect(fired).toBe(false); + }); + + it('cloned (non-prototype) Template DOES fire callbacks via call()', () => { + const proto = new Template({ + template: '
', + renderingEngine: stubEngine, + isPrototype: true, + }); + const cloned = proto.clone({ isPrototype: false }); + cloned.initialize(); // populates callParams, etc. + let fired = false; + cloned.call(() => { + fired = true; + }); + expect(fired).toBe(true); + cloned.onDestroyed(); + }); + + it('returns undefined for non-function func (tolerant)', () => { + const { template, cleanup } = freshTemplate(); + template.initialize(); + expect(template.call(undefined)).toBeUndefined(); + expect(template.call(null)).toBeUndefined(); + expect(template.call('not a function')).toBeUndefined(); + cleanup(); + }); +}); + +/******************************* + this-binding contrast +*******************************/ + +describe('call() — this-binding inside callbacks', () => { + it('createComponent runs with this === instance (the createComponent return)', () => { + let capturedThis; + const fixture = mountTemplateInShadow({ + createComponent() { + capturedThis = this; + return { tag: 'instance-marker' }; + }, + }); + // In createComponent, `this` is the same object that becomes `this.instance`. + // After extend(template.instance, returned), template.instance has the marker. + // The `this` captured was BEFORE extend, but it IS the same object reference + // that `template.instance` points to. + expect(capturedThis).toBe(fixture.template.instance); + fixture.cleanup(); + }); + + it('onCreated runs with this === element (the host)', () => { + let capturedThis; + const fixture = mountTemplateInShadow({ + onCreated() { + capturedThis = this; + }, + }); + expect(capturedThis).toBe(fixture.host); + fixture.cleanup(); + }); + + it('onRendered runs with this === element', async () => { + let capturedThis; + const fixture = mountTemplateInShadow({ + onRendered() { + capturedThis = this; + }, + }); + // Trigger onRendered explicitly — render() schedules it via setTimeout + fixture.template.render(); + // Wait for the setTimeout(...,0) to fire onRendered + await new Promise(r => setTimeout(r, 5)); + expect(capturedThis).toBe(fixture.host); + fixture.cleanup(); + }); + + it('event handlers run with this === matched (target) element (line 559 bind override)', () => { + let capturedThis; + const fixture = mountForEvents({ + shadowHTML: '', + events: { + 'click .btn'() { + capturedThis = this; + }, + }, + }); + const btn = fixture.shadow.querySelector('.btn'); + clickOn(btn); + // The user handler is `boundEvent = userHandler.bind(targetElement)`, + // then `template.call(boundEvent, ...)`. Bound functions ignore `apply`'s + // `thisArg`, so `this === targetElement` regardless of `call()`'s default. + expect(capturedThis).toBe(btn); + fixture.cleanup(); + }); +}); + +/******************************* + Cached callParams +*******************************/ + +describe('call() — cached callParams', () => { + it('this.callParams is built once at end of initialize()', () => { + const fixture = mountTemplateInShadow({}); + // After initialize → onCreated, callParams is populated. + expect(fixture.template.callParams).toBeDefined(); + expect(typeof fixture.template.callParams).toBe('object'); + fixture.cleanup(); + }); + + it('reuses the SAME callParams reference across multiple plain calls (no additionalData)', () => { + const observed = []; + const fixture = mountTemplateInShadow({}); + fixture.template.call((p) => observed.push(p)); + fixture.template.call((p) => observed.push(p)); + expect(observed.length).toBe(2); + expect(observed[0]).toBe(observed[1]); + expect(observed[0]).toBe(fixture.template.callParams); + fixture.cleanup(); + }); + + it('reuses bound function references across cached callParams (e.g., self, $)', () => { + const observed = []; + const fixture = mountTemplateInShadow({}); + fixture.template.call((p) => observed.push(p)); + fixture.template.call((p) => observed.push(p)); + expect(observed[0].self).toBe(observed[1].self); + expect(observed[0].$).toBe(observed[1].$); + expect(observed[0].$$).toBe(observed[1].$$); + fixture.cleanup(); + }); + + it('additionalData merge produces a NEW object but same underlying callParams source', () => { + const observed = []; + const fixture = mountTemplateInShadow({}); + fixture.template.call((p) => observed.push(p), { additionalData: { x: 1 } }); + fixture.template.call((p) => observed.push(p), { additionalData: { x: 2 } }); + expect(observed[0]).not.toBe(observed[1]); // different merged objects + expect(observed[0].x).toBe(1); + expect(observed[1].x).toBe(2); + // self still refers to the same instance (came from cached callParams) + expect(observed[0].self).toBe(observed[1].self); + fixture.cleanup(); + }); +}); diff --git a/packages/templating/test/browser/data-context-render.test.js b/packages/templating/test/browser/data-context-render.test.js new file mode 100644 index 000000000..e6bab710e --- /dev/null +++ b/packages/templating/test/browser/data-context-render.test.js @@ -0,0 +1,330 @@ +// Surface 6 — Render coordination. +// +// Tests Template.render() integration with the renderer. The contract here +// is the engine-facing one: +// - First render(): initialize → setData → render → setTimeout(onRendered) +// - Subsequent render(): setData; render NOT called; bumpDataVersion only +// when dataReplaced was set +// - additionalData wins over getDataContext on key collision +// - markRendered fires after each render +// +// Stub engine is used because we're verifying CALL COUNTS, not output. See +// _helpers/stub-engine.js. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Reaction, Signal } from '@semantic-ui/reactivity'; + +import { Template } from '../../src/template.js'; +import { freshTemplate } from '../_helpers/fresh-template.js'; +import { clearTemplateRegistry } from '../_helpers/registry-cleanup.js'; +import { makeStubEngine } from '../_helpers/stub-engine.js'; + +afterEach(() => { + clearTemplateRegistry(); + document.body.innerHTML = ''; +}); + +/******************************* + render — first call +*******************************/ + +describe('Template — render (first call)', () => { + it('lazily initializes on first render', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { count: 0 }, + }); + try { + expect(template.initialized).toBeUndefined(); + template.render(); + expect(template.initialized).toBe(true); + } + finally { + cleanup(); + } + }); + + it('calls renderer.setData once per render call', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + defaultState: { count: 0 }, + renderingEngine: engine, + }); + try { + template.render(); + expect(template.renderer.setDataCalls).toBe(1); + } + finally { + cleanup(); + } + }); + + it('calls renderer.render exactly once on first render', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + defaultState: { count: 0 }, + renderingEngine: engine, + }); + try { + template.render(); + expect(template.renderer.renderCalls).toBe(1); + } + finally { + cleanup(); + } + }); + + it('passes the merged data context to renderer.setData', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + data: { name: 'jack' }, + defaultState: { count: 7 }, + renderingEngine: engine, + }); + try { + template.render(); + // Stub records the data argument on each setData call by storing + // the value passed (see stub-engine.js setData). + expect(template.renderer.data.name).toBe('jack'); + expect(template.renderer.data.count).toBeInstanceOf(Signal); + expect(template.renderer.data.count.peek()).toBe(7); + } + finally { + cleanup(); + } + }); + + it('returns the renderer.render() output as this.html', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + renderingEngine: engine, + }); + try { + const html = template.render(); + // Stub render returns DocumentFragment in browser env + expect(html).toBeInstanceOf(DocumentFragment); + expect(template.html).toBe(html); + } + finally { + cleanup(); + } + }); + + it('flips rendered → true via markRendered before returning', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + renderingEngine: engine, + }); + try { + expect(template.rendered).toBe(false); + template.render(); + expect(template.rendered).toBe(true); + } + finally { + cleanup(); + } + }); + + it('schedules onRendered via setTimeout(0) on client', async () => { + const engine = makeStubEngine(); + const onRendered = vi.fn(); + const { template, cleanup } = freshTemplate({ + renderingEngine: engine, + onRendered, + }); + try { + template.render(); + // Not called synchronously — scheduled via setTimeout + expect(onRendered).not.toHaveBeenCalled(); + await new Promise((r) => setTimeout(r, 10)); + expect(onRendered).toHaveBeenCalled(); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + render — re-call +*******************************/ + +describe('Template — render (re-call)', () => { + it('does NOT call renderer.render again when already rendered', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + defaultState: { count: 0 }, + renderingEngine: engine, + }); + try { + template.render(); + expect(template.renderer.renderCalls).toBe(1); + // Clear dataReplaced so re-render takes the no-op branch + template.dataReplaced = false; + template.render(); + expect(template.renderer.renderCalls).toBe(1); // unchanged + } + finally { + cleanup(); + } + }); + + it('calls renderer.setData on every render (engine always sees latest data)', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + defaultState: { count: 0 }, + renderingEngine: engine, + }); + try { + template.render(); + template.render(); + template.render(); + expect(template.renderer.setDataCalls).toBe(3); + } + finally { + cleanup(); + } + }); + + it('calls bumpDataVersion when dataReplaced is true on re-render', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + renderingEngine: engine, + }); + try { + template.render(); + const firstBumps = template.renderer.bumpDataVersionCalls; + // Force dataReplaced for the second render + template.dataReplaced = true; + template.render(); + expect(template.renderer.bumpDataVersionCalls).toBe(firstBumps + 1); + } + finally { + cleanup(); + } + }); + + it('does NOT call bumpDataVersion when dataReplaced is false', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + renderingEngine: engine, + }); + try { + template.render(); + const firstBumps = template.renderer.bumpDataVersionCalls; + template.dataReplaced = false; + template.render(); + expect(template.renderer.bumpDataVersionCalls).toBe(firstBumps); + } + finally { + cleanup(); + } + }); + + it('clears dataReplaced after using it on re-render', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + renderingEngine: engine, + }); + try { + template.render(); + template.dataReplaced = true; + template.render(); + expect(template.dataReplaced).toBe(false); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + additionalData override +*******************************/ + +describe('Template — render additionalData override', () => { + it('lets additionalData override getDataContext on collision', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + data: { index: 0 }, + defaultState: { index: 1 }, + renderingEngine: engine, + }); + try { + template.render({ index: 5 }); + // additionalData wins over both data and state + expect(template.renderer.data.index).toBe(5); + } + finally { + cleanup(); + } + }); + + it('does not affect template.data after the call', () => { + // additionalData is merged into the dataContext passed to setData, but + // because setDataContext writes to this.data via assignInPlace, the + // additionalData keys ARE persisted. Pin source's actual behavior. + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + data: { name: 'jack' }, + renderingEngine: engine, + }); + try { + template.render({ extra: 'value' }); + // assignInPlace will add `extra` to this.data (default mode). + expect(template.data.extra).toBe('value'); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + setDataContext-driven re-render +*******************************/ + +describe('Template — setDataContext + render coordination', () => { + it('setDataContext default rerender:true forces renderer.render() again', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + renderingEngine: engine, + }); + try { + template.render(); + expect(template.renderer.renderCalls).toBe(1); + // setDataContext default { rerender: true } resets this.rendered=false + template.setDataContext({ a: 2 }); + template.render(); + expect(template.renderer.renderCalls).toBe(2); + } + finally { + cleanup(); + } + }); + + it('setDataContext { rerender: false } does NOT trigger renderer.render', () => { + const engine = makeStubEngine(); + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + renderingEngine: engine, + }); + try { + template.render(); + expect(template.renderer.renderCalls).toBe(1); + template.setDataContext({ a: 2 }, { rerender: false }); + template.render(); + // rendered stayed true → render() takes the else-if branch + expect(template.renderer.renderCalls).toBe(1); + // dataReplaced was set by the setDataContext mutation → bump fires + expect(template.renderer.bumpDataVersionCalls).toBe(1); + } + finally { + cleanup(); + } + }); +}); diff --git a/packages/templating/test/browser/dom-scoping.test.js b/packages/templating/test/browser/dom-scoping.test.js new file mode 100644 index 000000000..20d3250ae --- /dev/null +++ b/packages/templating/test/browser/dom-scoping.test.js @@ -0,0 +1,443 @@ +// Surface 5 — DOM scoping ($, $$, isNodeInTemplate) +// +// Tests Template's renderRoot-scoped query helpers and the containment +// substrate they share with the events DSL. All tests run in the browser +// project — shadow DOM, attachShadow, and compareDocumentPosition need a +// real browser; jsdom support is partial and unreliable. +// +// Methodology: +// - mountTemplateInShadow attaches a Template to a fresh open shadow root. +// - The stub engine returns an empty fragment, so we manually populate the +// shadow root with the elements each scenario needs. This isolates +// $/$$ contracts from the renderer's behavior. +// - For isNodeInTemplate's startNode/endNode branch, sentinels are mutated +// on the Template instance directly (matches what the renderer's +// DynamicRegion does when wiring subtemplates). + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mountTemplateInShadow } from '../_helpers/browser-fixture.js'; +import { clearTemplateRegistry } from '../_helpers/registry-cleanup.js'; + +describe('Surface 5 — Template DOM scoping', () => { + let fixture; + let cleanups = []; + + afterEach(() => { + if (fixture && fixture.cleanup) { + try { + fixture.cleanup(); + } + catch (_) {} + } + cleanups.forEach(fn => { + try { + fn(); + } + catch (_) {} + }); + cleanups = []; + fixture = null; + document.body.innerHTML = ''; + clearTemplateRegistry(); + }); + + /******************************* + $ — own shadow root + *******************************/ + + describe('$ — renderRoot-scoped query', () => { + it("finds elements in the component's own shadow root", () => { + fixture = mountTemplateInShadow(); + const div = document.createElement('div'); + div.className = 'match'; + fixture.shadow.appendChild(div); + + const result = fixture.template.$('.match'); + expect(result.length).toBe(1); + expect(result[0]).toBe(div); + }); + + it('returns empty when nothing matches in the shadow root', () => { + fixture = mountTemplateInShadow(); + const result = fixture.template.$('.no-such-thing'); + expect(result.length).toBe(0); + }); + + it('does NOT find elements that live in the light DOM (slotted but not projected)', () => { + // A child in light DOM (host's children, not slotted into the shadow tree) + // is NOT visible from `querySelectorAll` rooted at the shadow root. + fixture = mountTemplateInShadow(); + const lightChild = document.createElement('div'); + lightChild.className = 'match'; + lightChild.textContent = 'light'; + fixture.host.appendChild(lightChild); + + const result = fixture.template.$('.match'); + expect(result.length).toBe(0); + }); + + it("does NOT find elements in a nested child component's shadow root (no piercing)", () => { + fixture = mountTemplateInShadow(); + // Nested element with its own shadow root + a matching descendant inside it. + const inner = document.createElement('div'); + const innerShadow = inner.attachShadow({ mode: 'open' }); + const buried = document.createElement('span'); + buried.className = 'match'; + buried.textContent = 'buried'; + innerShadow.appendChild(buried); + fixture.shadow.appendChild(inner); + + const result = fixture.template.$('.match'); + expect(result.length).toBe(0); + }); + + it('does NOT match elements in light DOM ancestors of the host', () => { + fixture = mountTemplateInShadow(); + // A sibling in document.body that matches — must not be visible. + const sibling = document.createElement('div'); + sibling.className = 'match'; + document.body.appendChild(sibling); + cleanups.push(() => sibling.remove()); + + const result = fixture.template.$('.match'); + expect(result.length).toBe(0); + }); + }); + + /******************************* + $$ — shadow-piercing query + *******************************/ + + describe('$$ — shadow-piercing query', () => { + it('finds elements in own shadow root (same as $ for that case)', () => { + fixture = mountTemplateInShadow(); + const div = document.createElement('div'); + div.className = 'match'; + fixture.shadow.appendChild(div); + + const result = fixture.template.$$('.match'); + expect(result.length).toBe(1); + expect(result[0]).toBe(div); + }); + + it("finds elements in a nested child component's shadow root (load-bearing difference)", () => { + fixture = mountTemplateInShadow(); + const inner = document.createElement('div'); + const innerShadow = inner.attachShadow({ mode: 'open' }); + const buried = document.createElement('span'); + buried.className = 'match'; + innerShadow.appendChild(buried); + fixture.shadow.appendChild(inner); + + const result = fixture.template.$$('.match'); + // Pierces into innerShadow. + const matched = Array.from(result); + expect(matched).toContain(buried); + }); + + it('finds elements at multiple nesting levels of shadow roots', () => { + fixture = mountTemplateInShadow(); + + // shadow → outerHost (shadow) → innerHost (shadow) → .match + const outerHost = document.createElement('div'); + const outerShadow = outerHost.attachShadow({ mode: 'open' }); + const innerHost = document.createElement('div'); + const innerShadow = innerHost.attachShadow({ mode: 'open' }); + const deepest = document.createElement('span'); + deepest.className = 'match'; + innerShadow.appendChild(deepest); + outerShadow.appendChild(innerHost); + fixture.shadow.appendChild(outerHost); + + // Also a sibling at the top level. + const top = document.createElement('span'); + top.className = 'match'; + fixture.shadow.appendChild(top); + + const result = Array.from(fixture.template.$$('.match')); + expect(result).toContain(top); + expect(result).toContain(deepest); + expect(result.length).toBeGreaterThanOrEqual(2); + }); + }); + + /******************************* + Special selectors → document + *******************************/ + + describe('special selectors escape to document', () => { + it('$("body") returns document.body even when called from inside a component', () => { + fixture = mountTemplateInShadow(); + const result = fixture.template.$('body'); + expect(result.length).toBe(1); + expect(result[0]).toBe(document.body); + }); + + it('$("html") returns document.documentElement', () => { + fixture = mountTemplateInShadow(); + const result = fixture.template.$('html'); + expect(result.length).toBe(1); + expect(result[0]).toBe(document.documentElement); + }); + + // Source observation: 'document' is in template.js's special-selector + // list (rebinds root → document) but Query has NO matching special-case + // for the literal selector 'document' (unlike 'window'/'globalThis'). + // So the call ends up running `document.querySelectorAll('document')` + // — invalid as a CSS tag selector — and returns zero matches. The + // rebinding is observable (filter is skipped, see next test) but the + // selector never resolves to the document itself. Authors who want + // the document object should use `{ root: document }` or query for + // 'html'/'body' instead. + it('$("document") rebinds root to document but yields no matches (no CSS resolution for "document")', () => { + fixture = mountTemplateInShadow(); + const result = fixture.template.$('document'); + expect(result.length).toBe(0); + }); + + it('$("body") result is not filtered by isNodeInTemplate (root rebound to document, filter skipped)', () => { + // body is plainly outside the renderRoot. If filterTemplate were applied, + // the result would be filtered out (isNodeInTemplate(body) === false). + // The contract: rebinding the root to document also bypasses the filter. + fixture = mountTemplateInShadow(); + const result = fixture.template.$('body'); + expect(result.length).toBe(1); + expect(result[0]).toBe(document.body); + }); + + // L1 — cross-package: 'window' is handled by Query, NOT template.js's + // special-selector list. This test pins the cross-package coordination — + // Template's special-selector list is ['body', 'document', 'html'] only, + // but Query's own `inArray(selector, ['window', 'globalThis'])` branch + // (query.js line 159) catches 'window' downstream. If anyone refactors + // Query's special-case, in-component $('window') silently breaks. + it('$("window") returns the global proxy (delegated to underlying Query)', async () => { + fixture = mountTemplateInShadow(); + const result = fixture.template.$('window'); + expect(result.length).toBe(1); + // Query.globalThisProxy is the wrapped global. Verify the proxy + // behaves like window by checking a property that exists on globalThis. + const { Query } = await import('@semantic-ui/query'); + expect(Query.isWindow(result[0])).toBe(true); + }); + }); + + /******************************* + filterTemplate: false + *******************************/ + + describe('filterTemplate: false (internal opt-out)', () => { + it('returns the raw query without applying isNodeInTemplate filter', () => { + // Mount a template that pretends to be a subtemplate by setting + // startNode/endNode such that NOTHING is in range — then verify + // filterTemplate:false still returns matches (the filter is skipped). + fixture = mountTemplateInShadow(); + const div = document.createElement('div'); + div.className = 'match'; + fixture.shadow.appendChild(div); + + // Force isNodeInTemplate to return false for everything by setting + // a degenerate range — sentinels positioned such that no DOM node + // can be strictly between them. + const startNode = document.createTextNode(''); + const endNode = document.createTextNode(''); + // Place both sentinels AFTER `div` in document order, with start + // immediately followed by end — no node can fall strictly between them. + fixture.shadow.appendChild(startNode); + fixture.shadow.appendChild(endNode); + fixture.template.startNode = startNode; + fixture.template.endNode = endNode; + + // With default filterTemplate:true, range filter excludes div. + expect(fixture.template.$('.match').length).toBe(0); + // With filterTemplate:false, raw query returns div. + const raw = fixture.template.$('.match', { filterTemplate: false }); + expect(raw.length).toBe(1); + expect(raw[0]).toBe(div); + }); + }); + + /******************************* + isNodeInTemplate + (web component — no startNode/endNode) + *******************************/ + + describe('isNodeInTemplate — web component (no range markers)', () => { + it("returns true for an element rendered inside the component's shadow root", () => { + fixture = mountTemplateInShadow(); + const div = document.createElement('div'); + fixture.shadow.appendChild(div); + expect(fixture.template.isNodeInTemplate(div)).toBe(true); + }); + + it('returns true for a deeply nested element in own shadow tree', () => { + fixture = mountTemplateInShadow(); + const a = document.createElement('div'); + const b = document.createElement('div'); + const c = document.createElement('span'); + a.appendChild(b); + b.appendChild(c); + fixture.shadow.appendChild(a); + expect(fixture.template.isNodeInTemplate(c)).toBe(true); + }); + + it('returns true for an element inside a NESTED shadow root (host-walking via .host)', () => { + // The getRootChild walk must cross shadow boundaries upward via + // `node.host` so events bubbling out of a nested child component can + // be attributed to the parent template. + fixture = mountTemplateInShadow(); + const innerHost = document.createElement('div'); + const innerShadow = innerHost.attachShadow({ mode: 'open' }); + const deep = document.createElement('span'); + innerShadow.appendChild(deep); + fixture.shadow.appendChild(innerHost); + + expect(fixture.template.isNodeInTemplate(deep)).toBe(true); + }); + + // Source observation: for top-level Templates (no startNode/endNode), + // `isNodeInRange` short-circuits to `true` BEFORE the `node === null` + // check (template.js line 707 → 710). So even when `getRootChild` walks + // off the top of the document and returns null, `isNodeInTemplate` + // returns true. The function's actual contract is narrower than + // "is this node a descendant of my renderRoot": it's "given a node + // that bubbled to my event listener, is it in my range" — and event + // targets that reach the listener are by construction inside the + // renderRoot. We pin the current behavior here. + it('returns true for a sibling element on the document outside the renderRoot (range short-circuit)', () => { + fixture = mountTemplateInShadow(); + const sibling = document.createElement('div'); + document.body.appendChild(sibling); + cleanups.push(() => sibling.remove()); + + // getRootChild walks up to document, then null; isNodeInRange(null) + // returns true because !startNode || !endNode short-circuits first. + expect(fixture.template.isNodeInTemplate(sibling)).toBe(true); + }); + + it('returns true for a fully detached node when no sentinels are set (range short-circuit)', () => { + fixture = mountTemplateInShadow(); + const detached = document.createElement('div'); + // never appended to anything — parentNode is null, host is undefined. + // Walk dies immediately. isNodeInRange(null) hits the + // `!startNode || !endNode` short-circuit and returns true. + expect(fixture.template.isNodeInTemplate(detached)).toBe(true); + }); + }); + + /******************************* + isNodeInTemplate + (subtemplate — startNode/endNode set) + *******************************/ + + describe('isNodeInTemplate — subtemplate range (sentinels set)', () => { + // Helper: mount, set sentinels around a known position in the shadow tree. + function mountWithSentinelRange() { + const f = mountTemplateInShadow(); + + // Layout in shadow: + //
before
+ // + //
middle
+ // + //
after
+ const before = document.createElement('div'); + before.className = 'before'; + const startNode = document.createTextNode(''); + const middle = document.createElement('div'); + middle.className = 'middle'; + const endNode = document.createTextNode(''); + const after = document.createElement('div'); + after.className = 'after'; + + f.shadow.appendChild(before); + f.shadow.appendChild(startNode); + f.shadow.appendChild(middle); + f.shadow.appendChild(endNode); + f.shadow.appendChild(after); + + f.template.startNode = startNode; + f.template.endNode = endNode; + + return { fixture: f, before, startNode, middle, endNode, after }; + } + + it('returns true for a node strictly between startNode and endNode', () => { + const ctx = mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.middle)).toBe(true); + }); + + it('returns false for a node before startNode', () => { + const ctx = mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.before)).toBe(false); + }); + + it('returns false for a node after endNode', () => { + const ctx = mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.after)).toBe(false); + }); + + // Sentinel exclusivity (strict-between semantics) — confirms the + // renderer's trailing-sentinel comment in dynamic-region.js is + // load-bearing: sentinels themselves are NOT in the range. + it('returns false when node === startNode (sentinels are exclusive)', () => { + const ctx = mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.startNode)).toBe(false); + }); + + it('returns false when node === endNode (sentinels are exclusive)', () => { + const ctx = mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.endNode)).toBe(false); + }); + + it('returns false for a detached node even when sentinels are set', () => { + const ctx = mountWithSentinelRange(); + fixture = ctx.fixture; + const detached = document.createElement('div'); + expect(fixture.template.isNodeInTemplate(detached)).toBe(false); + }); + }); + + /******************************* + $ + range filter integration + *******************************/ + + describe('$ post-filters via isNodeInTemplate when root === renderRoot', () => { + it('with sentinels set, $ returns only nodes strictly between them', () => { + // The same fixture as the subtemplate range tests, but now we hit + // the $ surface — verify the post-filter is applied correctly. + fixture = mountTemplateInShadow(); + + const before = document.createElement('div'); + before.className = 'match'; + before.textContent = 'before'; + const startNode = document.createTextNode(''); + const middle = document.createElement('div'); + middle.className = 'match'; + middle.textContent = 'middle'; + const endNode = document.createTextNode(''); + const after = document.createElement('div'); + after.className = 'match'; + after.textContent = 'after'; + + fixture.shadow.appendChild(before); + fixture.shadow.appendChild(startNode); + fixture.shadow.appendChild(middle); + fixture.shadow.appendChild(endNode); + fixture.shadow.appendChild(after); + + fixture.template.startNode = startNode; + fixture.template.endNode = endNode; + + const result = fixture.template.$('.match'); + expect(result.length).toBe(1); + expect(result[0]).toBe(middle); + }); + }); +}); diff --git a/packages/templating/test/browser/events.test.js b/packages/templating/test/browser/events.test.js new file mode 100644 index 000000000..ea28ec011 --- /dev/null +++ b/packages/templating/test/browser/events.test.js @@ -0,0 +1,963 @@ +// Surface 1 — Events DSL coverage tests. +// +// Tests written against the documented contract: +// - Selector grammar (single/multi event, single/multi selector, keyword prefix) +// - Bubble-map (non-bubbling -> bubbling rewrite) +// - Four dialects: default delegation, deep, global, bind +// - Handler-arg shape: event/target/data/value/isDeep +// - Return-value contract (false -> stopPropagation, 'cancel' -> preventDefault) +// - attachEvent dynamic helper + auto-cleanup +// - dispatchEvent custom-event emission +// - Lifecycle teardown via AbortController cascade + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { mountTemplateInShadow } from '../_helpers/browser-fixture.js'; +import { clickOn, fireCustomEvent, fireEvent } from '../_helpers/dispatch.js'; +import { freshTemplate } from '../_helpers/fresh-template.js'; +import { clearTemplateRegistry } from '../_helpers/registry-cleanup.js'; + +afterEach(() => { + clearTemplateRegistry(); + document.body.innerHTML = ''; +}); + +/******************************* + Parser Grammar +*******************************/ + +describe('events DSL — selector grammar', () => { + it('parses a single event with a single selector', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('click .submit'); + expect(parsed).toEqual([ + { eventName: 'click', eventType: 'delegate', selector: '.submit' }, + ]); + } + finally { + cleanup(); + } + }); + + it('parses a comma-list of events sharing one selector', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('mouseup, mouseleave .selector'); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toMatchObject({ eventName: 'mouseup', selector: '.selector' }); + // mouseleave is rewritten to mouseout via bubble map + expect(parsed[1]).toMatchObject({ eventName: 'mouseout', selector: '.selector' }); + } + finally { + cleanup(); + } + }); + + it('parses a comma-list of selectors sharing one event', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('click .a, .b'); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toMatchObject({ eventName: 'click', selector: '.a' }); + expect(parsed[1]).toMatchObject({ eventName: 'click', selector: '.b' }); + } + finally { + cleanup(); + } + }); + + it('binds one handler to the cross product of comma-listed events and selectors', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('click, mouseup .a, .b'); + expect(parsed).toHaveLength(4); + const pairs = parsed.map(({ eventName, selector }) => `${eventName}|${selector}`).sort(); + expect(pairs).toEqual([ + 'click|.a', + 'click|.b', + 'mouseup|.a', + 'mouseup|.b', + ]); + } + finally { + cleanup(); + } + }); + + it('treats an empty selector as component-wide when no selector is given', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('click'); + expect(parsed).toEqual([ + { eventName: 'click', eventType: 'delegate', selector: '' }, + ]); + } + finally { + cleanup(); + } + }); + + it('strips the `deep` keyword and sets eventType', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('deep click .item'); + expect(parsed).toEqual([ + { eventName: 'click', eventType: 'deep', selector: '.item' }, + ]); + } + finally { + cleanup(); + } + }); + + it('strips the `global` keyword and sets eventType', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('global hashchange window'); + expect(parsed).toEqual([ + { eventName: 'hashchange', eventType: 'global', selector: 'window' }, + ]); + } + finally { + cleanup(); + } + }); + + it('strips the `bind` keyword and sets eventType', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('bind customevent some-component'); + expect(parsed).toEqual([ + { eventName: 'customevent', eventType: 'bind', selector: 'some-component' }, + ]); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + Bubble Map Mapping +*******************************/ + +describe('events DSL — non-bubbling event mapping', () => { + it('rewrites blur as focusout so delegation can hear it', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('blur .input'); + expect(parsed[0].eventName).toBe('focusout'); + } + finally { + cleanup(); + } + }); + + it('rewrites focus as focusin so delegation can hear it', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('focus .input'); + expect(parsed[0].eventName).toBe('focusin'); + } + finally { + cleanup(); + } + }); + + it('rewrites mouseenter as mouseover', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('mouseenter .area'); + expect(parsed[0].eventName).toBe('mouseover'); + } + finally { + cleanup(); + } + }); + + it('rewrites mouseleave as mouseout', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('mouseleave .area'); + expect(parsed[0].eventName).toBe('mouseout'); + } + finally { + cleanup(); + } + }); + + // Source-pinned (D4 from intent doc): the skill omits these but source includes them + it('rewrites load as DOMContentLoaded (source-pinned)', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('load'); + expect(parsed[0].eventName).toBe('DOMContentLoaded'); + } + finally { + cleanup(); + } + }); + + it('rewrites unload as beforeunload (source-pinned)', () => { + const { template, cleanup } = freshTemplate(); + try { + const parsed = template.parseEventString('unload'); + expect(parsed[0].eventName).toBe('beforeunload'); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + Default-Mode Delegation +*******************************/ + +describe('events DSL — default-mode delegation', () => { + it('fires on elements matching the selector when delegated to the shadow root', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'click .btn': handler }, + }); + fixture.shadow.innerHTML = ''; + try { + const btn = fixture.shadow.querySelector('.btn'); + clickOn(btn); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('binds one handler to multiple events when events are comma-separated', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'click, mouseup .btn': handler }, + }); + fixture.shadow.innerHTML = ''; + try { + const btn = fixture.shadow.querySelector('.btn'); + clickOn(btn); + fireEvent(btn, 'mouseup'); + expect(handler).toHaveBeenCalledTimes(2); + } + finally { + fixture.cleanup(); + } + }); + + it('binds one handler to multiple selectors when selectors are comma-separated', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'click .a, .b': handler }, + }); + fixture.shadow.innerHTML = '
A
B
'; + try { + clickOn(fixture.shadow.querySelector('.a')); + clickOn(fixture.shadow.querySelector('.b')); + expect(handler).toHaveBeenCalledTimes(2); + } + finally { + fixture.cleanup(); + } + }); + + it('does NOT fire on slotted content matching the selector (encapsulation by default)', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'click .item': handler }, + }); + // Set up the shadow root with a ; light DOM children flow into it. + // To activate the isNodeInTemplate range filter, we need startNode/endNode + // markers around the rendered content (in production, the renderer sets these). + const startMarker = document.createComment('start'); + const endMarker = document.createComment('end'); + fixture.shadow.innerHTML = ''; + fixture.shadow.appendChild(startMarker); + const ownTemplate = document.createElement('div'); + ownTemplate.className = 'own-template'; + ownTemplate.innerHTML = ''; + fixture.shadow.appendChild(ownTemplate); + fixture.shadow.appendChild(endMarker); + fixture.template.startNode = startMarker; + fixture.template.endNode = endMarker; + fixture.host.innerHTML = ''; + try { + // The slotted button is in the host's light DOM, not inside the shadow. + // event.target on click is the light-DOM button. isNodeInTemplate walks + // up via parentNode chain — light DOM never finds a child of the shadow + // root, so getRootChild returns document, which is disconnected from + // startNode/endNode → isNodeInTemplate returns false → handler skipped. + const slotted = fixture.host.querySelector('.item'); + clickOn(slotted); + expect(handler).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('does NOT fire on elements inside a nested child shadow DOM matching the selector', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'click .item': handler }, + }); + // Nested child host with its own shadow root inside our renderRoot. + // Range markers around the rendered content (production-like setup). + const startMarker = document.createComment('start'); + const endMarker = document.createComment('end'); + fixture.shadow.innerHTML = ''; + fixture.shadow.appendChild(startMarker); + const ownTemplate = document.createElement('div'); + ownTemplate.className = 'own-template'; + const childHost = document.createElement('div'); + childHost.className = 'child-host'; + ownTemplate.appendChild(childHost); + fixture.shadow.appendChild(ownTemplate); + fixture.shadow.appendChild(endMarker); + fixture.template.startNode = startMarker; + fixture.template.endNode = endMarker; + const childShadow = childHost.attachShadow({ mode: 'open' }); + childShadow.innerHTML = ''; + try { + const nested = childShadow.querySelector('.item'); + clickOn(nested); + // composed: true bubbles to parent shadow, but event.target retargets + // to the child-host (which doesn't match .item), so the isDeep check + // rejects the handler. + expect(handler).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('skips mouseover when relatedTarget is a descendant of the target', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'mouseenter .area': handler }, + }); + fixture.shadow.innerHTML = '
child
'; + try { + const area = fixture.shadow.querySelector('.area'); + const inner = fixture.shadow.querySelector('.inner'); + // Simulate a mouseover entering area from a sibling (relatedTarget unrelated) + // — should fire (boundary crossed). + area.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + composed: true, + relatedTarget: document.body, + }), + ); + expect(handler).toHaveBeenCalledTimes(1); + + // Now simulate motion *within* the area (relatedTarget contained by target) + // — should be filtered out. + area.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + composed: true, + relatedTarget: inner, + }), + ); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + Deep Keyword +*******************************/ + +describe('events DSL — deep keyword (boundary escape)', () => { + it('fires on slotted content matching the selector', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'deep click .item': handler }, + }); + // Use the same range-marker setup as the default-mode encapsulation test, + // so this test exercises the boundary-escape contract symmetrically: the + // default test pins safety, this test pins escape under identical scaffolding. + const startMarker = document.createComment('start'); + const endMarker = document.createComment('end'); + fixture.shadow.innerHTML = ''; + fixture.shadow.appendChild(startMarker); + const ownTemplate = document.createElement('div'); + ownTemplate.className = 'own-template'; + ownTemplate.innerHTML = ''; + fixture.shadow.appendChild(ownTemplate); + fixture.shadow.appendChild(endMarker); + fixture.template.startNode = startMarker; + fixture.template.endNode = endMarker; + fixture.host.innerHTML = ''; + try { + clickOn(fixture.host.querySelector('.item')); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('fires on a direct match inside the own template (isDeep=false)', () => { + let receivedIsDeep; + const fixture = mountTemplateInShadow({ + events: { + 'deep click .btn'({ isDeep }) { + receivedIsDeep = isDeep; + }, + }, + }); + fixture.shadow.innerHTML = ''; + try { + clickOn(fixture.shadow.querySelector('.btn')); + expect(receivedIsDeep).toBe(false); + } + finally { + fixture.cleanup(); + } + }); + + it('delivers isDeep as a boolean to the handler', () => { + // isDeep is computed from `selector && $(event.target).closest(selector).length === 0`. + // Pin that the handler arg includes isDeep as a boolean (false in the common case + // where the delegated target itself matches the selector). + let receivedIsDeep; + const fixture = mountTemplateInShadow({ + events: { + 'deep click .btn'({ isDeep }) { + receivedIsDeep = isDeep; + }, + }, + }); + fixture.shadow.innerHTML = ''; + try { + clickOn(fixture.shadow.querySelector('.btn')); + expect(typeof receivedIsDeep).toBe('boolean'); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + Global Keyword +*******************************/ + +describe('events DSL — global keyword', () => { + it('attaches listeners to window for global window events', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'global hashchange window': handler }, + }); + try { + window.dispatchEvent(new HashChangeEvent('hashchange')); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('removes the global listener when the template is destroyed', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'global hashchange window': handler }, + }); + fixture.cleanup(); + window.dispatchEvent(new HashChangeEvent('hashchange')); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +/******************************* + Bind Keyword +*******************************/ + +describe('events DSL — bind keyword', () => { + it('does not bind to elements until first render fires onRenderOnce', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'bind ping .target': handler }, + }); + // Even though attachEvents has been called, bind defers until onRenderOnce. + fixture.shadow.innerHTML = '
'; + try { + const tgt = fixture.shadow.querySelector('.target'); + fireCustomEvent(tgt, 'ping'); + // No render has fired in the test fixture (stub engine; onRendered + // only fires through Template.render() which we don't invoke). + expect(handler).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('attaches listeners directly after onRendered fires (manual trigger)', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'bind ping .target': handler }, + }); + fixture.shadow.innerHTML = '
'; + try { + // Manually invoke onRenderOnce, simulating the post-first-render hook. + if (typeof fixture.template.onRenderOnce === 'function') { + fixture.template.onRenderOnce(); + } + const tgt = fixture.shadow.querySelector('.target'); + fireCustomEvent(tgt, 'ping'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('hears non-bubbling CustomEvents that delegation cannot see', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'bind nobubble .target': handler }, + }); + fixture.shadow.innerHTML = '
'; + try { + if (typeof fixture.template.onRenderOnce === 'function') { + fixture.template.onRenderOnce(); + } + const tgt = fixture.shadow.querySelector('.target'); + // dispatch with bubbles: false to confirm direct binding, not delegation + tgt.dispatchEvent(new CustomEvent('nobubble', { bubbles: false })); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('does not double-bind across multiple render cycles', () => { + // Pin current behavior: wrapFunction(this.onRenderOnce) replaces onRenderOnce + // with a no-op after first call, so subsequent renders do not re-bind. + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'bind ping .target': handler }, + }); + fixture.shadow.innerHTML = '
'; + try { + // First "render" + if (typeof fixture.template.onRenderOnce === 'function') { + fixture.template.onRenderOnce(); + } + // Second "render": onRenderOnce is wrapped to noop after first call, + // so even if we tried to call it, it wouldn't rebind. + // (No-op call is safe; if someone calls it, it does nothing.) + const tgt = fixture.shadow.querySelector('.target'); + fireCustomEvent(tgt, 'ping'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + Handler Arguments +*******************************/ + +describe('events DSL — handler arguments', () => { + it('passes the native event as `event`', () => { + let received; + const fixture = mountTemplateInShadow({ + events: { + 'click .btn'({ event }) { + received = event; + }, + }, + }); + fixture.shadow.innerHTML = ''; + try { + clickOn(fixture.shadow.querySelector('.btn')); + expect(received).toBeInstanceOf(MouseEvent); + expect(received.type).toBe('click'); + } + finally { + fixture.cleanup(); + } + }); + + it('passes the matched element as `target`', () => { + let receivedTarget; + const fixture = mountTemplateInShadow({ + events: { + 'click .btn'({ target }) { + receivedTarget = target; + }, + }, + }); + fixture.shadow.innerHTML = ''; + try { + // Click on the inner span; matched element is .btn (the closest match) + clickOn(fixture.shadow.querySelector('.label')); + expect(receivedTarget).toBe(fixture.shadow.querySelector('.btn')); + } + finally { + fixture.cleanup(); + } + }); + + it('binds `this` to the matched element', () => { + let receivedThis; + const fixture = mountTemplateInShadow({ + events: { + 'click .btn'() { + receivedThis = this; + }, + }, + }); + fixture.shadow.innerHTML = ''; + try { + const btn = fixture.shadow.querySelector('.btn'); + clickOn(btn); + expect(receivedThis).toBe(btn); + } + finally { + fixture.cleanup(); + } + }); + + it('parses data-* attributes as typed values in `data` (numbers, booleans, JSON)', () => { + let receivedData; + const fixture = mountTemplateInShadow({ + events: { + 'click .btn'({ data }) { + receivedData = data; + }, + }, + }); + fixture.shadow.innerHTML = ` + + `; + try { + clickOn(fixture.shadow.querySelector('.btn')); + expect(receivedData.amount).toBe(5); + expect(receivedData.active).toBe(true); + expect(receivedData.config).toEqual({ x: 1 }); + expect(receivedData.name).toBe('hello'); + } + finally { + fixture.cleanup(); + } + }); + + it('merges event.detail into `data`, with detail keys overriding dataset keys', () => { + let receivedData; + const fixture = mountTemplateInShadow({ + events: { + 'mycustom .item'({ data }) { + receivedData = data; + }, + }, + }); + fixture.shadow.innerHTML = '
'; + try { + const item = fixture.shadow.querySelector('.item'); + fireCustomEvent(item, 'mycustom', { key: 'from-detail', extra: 'detail-only' }); + expect(receivedData.key).toBe('from-detail'); + expect(receivedData.extra).toBe('detail-only'); + expect(receivedData.only).toBe('dataset-only'); + } + finally { + fixture.cleanup(); + } + }); + + it('passes `value` from target.value when the target is a form control', () => { + let receivedValue; + const fixture = mountTemplateInShadow({ + events: { + 'change input'({ value }) { + receivedValue = value; + }, + }, + }); + fixture.shadow.innerHTML = ''; + try { + const input = fixture.shadow.querySelector('input'); + input.value = 'updated'; + fireEvent(input, 'change'); + expect(receivedValue).toBe('updated'); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + Return-Value Contract +*******************************/ + +describe('events DSL — return-value contract', () => { + it('calls stopPropagation when the handler returns false', () => { + const inner = vi.fn(() => false); + const outer = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'click .btn': inner }, + }); + fixture.shadow.innerHTML = ''; + // Outer listener at the shadow root document level — should not see click + // if stopPropagation fires inside the inner delegated handler. + document.addEventListener('click', outer); + try { + clickOn(fixture.shadow.querySelector('.btn')); + expect(inner).toHaveBeenCalledTimes(1); + // Listener delegated at the shadow root — once stopPropagation fires + // inside the inner handler, the click does not continue to bubble out + // of the shadow root to document. + expect(outer).not.toHaveBeenCalled(); + } + finally { + document.removeEventListener('click', outer); + fixture.cleanup(); + } + }); + + it('calls preventDefault when the handler returns the string "cancel"', () => { + const handler = vi.fn(() => 'cancel'); + const fixture = mountTemplateInShadow({ + events: { 'click .btn': handler }, + }); + fixture.shadow.innerHTML = ''; + try { + const btn = fixture.shadow.querySelector('.btn'); + const ev = new MouseEvent('click', { + bubbles: true, + composed: true, + cancelable: true, + }); + btn.dispatchEvent(ev); + expect(handler).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(true); + } + finally { + fixture.cleanup(); + } + }); + + it('does neither when the handler returns undefined', () => { + const handler = vi.fn(); // implicitly returns undefined + const fixture = mountTemplateInShadow({ + events: { 'click .btn': handler }, + }); + fixture.shadow.innerHTML = ''; + try { + const btn = fixture.shadow.querySelector('.btn'); + const ev = new MouseEvent('click', { + bubbles: true, + composed: true, + cancelable: true, + }); + btn.dispatchEvent(ev); + expect(handler).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(false); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + attachEvent +*******************************/ + +describe('attachEvent — dynamic event binding with auto-cleanup', () => { + it('binds an event to an external selector from inside the component', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({}); + // Create a target outside the component's shadow tree. + const externalTarget = document.createElement('div'); + externalTarget.id = 'external-target'; + document.body.appendChild(externalTarget); + try { + fixture.template.attachEvent(externalTarget, 'click', handler); + clickOn(externalTarget); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + externalTarget.remove(); + fixture.cleanup(); + } + }); + + it('returns a handler object that can be aborted manually', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({}); + const externalTarget = document.createElement('div'); + document.body.appendChild(externalTarget); + try { + const eventHandler = fixture.template.attachEvent(externalTarget, 'click', handler); + expect(eventHandler).toBeDefined(); + expect(typeof eventHandler.abort).toBe('function'); + + eventHandler.abort(); + clickOn(externalTarget); + expect(handler).not.toHaveBeenCalled(); + } + finally { + externalTarget.remove(); + fixture.cleanup(); + } + }); + + it('removes the listener when the component is destroyed', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({}); + const externalTarget = document.createElement('div'); + document.body.appendChild(externalTarget); + fixture.template.attachEvent(externalTarget, 'click', handler); + fixture.cleanup(); + // After destroy, listener is gone. + clickOn(externalTarget); + expect(handler).not.toHaveBeenCalled(); + externalTarget.remove(); + }); +}); + +/******************************* + dispatchEvent +*******************************/ + +describe('dispatchEvent — emitting custom events from a component', () => { + it('fires a CustomEvent on the component element with detail equal to the supplied data', () => { + const fixture = mountTemplateInShadow({}); + const handler = vi.fn(); + fixture.host.addEventListener('itemactive', handler); + try { + fixture.template.dispatchEvent('itemactive', { id: 42 }); + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0]; + expect(event).toBeInstanceOf(CustomEvent); + expect(event.detail).toEqual({ id: 42 }); + } + finally { + fixture.cleanup(); + } + }); + + it('emits CustomEvents that cross shadow boundaries (composed: true) by default', () => { + const fixture = mountTemplateInShadow({}); + const docHandler = vi.fn(); + document.addEventListener('itemactive', docHandler); + try { + fixture.template.dispatchEvent('itemactive', { id: 1 }); + expect(docHandler).toHaveBeenCalledTimes(1); + } + finally { + document.removeEventListener('itemactive', docHandler); + fixture.cleanup(); + } + }); + + it('invokes the matching on{Name} setting callback before dispatching the DOM event when triggerCallback is true', () => { + // Stub host element with an onFoo callback (mimics what defineComponent + // sets up via settings reflection). + const fixture = mountTemplateInShadow({}); + const events = []; + fixture.host.onFoo = function(data) { + events.push(['callback', data]); + }; + fixture.host.addEventListener('foo', function(e) { + events.push(['dom', e.detail]); + }); + try { + fixture.template.dispatchEvent('foo', { x: 1 }); + expect(events).toEqual([ + ['callback', { x: 1 }], + ['dom', { x: 1 }], + ]); + } + finally { + fixture.cleanup(); + } + }); + + it('skips the on{Name} callback when triggerCallback is false', () => { + const fixture = mountTemplateInShadow({}); + const cb = vi.fn(); + const dom = vi.fn(); + fixture.host.onFoo = cb; + fixture.host.addEventListener('foo', dom); + try { + fixture.template.dispatchEvent('foo', { x: 1 }, undefined, { triggerCallback: false }); + expect(cb).not.toHaveBeenCalled(); + expect(dom).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('lets callers override CustomEvent options via the third argument', () => { + const fixture = mountTemplateInShadow({}); + const handler = vi.fn(); + fixture.host.addEventListener('cancelable', handler); + try { + fixture.template.dispatchEvent('cancelable', { x: 1 }, { bubbles: false }); + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0]; + expect(event.bubbles).toBe(false); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + Lifecycle Teardown +*******************************/ + +describe('events DSL — lifecycle teardown', () => { + it('removes every events-DSL listener when the template is destroyed', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'click .btn': handler }, + }); + fixture.shadow.innerHTML = ''; + const btn = fixture.shadow.querySelector('.btn'); + fixture.cleanup(); + clickOn(btn); + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not double-bind listeners when attachEvents is called again', () => { + const handler = vi.fn(); + const fixture = mountTemplateInShadow({ + events: { 'click .btn': handler }, + }); + fixture.shadow.innerHTML = ''; + try { + // Re-attach should remove existing first (template.js:489). + fixture.template.attachEvents(); + const btn = fixture.shadow.querySelector('.btn'); + clickOn(btn); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); +}); diff --git a/packages/templating/test/browser/lifecycle.test.js b/packages/templating/test/browser/lifecycle.test.js new file mode 100644 index 000000000..d87d37d81 --- /dev/null +++ b/packages/templating/test/browser/lifecycle.test.js @@ -0,0 +1,915 @@ +// Surface 2 — Template lifecycle (internal wrappers, registry, promises, theme). +// +// These tests exercise Template's own contracts directly, without going through +// WebComponentBase or defineComponent. The component-package browser tests cover +// the full integration. See packages/templating/test/_helpers/README.md for the +// cross-package boundary rationale. + +import { Reaction } from '@semantic-ui/reactivity'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Template } from '../../src/template.js'; +import { mountTemplateInShadow } from '../_helpers/browser-fixture.js'; +import { fireCustomEvent } from '../_helpers/dispatch.js'; +import { freshTemplate } from '../_helpers/fresh-template.js'; +import { assertRegistryEmpty, clearTemplateRegistry, snapshotRegistry } from '../_helpers/registry-cleanup.js'; +import { stubEngine } from '../_helpers/stub-engine.js'; + +afterEach(() => { + clearTemplateRegistry(); + document.body.innerHTML = ''; +}); + +/******************************* + Hook firing order +*******************************/ + +describe('Template lifecycle — hook firing order', () => { + it('runs createComponent then initialize then onCreated callback during initialize()', () => { + const calls = []; + const createComponent = vi.fn(() => { + calls.push('createComponent'); + return { + initialize() { + calls.push('instance.initialize'); + }, + }; + }); + const onCreated = vi.fn(() => calls.push('onCreated')); + + const { template, cleanup } = freshTemplate({ + createComponent, + onCreated, + }); + try { + template.initialize(); + expect(calls).toEqual(['createComponent', 'instance.initialize', 'onCreated']); + expect(createComponent).toHaveBeenCalledTimes(1); + expect(onCreated).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('does not fire onRendered during initialize() — only onCreated does', () => { + const onCreated = vi.fn(); + const onRendered = vi.fn(); + const { template, cleanup } = freshTemplate({ onCreated, onRendered }); + try { + template.initialize(); + expect(onCreated).toHaveBeenCalledTimes(1); + expect(onRendered).not.toHaveBeenCalled(); + } + finally { + cleanup(); + } + }); + + it('fires onRendered after a render() call', async () => { + const onCreated = vi.fn(); + const onRendered = vi.fn(); + const { template, cleanup } = freshTemplate({ onCreated, onRendered }); + try { + template.initialize(); + template.render(); + // template.render() schedules onRendered via setTimeout(fn, 0) + await new Promise(r => setTimeout(r, 5)); + expect(onCreated).toHaveBeenCalledTimes(1); + expect(onRendered).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('fires onDestroyed when the wrapper is invoked', () => { + const onCreated = vi.fn(); + const onDestroyed = vi.fn(); + const { template, cleanup } = freshTemplate({ onCreated, onDestroyed }); + try { + template.initialize(); + template.onDestroyed(); + expect(onDestroyed).toHaveBeenCalledTimes(1); + expect(template.destroyed).toBe(true); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + Hook callback signatures +*******************************/ + +describe('Template lifecycle — callback params', () => { + it('passes destructurable params to onCreated', () => { + let received; + const onCreated = vi.fn((params) => { + received = params; + }); + const { template, cleanup } = freshTemplate({ onCreated }); + try { + template.initialize(); + expect(received).toBeDefined(); + // spot-check the documented destructurables + expect(received).toHaveProperty('self'); + expect(received).toHaveProperty('tpl'); + expect(received).toHaveProperty('component'); + expect(received).toHaveProperty('state'); + expect(received).toHaveProperty('data'); + expect(received).toHaveProperty('isClient'); + expect(received).toHaveProperty('isServer'); + expect(received).toHaveProperty('isHydrating'); + expect(received).toHaveProperty('reaction'); + expect(received).toHaveProperty('signal'); + expect(received).toHaveProperty('interval'); + expect(received).toHaveProperty('timeout'); + } + finally { + cleanup(); + } + }); + + it('passes the same callParams object to onDestroyed', () => { + let createdParams; + let destroyedParams; + const onCreated = vi.fn((p) => { + createdParams = p; + }); + const onDestroyed = vi.fn((p) => { + destroyedParams = p; + }); + const { template, cleanup } = freshTemplate({ onCreated, onDestroyed }); + try { + template.initialize(); + template.onDestroyed(); + expect(destroyedParams).toBe(createdParams); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + onUpdated +*******************************/ + +// F-B: vocab is implementation-flavored on purpose. The undocumented surface +// shouldn't accidentally read like external documentation. + +describe('Template.onUpdated wrapper (internal microtask debounce)', () => { + it('does not invoke onUpdated wrapper directly during initialize()', () => { + const onUpdated = vi.fn(); + const { template, cleanup } = freshTemplate({ onUpdated }); + try { + template.initialize(); + // wrapper exists but the user callback is reached via this.call() + // and the onUpdated wrapper itself only fires the DOM event. + // For state-driven onUpdated, see the next test. + expect(typeof template.onUpdated).toBe('function'); + } + finally { + cleanup(); + } + }); + + it('schedules onUpdated via state Reaction afterFlush when state mutates after first render', async () => { + const onUpdated = vi.fn(); + const fixture = mountTemplateInShadow({ + template: '', + defaultState: { count: 0 }, + onUpdated, + }); + try { + // simulate first-render finishing + fixture.template.markRendered(); + + // mutate a state signal — state reaction fires Reaction.afterFlush(this.onUpdated) + fixture.template.state.count.set(1); + Reaction.flush(); + // afterFlush has now invoked the onUpdated wrapper, which queues the + // 'updated' event in a microtask. Drain microtasks. + await Promise.resolve(); + await Promise.resolve(); + + // the wrapper itself doesn't call the user onUpdated — that path goes + // via dispatchEvent('updated') with triggerCallback:false. So our + // expectation here is that the 'updated' DOM event was dispatched. + // But we registered onUpdated as the user callback via Template + // options. The user callback for onUpdated is NOT called by the + // wrapper (it dispatches event, no callback). Pin this behavior. + expect(onUpdated).not.toHaveBeenCalled(); + + // What IS observable: the DOM event was dispatched on element. + // We'll observe that by listening on the host element. + } + finally { + fixture.cleanup(); + } + }); + + it('emits a single updated DOM event when the state Reaction fires', async () => { + const fixture = mountTemplateInShadow({ + template: '', + defaultState: { count: 0 }, + }); + const heard = vi.fn(); + fixture.host.addEventListener('updated', heard); + try { + fixture.template.markRendered(); + fixture.template.state.count.set(1); + Reaction.flush(); + // wait for the queueMicrotask in onUpdated wrapper + await Promise.resolve(); + await Promise.resolve(); + expect(heard).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('coalesces multiple synchronous state mutations into one updated DOM event (microtask debounce)', async () => { + const fixture = mountTemplateInShadow({ + template: '', + defaultState: { a: 0, b: 0, c: 0 }, + }); + const heard = vi.fn(); + fixture.host.addEventListener('updated', heard); + try { + fixture.template.markRendered(); + fixture.template.state.a.set(1); + fixture.template.state.b.set(2); + fixture.template.state.c.set(3); + Reaction.flush(); + await Promise.resolve(); + await Promise.resolve(); + expect(heard).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('does not dispatch updated when the wrapper runs before markRendered() (i.e., on first render)', async () => { + const fixture = mountTemplateInShadow({ + template: '', + defaultState: { count: 0 }, + }); + const heard = vi.fn(); + fixture.host.addEventListener('updated', heard); + try { + // do NOT call markRendered. mutate state. + fixture.template.state.count.set(1); + Reaction.flush(); + await Promise.resolve(); + await Promise.resolve(); + // state reaction at template.js:262 only schedules when this.rendered === true + expect(heard).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('flips updateScheduled true while pending and false after the microtask fires', async () => { + const fixture = mountTemplateInShadow({ + template: '', + defaultState: { count: 0 }, + }); + try { + fixture.template.markRendered(); + fixture.template.state.count.set(1); + Reaction.flush(); + // immediately after afterFlush invokes the onUpdated wrapper synchronously, + // updateScheduled is true (pending microtask) and the element mirror is too + expect(fixture.template.updateScheduled).toBe(true); + expect(fixture.host.updateScheduled).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(fixture.template.updateScheduled).toBe(false); + expect(fixture.host.updateScheduled).toBe(false); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + isHydrating DOM-event suppression +*******************************/ + +describe('Template lifecycle — isHydrating gates DOM events (intentional, F-C)', () => { + it('runs the onCreated user callback during hydration', () => { + const onCreated = vi.fn(); + const fixture = mountTemplateInShadow({ + template: '', + onCreated, + }); + try { + // mountTemplateInShadow already initialized once. The hydration gate + // is observed on the dispatch path. Re-run: set isHydrating, then + // manually invoke the onCreated wrapper to assert callback fires + // and DOM event is suppressed. + const heard = vi.fn(); + fixture.host.addEventListener('created', heard); + + fixture.template.isHydrating = true; + fixture.template.onCreated(); + expect(onCreated).toHaveBeenCalledTimes(2); // once in mount + this manual call + expect(heard).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('suppresses the rendered DOM event during hydration but still runs the user callback', () => { + const onRendered = vi.fn(); + const fixture = mountTemplateInShadow({ + template: '', + onRendered, + }); + const heard = vi.fn(); + fixture.host.addEventListener('rendered', heard); + try { + fixture.template.isHydrating = true; + fixture.template.onRendered(); + expect(onRendered).toHaveBeenCalledTimes(1); + expect(heard).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('dispatches the created DOM event when not hydrating', () => { + const fixture = mountTemplateInShadow({ + template: '', + }); + const heard = vi.fn(); + fixture.host.addEventListener('created', heard); + try { + fixture.template.isHydrating = false; + fixture.template.onCreated(); + expect(heard).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + lifecyclePromise / resolveLifecyclePromise +*******************************/ + +describe('Template.lifecyclePromise (internal promise mechanics)', () => { + it('returns the same Promise for repeated calls before resolution', () => { + const { template, cleanup } = freshTemplate(); + try { + template.initialize(); + const p1 = template.lifecyclePromise('rendered'); + const p2 = template.lifecyclePromise('rendered'); + expect(p1).toBe(p2); + } + finally { + cleanup(); + } + }); + + it('caches the resolved promise for one-shot events (created)', async () => { + const fixture = mountTemplateInShadow({ template: '' }); + try { + // mount already invoked onCreated which called resolveLifecyclePromise('created'). + // BUT mount didn't access lifecyclePromise('created') beforehand — + // see B2a expected-bug test below for that case. + // For this test: pre-access first. + fixture.template.lifecyclePromise('created'); + // re-fire to resolve + fixture.template.resolveLifecyclePromise('created'); + const p1 = fixture.template.lifecyclePromise('created'); + const p2 = fixture.template.lifecyclePromise('created'); + expect(p1).toBe(p2); + // promise resolved + await expect(Promise.race([p1, new Promise((_, rej) => setTimeout(() => rej(new Error('hung')), 50))])) + .resolves.toBeUndefined(); + } + finally { + fixture.cleanup(); + } + }); + + it('returns a fresh Promise after resolution for the recurring event (updated)', () => { + const { template, cleanup } = freshTemplate(); + try { + template.initialize(); + const p1 = template.lifecyclePromise('updated'); + template.resolveLifecyclePromise('updated'); + const p2 = template.lifecyclePromise('updated'); + // recurring: cached promise was deleted, p2 is a fresh promise + expect(p1).not.toBe(p2); + } + finally { + cleanup(); + } + }); + + it('no-ops resolveLifecyclePromise when no resolver is registered', () => { + const { template, cleanup } = freshTemplate(); + try { + template.initialize(); + // never accessed lifecyclePromise('rendered'), so no resolver is registered. + // This call should not throw. + expect(() => template.resolveLifecyclePromise('rendered')).not.toThrow(); + // and the promise map is still empty + expect(template.lifecyclePromises.rendered).toBeUndefined(); + } + finally { + cleanup(); + } + }); + + /* + * B2a — late-awaiter never resolves. + * + * Scenario: a consumer never accesses el.created BEFORE creation fires. + * resolveLifecyclePromise('created') no-ops (no resolver registered). + * Then consumer awaits el.created — lifecyclePromise lazy-creates a NEW + * Promise+resolver, but the event already fired. That resolver will never + * be called. Promise hangs forever. + * + * EXPECTED-BUG-PIN: should fail with current source. Fold-fix at B2. + */ + it('B2a: lifecyclePromise(created) accessed AFTER resolveLifecyclePromise hangs forever (expected bug)', async () => { + const { template, cleanup } = freshTemplate(); + try { + template.initialize(); + // simulate the lifecycle event firing without any prior promise access + template.resolveLifecyclePromise('created'); + // now a consumer awaits el.created for the first time + const promise = template.lifecyclePromise('created'); + const settled = await Promise.race([ + promise.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 100)), + ]); + // After B2 fix: should be 'resolved'. Today: 'hung'. + expect(settled).toBe('resolved'); + } + finally { + cleanup(); + } + }); + + /* + * B2b — hydration suppresses promise resolution. + * + * resolveLifecyclePromise is currently called from inside dispatchEvent. + * The onCreated/onRendered wrappers gate dispatchEvent on !isHydrating. + * So during hydration, neither the DOM event nor the promise resolution + * fires. Awaiters of el.created/el.rendered hang. + * + * EXPECTED-BUG-PIN: should fail with current source. Fold-fix at B2. + */ + it('B2b: lifecyclePromise(rendered) hangs when onRendered fires during isHydrating (expected bug)', async () => { + const fixture = mountTemplateInShadow({ template: '' }); + try { + // pre-access (so B2a is not the cause of failure here) + const promise = fixture.template.lifecyclePromise('rendered'); + + fixture.template.isHydrating = true; + fixture.template.onRendered(); + // The wrapper called the user callback but skipped dispatchEvent + // (isHydrating gate). dispatchEvent is what calls + // resolveLifecyclePromise. So the resolver was never invoked. + + const settled = await Promise.race([ + promise.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 100)), + ]); + // After B2 fix: should be 'resolved'. Today: 'hung'. + expect(settled).toBe('resolved'); + } + finally { + fixture.cleanup(); + } + }); + + it('pins ordering: synchronous DOM event listener runs before lifecyclePromise then-continuation', async () => { + // Note: dispatchEvent() at template.js:910 calls resolveLifecyclePromise + // (resolver runs synchronously, which schedules .then continuations as + // microtasks) and THEN synchronously dispatches the DOM event (which + // invokes its listener synchronously). Net order: DOM listener fires + // first, then-continuation drains in the next microtask. Pinning here + // so any future change to this ordering is intentional. + const fixture = mountTemplateInShadow({ template: '' }); + try { + const order = []; + const promise = fixture.template.lifecyclePromise('updated').then(() => { + order.push('promise'); + }); + fixture.host.addEventListener('updated', () => { + order.push('domEvent'); + }); + // dispatch the DOM event manually since dispatchEvent is the resolver + fixture.template.dispatchEvent( + 'updated', + { component: fixture.template.instance }, + { composed: false }, + { triggerCallback: false }, + ); + await promise; + expect(order[0]).toBe('domEvent'); + expect(order[1]).toBe('promise'); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + Theme observer +*******************************/ + +describe('Template lifecycle — onThemeChanged observer', () => { + it('does not install a MutationObserver when no onThemeChanged callback is provided', () => { + const fixture = mountTemplateInShadow({ template: '' }); + try { + // no observers registered for theme — observers array stays empty + // (events DSL doesn't add observers; only the theme path does) + expect(fixture.template.observers.length).toBe(0); + } + finally { + fixture.cleanup(); + } + }); + + it('installs a MutationObserver when onThemeChanged is provided', () => { + const fixture = mountTemplateInShadow({ + template: '', + onThemeChanged: () => {}, + }); + try { + expect(fixture.template.observers.length).toBe(1); + } + finally { + fixture.cleanup(); + } + }); + + it('fires onThemeChanged when the html class attribute changes', async () => { + const onThemeChanged = vi.fn(); + const fixture = mountTemplateInShadow({ + template: '', + onThemeChanged, + }); + try { + const html = document.documentElement; + const previous = html.className; + html.classList.add('dark'); + // 10ms debounce + await new Promise(r => setTimeout(r, 30)); + expect(onThemeChanged).toHaveBeenCalled(); + // restore + html.className = previous; + await new Promise(r => setTimeout(r, 30)); + } + finally { + fixture.cleanup(); + } + }); + + it('fires onThemeChanged when a themechange event is dispatched on html', async () => { + const onThemeChanged = vi.fn(); + const fixture = mountTemplateInShadow({ + template: '', + onThemeChanged, + }); + try { + fireCustomEvent(document.documentElement, 'themechange', { theme: 'dark' }); + await new Promise(r => setTimeout(r, 30)); + expect(onThemeChanged).toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('coalesces a class mutation and a themechange event in the same window into one callback (10ms debounce)', async () => { + const onThemeChanged = vi.fn(); + const fixture = mountTemplateInShadow({ + template: '', + onThemeChanged, + }); + try { + const html = document.documentElement; + const previous = html.className; + html.classList.add('dark'); + fireCustomEvent(html, 'themechange', { theme: 'dark' }); + await new Promise(r => setTimeout(r, 30)); + expect(onThemeChanged).toHaveBeenCalledTimes(1); + html.className = previous; + await new Promise(r => setTimeout(r, 30)); + } + finally { + fixture.cleanup(); + } + }); + + it('disconnects the MutationObserver on destroy', () => { + const onThemeChanged = vi.fn(); + const fixture = mountTemplateInShadow({ + template: '', + onThemeChanged, + }); + const observer = fixture.template.observers[0]; + const disconnectSpy = vi.spyOn(observer, 'disconnect'); + fixture.template.onDestroyed(); + fixture.cleanup(); + expect(disconnectSpy).toHaveBeenCalled(); + }); +}); + +/******************************* + Template.renderedTemplates registry +*******************************/ + +describe('Template.renderedTemplates registry', () => { + it('adds a template on onCreated and removes it on onDestroyed', () => { + const { template, cleanup } = freshTemplate({ + templateName: 'registry-add-remove', + }); + try { + template.initialize(); + const after = snapshotRegistry(); + expect(after.counts['registry-add-remove']).toBe(1); + template.onDestroyed(); + const afterDestroy = snapshotRegistry(); + // remove() leaves an empty array on the key + expect(afterDestroy.counts['registry-add-remove'] || 0).toBe(0); + } + finally { + cleanup(); + } + }); + + it('does not register isPrototype templates', () => { + const { template, cleanup } = freshTemplate({ + templateName: 'proto-template', + isPrototype: true, + }); + try { + template.initialize(); + const after = snapshotRegistry(); + expect(after.counts['proto-template'] || 0).toBe(0); + } + finally { + cleanup(); + } + }); + + it('grows the array to N when N templates share a name', () => { + const a = freshTemplate({ templateName: 'shared-name' }); + const b = freshTemplate({ templateName: 'shared-name' }); + const c = freshTemplate({ templateName: 'shared-name' }); + try { + a.template.initialize(); + b.template.initialize(); + c.template.initialize(); + expect(snapshotRegistry().counts['shared-name']).toBe(3); + b.template.onDestroyed(); + expect(snapshotRegistry().counts['shared-name']).toBe(2); + } + finally { + a.cleanup(); + b.cleanup(); + c.cleanup(); + } + }); + + it('auto-names anonymous templates using Template.templateCount', () => { + Template.templateCount = 0; + const a = freshTemplate(); // no templateName + const b = freshTemplate(); + try { + expect(a.template.templateName).toBe('Anonymous #1'); + expect(b.template.templateName).toBe('Anonymous #2'); + } + finally { + a.cleanup(); + b.cleanup(); + } + }); + + it('removes the template from registry BEFORE invoking the user onDestroyed callback', () => { + const observed = vi.fn(); + const { template, cleanup } = freshTemplate({ + templateName: 'registry-order', + onDestroyed() { + observed(snapshotRegistry().counts['registry-order'] || 0); + }, + }); + try { + template.initialize(); + template.onDestroyed(); + // when the user callback ran, this template was already removed + expect(observed).toHaveBeenCalledWith(0); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + SSR sequence pinning +*******************************/ + +describe('Template lifecycle — SSR sequence (D3 pin)', () => { + let originalIsServer; + + beforeEach(() => { + originalIsServer = Template.isServer; + }); + + afterEach(() => { + Template.isServer = originalIsServer; + }); + + it('runs onCreated callback on the server', () => { + Template.isServer = true; + const onCreated = vi.fn(); + const { template, cleanup } = freshTemplate({ onCreated }); + try { + template.initialize(); + expect(onCreated).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('does not dispatch the created DOM event on the server (dispatchEvent early-returns)', () => { + Template.isServer = true; + const fixture = mountTemplateInShadow({ + template: '', + }); + const heard = vi.fn(); + fixture.host.addEventListener('created', heard); + try { + // Re-trigger the wrapper after setting isServer=true. The dispatchEvent + // method early-returns at template.js:899 when Template.isServer. + fixture.template.onCreated(); + expect(heard).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('Template.render() schedules onRendered via setTimeout regardless of Template.isServer toggle', async () => { + // Surface finding: render() at template.js:741 gates on the imported + // `isServer` from utils (frozen at module init), not on Template.isServer. + // So toggling Template.isServer in tests does NOT suppress onRendered + // scheduling. The actual server-side path (Node) sets utils.isServer=true + // at import time. Pinning current behavior; this is a candidate for the + // D3 doc-cleanup follow-up. + Template.isServer = true; + const onRendered = vi.fn(); + const { template, cleanup } = freshTemplate({ onRendered }); + try { + template.initialize(); + template.render(); + await new Promise(r => setTimeout(r, 5)); + expect(onRendered).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('does not install the theme MutationObserver on the server', () => { + Template.isServer = true; + const fixture = mountTemplateInShadow({ + template: '', + onThemeChanged: () => {}, + }); + try { + expect(fixture.template.observers.length).toBe(0); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + Cleanup contract on destroy +*******************************/ + +describe('Template lifecycle — destroy cleanup contract', () => { + it('aborts the abortController signal', () => { + const { template, cleanup } = freshTemplate(); + try { + template.initialize(); + expect(template.abortSignal.aborted).toBe(false); + template.onDestroyed(); + expect(template.abortSignal.aborted).toBe(true); + } + finally { + cleanup(); + } + }); + + it('stops registered reactions and clears the array entries', () => { + const { template, cleanup } = freshTemplate(); + try { + template.initialize(); + // initialize() pushed at least the state-driven onUpdated reaction + // (only when this.element is truthy — freshTemplate has no element). + // Add one explicitly so we can observe it. + let runs = 0; + template.reaction(() => { + runs++; + }); + Reaction.flush(); + expect(runs).toBe(1); + const reactions = template.reactions; + template.onDestroyed(); + // each reaction.stop() is called — Reaction.stop sets _stopped flag + reactions.forEach(r => { + expect(r._stopped !== undefined ? r._stopped : true).toBeTruthy(); + }); + } + finally { + cleanup(); + } + }); + + it('flips destroyed=true and rendered=false on destroy', () => { + const { template, cleanup } = freshTemplate(); + try { + template.initialize(); + template.markRendered(); + expect(template.rendered).toBe(true); + template.onDestroyed(); + expect(template.destroyed).toBe(true); + expect(template.rendered).toBe(false); + } + finally { + cleanup(); + } + }); + + it('detaches from parentTemplate._childTemplates on destroy', () => { + const parent = freshTemplate(); + const child = freshTemplate(); + try { + parent.template.initialize(); + child.template.initialize(); + child.template.setParent(parent.template); + expect(parent.template._childTemplates.length).toBe(1); + child.template.onDestroyed(); + expect(parent.template._childTemplates.length).toBe(0); + } + finally { + parent.cleanup(); + // child already destroyed; cleanup is a no-op in that branch + child.cleanup(); + } + }); + + it('aborts the eventController on destroy (cascades from abortSignal)', () => { + const fixture = mountTemplateInShadow({ + template: '', + events: { 'click span'() {} }, + }); + try { + const ec = fixture.template.eventController; + expect(ec).toBeDefined(); + expect(ec.signal.aborted).toBe(false); + fixture.template.onDestroyed(); + expect(ec.signal.aborted).toBe(true); + } + finally { + fixture.cleanup(); + } + }); + + it('leaves the registry empty after a clean lifecycle (leak check)', () => { + const { template, cleanup } = freshTemplate({ templateName: 'leak-check' }); + try { + template.initialize(); + template.onDestroyed(); + assertRegistryEmpty(); + } + finally { + cleanup(); + } + }); +}); diff --git a/packages/templating/test/browser/subtemplate-composition.test.js b/packages/templating/test/browser/subtemplate-composition.test.js new file mode 100644 index 000000000..384088d06 --- /dev/null +++ b/packages/templating/test/browser/subtemplate-composition.test.js @@ -0,0 +1,220 @@ +// Surface 7 (browser) — Subtemplate composition with real DOM element shapes. +// +// The bulk of subtemplate-settings semantics is covered in the node test file +// (`test/subtemplate-settings.test.js`). This file pins the cases that need a +// real Element with a real shadowRoot — primarily the parent-fallback path +// where `parentTemplate.element?.settings` is reached through `this.element` +// after the renderer's setElement(parent.element) wiring. +// +// Tests use `mountSubtemplateInShadow` which mirrors the renderer wiring: +// parent gets host element + shadow root +// child gets the parent's host as its `.element` +// child.setParent(parent) + child.initialize() + +import { Reaction } from '@semantic-ui/reactivity'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Template } from '../../src/template.js'; +import { mountSubtemplateInShadow, mountTemplateInShadow } from '../_helpers/browser-fixture.js'; +import { clearTemplateRegistry } from '../_helpers/registry-cleanup.js'; +import { stubEngine } from '../_helpers/stub-engine.js'; + +afterEach(() => { + clearTemplateRegistry(); + document.body.innerHTML = ''; +}); + +/******************************* + Parent-fallback (live element) +*******************************/ + +describe('Subtemplate settings — parent fallback through real element', () => { + it('reads own settings (no fallback) when key is in defaultSettings', () => { + const fixture = mountSubtemplateInShadow({ + childDefaultSettings: { ownProp: 'X' }, + }); + // Attach .settings on the host element so the fallback path captures it. + fixture.parentHost.settings = { brand: 'parent-brand' }; + try { + // The child was constructed inside the fixture without yet capturing + // parent settings — re-init by recreating the Proxy via direct call. + // (The fixture initialized before settings was attached. For this + // suite we test against a fresh re-init flow.) + fixture.child.createSubtemplateSettings(); + expect(fixture.child.settings.ownProp).toBe('X'); + } + finally { + fixture.cleanup(); + } + }); + + it('falls back to parent host element.settings for keys not in defaultSettings', () => { + const fixture = mountSubtemplateInShadow({ + childDefaultSettings: { ownProp: 'X' }, + }); + fixture.parentHost.settings = { brand: 'parent-brand' }; + try { + fixture.child.createSubtemplateSettings(); + // Own key resolves to defaultSettings; parent key falls through. + expect(fixture.child.settings.ownProp).toBe('X'); + expect(fixture.child.settings.brand).toBe('parent-brand'); + expect(fixture.child.settings.notInEither).toBeUndefined(); + } + finally { + fixture.cleanup(); + } + }); + + it('shadows the parent fallback once an unrelated key is written through the Proxy', () => { + const fixture = mountSubtemplateInShadow({ + childDefaultSettings: { ownProp: 'X' }, + }); + fixture.parentHost.settings = { brand: 'parent-brand' }; + try { + fixture.child.createSubtemplateSettings(); + expect(fixture.child.settings.brand).toBe('parent-brand'); + fixture.child.settings.brand = 'shadowed'; + expect(fixture.child.settings.brand).toBe('shadowed'); + // Parent settings object is untouched + expect(fixture.parentHost.settings.brand).toBe('parent-brand'); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + Reactivity through the Proxy with real element +*******************************/ + +describe('Subtemplate settings — reactivity in browser context', () => { + it('a Reaction reading through the Proxy fires when the same key is updated via updateSubtemplateSettings', () => { + const fixture = mountSubtemplateInShadow({ + childDefaultSettings: { theme: 'light' }, + }); + let runs = 0; + let observed; + let reaction; + try { + reaction = Reaction.create(() => { + runs++; + observed = fixture.child.settings.theme; + }); + expect(runs).toBe(1); + expect(observed).toBe('light'); + + fixture.child.updateSubtemplateSettings({ theme: 'dark' }); + Reaction.flush(); + expect(runs).toBe(2); + expect(observed).toBe('dark'); + } + finally { + reaction?.stop(); + fixture.cleanup(); + } + }); +}); + +/******************************* + Production wiring +*******************************/ + +describe('Subtemplate composition — production clone+wire+initialize sequence', () => { + it('clone → setElement(parent.element) → setParent → initialize completes and settings work end-to-end', () => { + // Mount the parent fixture; we'll manually carry out the clone sequence + // for the child to mirror the renderer's behavior in production. + const parentFixture = mountTemplateInShadow({ + template: '
', + }); + parentFixture.host.settings = { brand: 'parent' }; + + const proto = new Template({ + template: '{theme}', + templateName: 'protoChild', + defaultSettings: { theme: 'light' }, + renderingEngine: stubEngine, + }); + + let child; + try { + child = proto.clone({ + parentTemplate: parentFixture.template, + data: { theme: 'dark' }, + }); + child.setElement(parentFixture.host); + child.setParent(parentFixture.template); + child.initialize(); + + expect(child.isSubtemplate()).toBe(true); + expect(child.settings.theme).toBe('dark'); // data overrides default + expect(child.settings.brand).toBe('parent'); // parent element fallback + expect(parentFixture.template._childTemplates).toContain(child); + } + finally { + try { + child?.onDestroyed(); + } + catch (_) {} + parentFixture.cleanup(); + } + }); + + it('two independently-cloned children of the same prototype have FRESH state and settings signals', () => { + const parentFixture = mountTemplateInShadow({ + template: '
', + }); + parentFixture.host.settings = {}; + + const proto = new Template({ + template: '{theme}', + defaultSettings: { theme: 'light' }, + defaultState: { count: 0 }, + renderingEngine: stubEngine, + }); + + let childA, childB; + try { + childA = proto.clone({ + parentTemplate: parentFixture.template, + data: { theme: 'red' }, + }); + childA.setElement(parentFixture.host); + childA.setParent(parentFixture.template); + childA.initialize(); + + childB = proto.clone({ + parentTemplate: parentFixture.template, + data: { theme: 'blue' }, + }); + childB.setElement(parentFixture.host); + childB.setParent(parentFixture.template); + childB.initialize(); + + // settings are independent + expect(childA.settings.theme).toBe('red'); + expect(childB.settings.theme).toBe('blue'); + + // settingsVars Maps are independent objects + expect(childA.settingsVars).not.toBe(childB.settingsVars); + + // state Signals are independent + expect(childA.state.count).not.toBe(childB.state.count); + + // Both children appear in parent._childTemplates + expect(parentFixture.template._childTemplates).toContain(childA); + expect(parentFixture.template._childTemplates).toContain(childB); + } + finally { + try { + childA?.onDestroyed(); + } + catch (_) {} + try { + childB?.onDestroyed(); + } + catch (_) {} + parentFixture.cleanup(); + } + }); +}); diff --git a/packages/templating/test/browser/tree-traversal-dom.test.js b/packages/templating/test/browser/tree-traversal-dom.test.js new file mode 100644 index 000000000..54d8e7d15 --- /dev/null +++ b/packages/templating/test/browser/tree-traversal-dom.test.js @@ -0,0 +1,728 @@ +// Surface 8 — Tree traversal: DOM cascade. +// +// These tests need a real DOM with shadow roots and elements that have +// `.component` and `.dataContext` (the wiring the component package does for +// real web components). We sidestep the component package (circular dep +// boundary, see _helpers/README.md) by assigning .component / .dataContext +// directly on host elements — sufficient to exercise the DOM cascade walk +// in template.js: findParentTemplate / findChildTemplates. +// +// Pinned bugs (do NOT fix here, just pin): +// - B3: DOM cascade returns { ...component, ...dataContext } — leaks state +// Signals (dataContext = data + state + instance). Locked decision: should +// return component (instance) only. +// - B5: findParent('uiPanels') (camel) works; findParent('ui-panels') (kebab) +// misses today. Locked decision: kebabToCamel input normalization at all +// instance binders. + +import { Signal } from '@semantic-ui/reactivity'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Template } from '../../src/template.js'; +import { mountTemplateInShadow } from '../_helpers/browser-fixture.js'; +import { freshTemplate } from '../_helpers/fresh-template.js'; +import { assertRegistryEmpty, clearTemplateRegistry, snapshotRegistry } from '../_helpers/registry-cleanup.js'; +import { stubEngine } from '../_helpers/stub-engine.js'; + +afterEach(() => { + clearTemplateRegistry(); + document.body.innerHTML = ''; +}); + +/******************************* + Helper: stand up a parent + web-component-style host with + .component, .dataContext, and + a shadow root containing a + child host +*******************************/ + +/** + * Build a parent host element with a shadow root that contains a child host + * element. Both hosts have a `.component` reference (template.instance + * standin) and a `.dataContext` (template.getDataContext() standin) — the + * wiring the component package does for real web components. + * + * Returns the assembled DOM and the templates so tests can call findParent + * from the child or findChild from the parent. + */ +function buildDomCascade({ + parentTemplateName = 'uiPanels', + childTemplateName = 'uiPanel', + parentInstance = {}, + parentState = {}, + parentData = {}, + childInstance = {}, +} = {}) { + // Build parent host with shadow root + const parentHost = document.createElement('ui-panels'); + const parentShadow = parentHost.attachShadow({ mode: 'open' }); + document.body.appendChild(parentHost); + + // Build child host inside parent's shadow root + const childHost = document.createElement('ui-panel'); + parentShadow.appendChild(childHost); + + // Stand up Templates so registry interactions are realistic + const parentTpl = new Template({ + renderingEngine: stubEngine, + template: '', + templateName: parentTemplateName, + element: parentHost, + createComponent: () => parentInstance, + defaultState: parentState, + data: parentData, + }); + parentTpl.initialize(); + // Mirror the component package wiring (base.js:133-134) + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const childTpl = new Template({ + renderingEngine: stubEngine, + template: '
', + templateName: childTemplateName, + element: childHost, + createComponent: () => childInstance, + }); + childTpl.initialize(); + childHost.component = childTpl.instance; + childHost.dataContext = childTpl.getDataContext(); + + return { + parentHost, + parentShadow, + childHost, + parentTpl, + childTpl, + cleanup() { + try { + childTpl.onDestroyed(); + } + catch (_) {} + try { + parentTpl.onDestroyed(); + } + catch (_) {} + if (parentHost.parentNode) { + parentHost.parentNode.removeChild(parentHost); + } + }, + }; +} + +/******************************* + findParent — DOM cascade +*******************************/ + +describe('findParent — DOM cascade (real shadow root, parent.component wired)', () => { + it('child component finds parent by camelCase templateName', () => { + // The motivating panels-style use case: child reaches up to parent's API. + const fixture = buildDomCascade({ + parentInstance: { + panels: [{ id: 'a' }, { id: 'b' }], + isHidden(index) { + return false; + }, + }, + }); + try { + const found = fixture.childTpl.findParent('uiPanels'); + expect(found).toBeDefined(); + // Method-call path + expect(typeof found.isHidden).toBe('function'); + expect(found.isHidden(0)).toBe(false); + // Instance-property path + expect(found.panels).toEqual([{ id: 'a' }, { id: 'b' }]); + } + finally { + fixture.cleanup(); + } + }); + + it('walks across shadow boundaries via element.host', () => { + // Set up: outer host -> outer shadow -> middle host -> middle shadow -> inner host. + // The DOM cascade must traverse via .host to cross each shadow boundary. + const outerHost = document.createElement('ui-outer'); + const outerShadow = outerHost.attachShadow({ mode: 'open' }); + document.body.appendChild(outerHost); + + const middleHost = document.createElement('ui-middle'); + const middleShadow = middleHost.attachShadow({ mode: 'open' }); + outerShadow.appendChild(middleHost); + + const innerHost = document.createElement('ui-inner'); + middleShadow.appendChild(innerHost); + + const outerTpl = new Template({ + renderingEngine: stubEngine, + template: '', + templateName: 'outer', + element: outerHost, + createComponent: () => ({ depth: 0 }), + }); + outerTpl.initialize(); + outerHost.component = outerTpl.instance; + outerHost.dataContext = outerTpl.getDataContext(); + + const middleTpl = new Template({ + renderingEngine: stubEngine, + template: '', + templateName: 'middle', + element: middleHost, + createComponent: () => ({ depth: 1 }), + }); + middleTpl.initialize(); + middleHost.component = middleTpl.instance; + middleHost.dataContext = middleTpl.getDataContext(); + + const innerTpl = new Template({ + renderingEngine: stubEngine, + template: '
', + templateName: 'inner', + element: innerHost, + createComponent: () => ({ depth: 2 }), + }); + innerTpl.initialize(); + innerHost.component = innerTpl.instance; + innerHost.dataContext = innerTpl.getDataContext(); + + try { + // Inner finds the outer via three shadow-boundary crossings + const found = innerTpl.findParent('outer'); + expect(found).toBeDefined(); + expect(found.depth).toBe(0); + } + finally { + try { + innerTpl.onDestroyed(); + } + catch (_) {} + try { + middleTpl.onDestroyed(); + } + catch (_) {} + try { + outerTpl.onDestroyed(); + } + catch (_) {} + outerHost.remove(); + } + }); + + it('returns undefined when no ancestor matches the name', () => { + const fixture = buildDomCascade(); + try { + expect(fixture.childTpl.findParent('totallyDifferent')).toBeUndefined(); + } + finally { + fixture.cleanup(); + } + }); + + it('with no name argument, returns the first ancestor with a templateName', () => { + const fixture = buildDomCascade(); + try { + const found = fixture.childTpl.findParent(); + expect(found).toBeDefined(); + expect(found.templateName).toBe('uiPanels'); + } + finally { + fixture.cleanup(); + } + }); + + /******************************* + B3 PIN — DOM cascade returns + instance-only after fix + *******************************/ + + describe('B3 PIN — DOM cascade returns instance-only after fix', () => { + it('PIN: state Signals leak through findParent today — should be undefined after fix', () => { + // Source: template.js:1078-1081 spreads { ...component, ...dataContext }. + // dataContext = extend({}, data, state, instance) so state Signals leak. + const fixture = buildDomCascade({ + parentState: { count: 5 }, + parentInstance: { + publicApi() { + return 'ok'; + }, + }, + }); + try { + const found = fixture.childTpl.findParent('uiPanels'); + expect(found).toBeDefined(); + // Method still works + expect(typeof found.publicApi).toBe('function'); + // EXPECTED-FAIL today (state Signal leaks via dataContext spread). + // After the locked B3 fix, state must NOT be reachable via findParent. + expect(found.count).toBeUndefined(); + } + finally { + fixture.cleanup(); + } + }); + + it('PIN: data closure leaks through findParent today — should be undefined after fix', () => { + // dataContext also includes raw data (the closure data passed to the parent). + const fixture = buildDomCascade({ + parentData: { secret: 'closure-leak' }, + parentInstance: { + publicApi() { + return 'ok'; + }, + }, + }); + try { + const found = fixture.childTpl.findParent('uiPanels'); + expect(typeof found.publicApi).toBe('function'); + // EXPECTED-FAIL today; passes after fix. + expect(found.secret).toBeUndefined(); + } + finally { + fixture.cleanup(); + } + }); + + it('PIN: cross-cascade convergence — DOM and subtemplate cascades return same shape', () => { + // Same parent component, accessed via both cascades. After the fix + // both must return objects with the same key set: instance methods + // and properties only — no state Signals, no closure data. + const parentInstance = { + hello() { + return 'world'; + }, + magic: 1, + }; + + // DOM cascade fixture + const dom = buildDomCascade({ + parentTemplateName: 'sharedShape', + childTemplateName: 'kid', + parentInstance, + parentState: { count: 0 }, + parentData: { secret: 'leak' }, + }); + + try { + const fromDom = dom.childTpl.findParent('sharedShape'); + // EXPECTED-FAIL today (DOM cascade leaks state and data; subtemplate + // leaks only data). After fix both should match. + expect(fromDom.count).toBeUndefined(); + expect(fromDom.secret).toBeUndefined(); + // Both paths must continue to deliver the public API + expect(typeof fromDom.hello).toBe('function'); + expect(fromDom.magic).toBe(1); + } + finally { + dom.cleanup(); + } + }); + }); + + /******************************* + B5 PIN — kebab form forgiveness + *******************************/ + + describe('B5 PIN — findParent forgiving lookup (DOM cascade)', () => { + it('findParent("uiPanels") works (camel form, baseline)', () => { + const fixture = buildDomCascade({ + parentInstance: { ok: true }, + }); + try { + const found = fixture.childTpl.findParent('uiPanels'); + expect(found).toBeDefined(); + expect(found.ok).toBe(true); + } + finally { + fixture.cleanup(); + } + }); + + it('PIN: findParent("ui-panels") (kebab) ALSO succeeds — EXPECTED FAIL today', () => { + const fixture = buildDomCascade({ + parentInstance: { ok: true }, + }); + try { + const found = fixture.childTpl.findParent('ui-panels'); + expect(found).toBeDefined(); + expect(found.ok).toBe(true); + } + finally { + fixture.cleanup(); + } + }); + + it('PIN: kebab and camel inputs converge on the same Template after fix', () => { + const fixture = buildDomCascade({ + parentInstance: { ok: true, sentinel: Math.random() }, + }); + try { + const camel = fixture.childTpl.findParent('uiPanels'); + const kebab = fixture.childTpl.findParent('ui-panels'); + expect(camel).toBeDefined(); + // EXPECTED-FAIL today; passes after fix. + expect(kebab).toBeDefined(); + expect(camel.sentinel).toBe(kebab.sentinel); + } + finally { + fixture.cleanup(); + } + }); + }); +}); + +/******************************* + findChild / findChildren — + DOM cascade +*******************************/ + +describe('findChild / findChildren — DOM cascade', () => { + it('finds direct DOM child by templateName', () => { + const fixture = buildDomCascade({ + childInstance: { theId: 'kid' }, + }); + try { + const found = fixture.parentTpl.findChild('uiPanel'); + expect(found).toBeDefined(); + expect(found.theId).toBe('kid'); + } + finally { + fixture.cleanup(); + } + }); + + it('findChildren returns ALL matching DOM children', () => { + // Build parent with three matching child hosts in its shadow root + const parentHost = document.createElement('ui-list'); + const shadow = parentHost.attachShadow({ mode: 'open' }); + document.body.appendChild(parentHost); + + const parentTpl = new Template({ + renderingEngine: stubEngine, + template: '', + templateName: 'list', + element: parentHost, + }); + parentTpl.initialize(); + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const children = []; + for (let i = 0; i < 3; i++) { + const childHost = document.createElement('ui-row'); + shadow.appendChild(childHost); + const childTpl = new Template({ + renderingEngine: stubEngine, + template: '
', + templateName: 'row', + element: childHost, + createComponent: () => ({ idx: i }), + }); + childTpl.initialize(); + childHost.component = childTpl.instance; + childHost.dataContext = childTpl.getDataContext(); + children.push(childTpl); + } + + try { + const found = parentTpl.findChildren('row'); + expect(Array.isArray(found)).toBe(true); + expect(found.length).toBe(3); + expect(found.map(f => f.idx)).toEqual([0, 1, 2]); + } + finally { + children.forEach(c => { + try { + c.onDestroyed(); + } + catch (_) {} + }); + try { + parentTpl.onDestroyed(); + } + catch (_) {} + parentHost.remove(); + } + }); + + it('recurses into 2-deep nested DOM child shadow roots', () => { + // outer (shadow) -> middle (shadow) -> inner + const outerHost = document.createElement('ui-outer'); + const outerShadow = outerHost.attachShadow({ mode: 'open' }); + document.body.appendChild(outerHost); + const outerTpl = new Template({ + renderingEngine: stubEngine, + template: '', + templateName: 'outer', + element: outerHost, + }); + outerTpl.initialize(); + outerHost.component = outerTpl.instance; + outerHost.dataContext = outerTpl.getDataContext(); + + const middleHost = document.createElement('ui-middle'); + const middleShadow = middleHost.attachShadow({ mode: 'open' }); + outerShadow.appendChild(middleHost); + const middleTpl = new Template({ + renderingEngine: stubEngine, + template: '', + templateName: 'middle', + element: middleHost, + }); + middleTpl.initialize(); + middleHost.component = middleTpl.instance; + middleHost.dataContext = middleTpl.getDataContext(); + + const innerHost = document.createElement('ui-inner'); + middleShadow.appendChild(innerHost); + const innerTpl = new Template({ + renderingEngine: stubEngine, + template: '
', + templateName: 'inner', + element: innerHost, + createComponent: () => ({ deep: true }), + }); + innerTpl.initialize(); + innerHost.component = innerTpl.instance; + innerHost.dataContext = innerTpl.getDataContext(); + + try { + const found = outerTpl.findChild('inner'); + expect(found).toBeDefined(); + expect(found.deep).toBe(true); + } + finally { + try { + innerTpl.onDestroyed(); + } + catch (_) {} + try { + middleTpl.onDestroyed(); + } + catch (_) {} + try { + outerTpl.onDestroyed(); + } + catch (_) {} + outerHost.remove(); + } + }); + + it('returns empty array when no DOM children match', () => { + const fixture = buildDomCascade(); + try { + const found = fixture.parentTpl.findChildren('nothingMatches'); + expect(found).toEqual([]); + } + finally { + fixture.cleanup(); + } + }); + + /******************************* + B3 PIN — findChild DOM cascade leaks + *******************************/ + + describe('B3 PIN — findChild DOM cascade returns instance-only after fix', () => { + it('PIN: state Signals leak through findChild today — should be undefined after fix', () => { + const parentHost = document.createElement('ui-list'); + const shadow = parentHost.attachShadow({ mode: 'open' }); + document.body.appendChild(parentHost); + + const parentTpl = new Template({ + renderingEngine: stubEngine, + template: '', + templateName: 'list', + element: parentHost, + }); + parentTpl.initialize(); + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const childHost = document.createElement('ui-row'); + shadow.appendChild(childHost); + const childTpl = new Template({ + renderingEngine: stubEngine, + template: '
', + templateName: 'row', + element: childHost, + defaultState: { count: 7 }, + createComponent: () => ({ + publicApi() { + return 'ok'; + }, + }), + }); + childTpl.initialize(); + childHost.component = childTpl.instance; + childHost.dataContext = childTpl.getDataContext(); + + try { + const found = parentTpl.findChild('row'); + expect(found).toBeDefined(); + expect(typeof found.publicApi).toBe('function'); + // EXPECTED-FAIL today; passes after fix. + expect(found.count).toBeUndefined(); + } + finally { + try { + childTpl.onDestroyed(); + } + catch (_) {} + try { + parentTpl.onDestroyed(); + } + catch (_) {} + parentHost.remove(); + } + }); + }); + + /******************************* + B5 PIN — findChild kebab form + *******************************/ + + describe('B5 PIN — findChild forgiving lookup (DOM cascade)', () => { + it('findChild("uiPanel") works (camel form, baseline)', () => { + const fixture = buildDomCascade({ + childInstance: { ok: true }, + }); + try { + expect(fixture.parentTpl.findChild('uiPanel')).toBeDefined(); + } + finally { + fixture.cleanup(); + } + }); + + it('PIN: findChild("ui-panel") (kebab) ALSO succeeds — EXPECTED FAIL today', () => { + const fixture = buildDomCascade({ + childInstance: { ok: true }, + }); + try { + const found = fixture.parentTpl.findChild('ui-panel'); + expect(found).toBeDefined(); + expect(found.ok).toBe(true); + } + finally { + fixture.cleanup(); + } + }); + }); +}); + +/******************************* + Cross-cascade precedence + (second-loop guard pin) +*******************************/ + +describe('findParent precedence — DOM cascade wins over subtemplate cascade', () => { + it('PIN: when both DOM ancestor and subtemplate parent share the templateName, DOM match wins', () => { + // Today: code structure (template.js:1086-1097) iterates the subtemplate + // chain unconditionally after the DOM walk, but the body is short- + // circuited by `match || ...` in isMatch. This works today and we pin + // the precedence so a future "cleanup" of isMatch can't silently flip it. + const fixture = buildDomCascade({ + parentInstance: { source: 'dom' }, + }); + + // Wire the child to ALSO have a subtemplate parent with the same + // templateName but different identity (so we can tell which won). + const altParent = new Template({ + renderingEngine: stubEngine, + template: '
', + templateName: 'uiPanels', + createComponent: () => ({ source: 'subtemplate' }), + }); + altParent.initialize(); + fixture.childTpl.setParent(altParent); + + try { + const found = fixture.childTpl.findParent('uiPanels'); + expect(found).toBeDefined(); + // DOM cascade wins + expect(found.source).toBe('dom'); + } + finally { + try { + altParent.onDestroyed(); + } + catch (_) {} + fixture.cleanup(); + } + }); + + it('falls back to subtemplate cascade when DOM ancestor does not match', () => { + const fixture = buildDomCascade({ + parentTemplateName: 'differentName', + parentInstance: { source: 'dom-no-match' }, + }); + + const subParent = new Template({ + renderingEngine: stubEngine, + template: '
', + templateName: 'wantedParent', + createComponent: () => ({ source: 'subtemplate-fallback' }), + }); + subParent.initialize(); + fixture.childTpl.setParent(subParent); + + try { + const found = fixture.childTpl.findParent('wantedParent'); + expect(found).toBeDefined(); + expect(found.source).toBe('subtemplate-fallback'); + } + finally { + try { + subParent.onDestroyed(); + } + catch (_) {} + fixture.cleanup(); + } + }); +}); + +/******************************* + Heap / GC — full DOM cycle +*******************************/ + +describe('heap / GC — DOM mount/unmount returns registry to empty', () => { + it('mount + unmount of a DOM cascade fixture leaves the registry clean', () => { + expect(snapshotRegistry().totalInstances).toBe(0); + const fixture = buildDomCascade(); + expect(snapshotRegistry().totalInstances).toBe(2); + fixture.cleanup(); + assertRegistryEmpty(); + }); + + it('100x DOM mount/unmount cycle does not leak registry entries', () => { + // Mirror the user's heap-leak concern: real DOM hosts + shadow roots, + // create + destroy in a tight loop. After the run, registry must be 0. + for (let i = 0; i < 100; i++) { + const fixture = buildDomCascade({ + parentTemplateName: 'cycleParent', + childTemplateName: 'cycleChild', + }); + fixture.cleanup(); + } + assertRegistryEmpty(); + }); + + it('child host detachment + onDestroyed clears entry from registry', () => { + const fixture = buildDomCascade(); + expect(snapshotRegistry().counts.uiPanel).toBe(1); + expect(snapshotRegistry().counts.uiPanels).toBe(1); + + // Detach the child host from the DOM and destroy its template + fixture.childHost.remove(); + fixture.childTpl.onDestroyed(); + expect(snapshotRegistry().counts.uiPanel || 0).toBe(0); + // Parent still around + expect(snapshotRegistry().counts.uiPanels).toBe(1); + + fixture.parentTpl.onDestroyed(); + fixture.parentHost.remove(); + assertRegistryEmpty(); + }); +}); diff --git a/packages/templating/test/data-context.test.js b/packages/templating/test/data-context.test.js new file mode 100644 index 000000000..44fc61850 --- /dev/null +++ b/packages/templating/test/data-context.test.js @@ -0,0 +1,931 @@ +// Surface 6 — Data context construction. +// +// Pure-logic tests for createReactiveState, setDataContext, getDataContext, +// overlaySettingsSignals, markRendered. Render coordination (engine call +// counts) lives in test/browser/data-context-render.test.js — not here. +// +// Pins / contracts: +// B1 (FAILING — locked contract): createReactiveState should treat +// `data[name] !== undefined` as override (matches Surface 7 behavior). +// Falsy values 0, false, '', null SHOULD seed the Signal. Today the +// truthy check on line 118 silently skips these. Tests are EXPECTED TO +// FAIL until the fix lands. +// D8: settings-via-overlay wins over state on key collision. Skill drift +// claims state wins; the source overlays settings AFTER the spread. +// L2: setDataContext deletes orphaned keys silently via assignInPlace's +// default mode. +// L3: dataReplaced flag stays true after first render (sticky). +// C3: subtemplate settings (Surface 7) already use `!== undefined`; pin +// that behavior here against regression when B1 fix lands. + +import { afterEach, describe, expect, it } from 'vitest'; + +import { Reaction, Signal } from '@semantic-ui/reactivity'; +import { extend } from '@semantic-ui/utils'; + +import { Template } from '../src/template.js'; +import { freshTemplate } from './_helpers/fresh-template.js'; +import { clearTemplateRegistry } from './_helpers/registry-cleanup.js'; + +afterEach(() => { + clearTemplateRegistry(); +}); + +/******************************* + createReactiveState +*******************************/ + +describe('Template — createReactiveState', () => { + it('wraps each defaultState entry in a Signal', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { count: 0, name: 'jack' }, + }); + try { + expect(template.state.count).toBeInstanceOf(Signal); + expect(template.state.name).toBeInstanceOf(Signal); + } + finally { + cleanup(); + } + }); + + it('initializes simple { count: 0 } config as Signal(0)', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { count: 0 }, + }); + try { + expect(template.state.count.peek()).toBe(0); + } + finally { + cleanup(); + } + }); + + it('forwards options for complex { value, options } config', () => { + // Custom equalityFunction lets us prove options reached the Signal. + // Default Signal equality treats deep-equal objects as equal; with + // a strict-reference equality, two structurally-equal objects differ. + const strictEquality = (a, b) => a === b; + const { template, cleanup } = freshTemplate({ + defaultState: { + config: { + value: { x: 1 }, + options: { equalityFunction: strictEquality, allowClone: false }, + }, + }, + }); + try { + const signal = template.state.config; + expect(signal).toBeInstanceOf(Signal); + expect(signal.peek()).toEqual({ x: 1 }); + // Strict equality: same-shape but different-reference is "changed". + let observed = 0; + const r = Reaction.create(() => { + signal.get(); + observed++; + }); + Reaction.flush(); + const before = observed; + signal.set({ x: 1 }); // structurally equal + Reaction.flush(); + // With strict equality, even structurally-equal value triggers update. + expect(observed).toBeGreaterThan(before); + r.stop(); + } + finally { + cleanup(); + } + }); + + it('returns {} when defaultState is undefined', () => { + const { template, cleanup } = freshTemplate(); + try { + expect(template.state).toEqual({}); + } + finally { + cleanup(); + } + }); + + it('uses defaultState as-is when data is undefined', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { count: 5 }, + }); + try { + expect(template.state.count.peek()).toBe(5); + } + finally { + cleanup(); + } + }); + + /******************************* + truthy override (sanity) + *******************************/ + + it('lets truthy data override defaultState', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { count: 5 }, + data: { count: 10 }, + }); + try { + expect(template.state.count.peek()).toBe(10); + } + finally { + cleanup(); + } + }); + + it('lets default work when data omits the key', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { count: 5 }, + data: {}, + }); + try { + expect(template.state.count.peek()).toBe(5); + } + finally { + cleanup(); + } + }); + + /******************************* + B1 PIN — falsy override + *******************************/ + + // Locked Stage 1.5 contract: `null` is treated as override (matches + // Surface 7's `!== undefined` precedent). Today template.js:118 uses + // `if (dataValue)` which silently skips falsy overrides. These tests + // are EXPECTED TO FAIL until B1 is fixed by changing line 118 to + // `if (dataValue !== undefined)`. + + describe('B1 PIN — falsy data override (EXPECTED TO FAIL pre-fix)', () => { + it('seeds Signal with 0 when data: { count: 0 }', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { count: 5 }, + data: { count: 0 }, + }); + try { + expect(template.state.count.peek()).toBe(0); + } + finally { + cleanup(); + } + }); + + it('seeds Signal with false when data: { active: false }', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { active: true }, + data: { active: false }, + }); + try { + expect(template.state.active.peek()).toBe(false); + } + finally { + cleanup(); + } + }); + + it('seeds Signal with empty string when data: { name: "" }', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { name: 'default' }, + data: { name: '' }, + }); + try { + expect(template.state.name.peek()).toBe(''); + } + finally { + cleanup(); + } + }); + + it('seeds Signal with null when data: { value: null }', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { value: 'default' }, + data: { value: null }, + }); + try { + expect(template.state.value.peek()).toBe(null); + } + finally { + cleanup(); + } + }); + }); +}); + +/******************************* + getDataContext — merge order +*******************************/ + +// Doc claim: data context is "flat — instance, then state, then data". The +// implementation is `extend({}, this.data, this.state, this.instance)`, so +// last-wins precedence is instance > state > data. +// +// Note: settings are NOT in getDataContext output — they enter via +// overlaySettingsSignals after the spread. + +describe('Template — getDataContext merge order', () => { + it('returns data only when only data is set', () => { + const { template, cleanup } = freshTemplate({ + data: { name: 'jack' }, + }); + try { + const ctx = template.getDataContext(); + expect(ctx).toEqual({ name: 'jack' }); + } + finally { + cleanup(); + } + }); + + it('returns state Signals only when only defaultState is set', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { count: 7 }, + }); + try { + const ctx = template.getDataContext(); + expect(ctx.count).toBeInstanceOf(Signal); + expect(ctx.count.peek()).toBe(7); + } + finally { + cleanup(); + } + }); + + it('includes instance properties from createComponent', () => { + const { template, cleanup } = freshTemplate({ + createComponent: () => ({ greet: 'hi' }), + }); + try { + template.initialize(); + const ctx = template.getDataContext(); + expect(ctx.greet).toBe('hi'); + } + finally { + cleanup(); + } + }); + + it('lets state Signal win over data on key collision', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { name: 'sally' }, + data: { name: 'jack' }, + }); + try { + const ctx = template.getDataContext(); + // state wraps the same key — state's Signal wins by spread order + expect(ctx.name).toBeInstanceOf(Signal); + // data override is truthy so the Signal seeds with 'jack' (per + // truthy-only override behavior); the assertion here is only that + // STATE wins the merge, not what the Signal happens to hold. + expect(ctx.name.peek()).toBe('jack'); + } + finally { + cleanup(); + } + }); + + it('lets instance win over data on key collision', () => { + const { template, cleanup } = freshTemplate({ + data: { name: 'jack' }, + createComponent: () => ({ name: 'bob' }), + }); + try { + template.initialize(); + const ctx = template.getDataContext(); + expect(ctx.name).toBe('bob'); + } + finally { + cleanup(); + } + }); + + it('lets instance win over state on key collision', () => { + const { template, cleanup } = freshTemplate({ + defaultState: { name: 'sally' }, + createComponent: () => ({ name: 'bob' }), + }); + try { + template.initialize(); + const ctx = template.getDataContext(); + expect(ctx.name).toBe('bob'); + } + finally { + cleanup(); + } + }); + + it('lets instance win across all three layers (transitive)', () => { + const { template, cleanup } = freshTemplate({ + data: { value: 'data' }, + defaultState: { value: 'state' }, + createComponent: () => ({ value: 'instance' }), + }); + try { + template.initialize(); + const ctx = template.getDataContext(); + expect(ctx.value).toBe('instance'); + } + finally { + cleanup(); + } + }); + + it('returns a fresh object on every call', () => { + const { template, cleanup } = freshTemplate({ + data: { name: 'jack' }, + }); + try { + const a = template.getDataContext(); + const b = template.getDataContext(); + expect(a).not.toBe(b); + expect(a).toEqual(b); + } + finally { + cleanup(); + } + }); + + it('does NOT include settings (settings enter via overlay)', () => { + // Stub element with settings only — these should NOT appear in + // getDataContext output. They only land via overlaySettingsSignals. + const fakeElement = { + settings: { color: 'blue' }, + settingsVars: new Map([['color', new Signal('blue')]]), + defaultSettings: { color: 'blue' }, + }; + const { template, cleanup } = freshTemplate({ + element: fakeElement, + }); + try { + const ctx = template.getDataContext(); + expect(ctx.color).toBeUndefined(); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + setDataContext +*******************************/ + +describe('Template — setDataContext', () => { + it('merges new keys into this.data', () => { + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + }); + try { + template.setDataContext({ a: 1, b: 2 }); + expect(template.data).toEqual({ a: 1, b: 2 }); + } + finally { + cleanup(); + } + }); + + it('sets dataReplaced=true when something changes', () => { + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + }); + try { + template.dataReplaced = false; + template.setDataContext({ a: 1, b: 2 }); + expect(template.dataReplaced).toBe(true); + } + finally { + cleanup(); + } + }); + + it('does NOT set dataReplaced when nothing changes', () => { + const { template, cleanup } = freshTemplate({ + data: { a: 1, b: 2 }, + }); + try { + template.dataReplaced = false; + template.setDataContext({ a: 1, b: 2 }); + expect(template.dataReplaced).toBe(false); + } + finally { + cleanup(); + } + }); + + it('default { rerender: true } resets this.rendered to false', () => { + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + }); + try { + template.rendered = true; + template.setDataContext({ a: 2 }); + expect(template.rendered).toBe(false); + } + finally { + cleanup(); + } + }); + + it('{ rerender: false } preserves this.rendered', () => { + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + }); + try { + template.rendered = true; + template.setDataContext({ a: 2 }, { rerender: false }); + expect(template.rendered).toBe(true); + } + finally { + cleanup(); + } + }); + + /******************************* + L2 PIN — orphan key deletion + *******************************/ + + it('L2 PIN — deletes orphaned keys silently (assignInPlace default)', () => { + // assignInPlace deletes keys on target that aren't in source unless + // preserveExistingKeys is true. setDataContext does NOT pass it, so + // missing keys disappear. + const { template, cleanup } = freshTemplate({ + data: { a: 1, b: 2 }, + }); + try { + template.setDataContext({ a: 1 }); // no `b` + expect(template.data).toEqual({ a: 1 }); + expect('b' in template.data).toBe(false); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + overlaySettingsSignals +*******************************/ + +describe('Template — overlaySettingsSignals (subtemplate path)', () => { + it('is a no-op when settingsVars is not set', () => { + const { template: parent, cleanup: cleanupParent } = freshTemplate(); + const { template: child, cleanup: cleanupChild } = freshTemplate({ + defaultSettings: { color: 'blue' }, + }); + child.setParent(parent); + try { + // No settingsVars → nothing overlaid; context returned unchanged. + const ctx = { existing: 'value' }; + const result = child.overlaySettingsSignals(ctx); + expect(result).toBe(ctx); + expect(result.color).toBeUndefined(); + } + finally { + cleanupChild(); + cleanupParent(); + } + }); + + it('is a no-op when defaultSettings is missing', () => { + const { template: parent, cleanup: cleanupParent } = freshTemplate(); + const { template: child, cleanup: cleanupChild } = freshTemplate(); + child.setParent(parent); + // settingsVars set but defaultSettings absent → branch falls through + child.settingsVars = new Map([['color', new Signal('red')]]); + try { + const ctx = {}; + child.overlaySettingsSignals(ctx); + expect(ctx.color).toBeUndefined(); + } + finally { + cleanupChild(); + cleanupParent(); + } + }); + + it('overlays Signals from settingsVars onto context', () => { + const { template: parent, cleanup: cleanupParent } = freshTemplate(); + const { template: child, cleanup: cleanupChild } = freshTemplate({ + defaultSettings: { color: 'blue' }, + }); + child.setParent(parent); + // Manually wire settingsVars Map (avoid invoking createSubtemplateSettings + // which is Surface 7's territory). Need a settings proxy too because + // overlay walks defaultSettings via `this.settings[name]`. + const colorSignal = new Signal('red'); + child.settingsVars = new Map([['color', colorSignal]]); + child.settings = { color: 'red' }; // proxy stand-in; just needs to read + try { + const ctx = {}; + child.overlaySettingsSignals(ctx); + expect(ctx.color).toBe(colorSignal); + expect(ctx.color.peek()).toBe('red'); + } + finally { + cleanupChild(); + cleanupParent(); + } + }); + + it('makes the Signal win over a plain duplicate from the spread', () => { + const { template: parent, cleanup: cleanupParent } = freshTemplate(); + const { template: child, cleanup: cleanupChild } = freshTemplate({ + defaultSettings: { color: 'blue' }, + }); + child.setParent(parent); + const colorSignal = new Signal('red'); + child.settingsVars = new Map([['color', colorSignal]]); + child.settings = { color: 'red' }; + try { + const ctx = { color: 'plain-string' }; + child.overlaySettingsSignals(ctx); + // overlay wrote AFTER the spread → Signal wins + expect(ctx.color).toBe(colorSignal); + } + finally { + cleanupChild(); + cleanupParent(); + } + }); +}); + +describe('Template — overlaySettingsSignals (web component path)', () => { + it('is a no-op when element has no settingsVars', () => { + const fakeElement = { settings: {} }; + const { template, cleanup } = freshTemplate({ + element: fakeElement, + }); + try { + const ctx = {}; + template.overlaySettingsSignals(ctx); + expect(ctx).toEqual({}); + } + finally { + cleanup(); + } + }); + + it('overlays each settingsVars entry as a Signal onto context', () => { + const colorSignal = new Signal('blue'); + const sizeSignal = new Signal('small'); + const fakeElement = { + settings: { color: 'blue', size: 'small' }, + settingsVars: new Map([ + ['color', colorSignal], + ['size', sizeSignal], + ]), + defaultSettings: { color: 'blue', size: 'small' }, + }; + const { template, cleanup } = freshTemplate({ + element: fakeElement, + }); + try { + const ctx = {}; + template.overlaySettingsSignals(ctx); + expect(ctx.color).toBe(colorSignal); + expect(ctx.size).toBe(sizeSignal); + } + finally { + cleanup(); + } + }); + + it('touches each defaultSettings key (drives shadow Signal creation)', () => { + // The overlay reads `this.element.settings[name]` for each defaultSettings + // key as a side effect — in real components this triggers the settings + // proxy's getter to lazy-create Signals. Pin: every key is read. + const reads = []; + const fakeElement = { + settings: new Proxy({ color: 'blue', size: 'small' }, { + get: (target, prop) => { + if (typeof prop === 'string') { + reads.push(prop); + } + return target[prop]; + }, + }), + settingsVars: new Map([['color', new Signal('blue')]]), + defaultSettings: { color: 'blue', size: 'small' }, + }; + const { template, cleanup } = freshTemplate({ + element: fakeElement, + }); + try { + template.overlaySettingsSignals({}); + expect(reads).toContain('color'); + expect(reads).toContain('size'); + } + finally { + cleanup(); + } + }); + + it('touches each componentSpec.attributes key (drives spec-attribute Signals)', () => { + const reads = []; + const fakeElement = { + settings: new Proxy({ color: 'blue', active: false, size: 'm' }, { + get: (target, prop) => { + if (typeof prop === 'string') { + reads.push(prop); + } + return target[prop]; + }, + }), + settingsVars: new Map([['color', new Signal('blue')]]), + defaultSettings: { color: 'blue' }, + // componentSpec.attributes is an array of attribute names (per + // src/primitives/button/specs/button.component.js); each() over the + // array passes the string name as first arg to the callback. + componentSpec: { + attributes: ['active', 'size'], + }, + }; + const { template, cleanup } = freshTemplate({ + element: fakeElement, + }); + try { + template.overlaySettingsSignals({}); + expect(reads).toContain('active'); + expect(reads).toContain('size'); + } + finally { + cleanup(); + } + }); + + it('returns the context object passed in', () => { + const fakeElement = { + settings: {}, + settingsVars: new Map(), + defaultSettings: {}, + }; + const { template, cleanup } = freshTemplate({ + element: fakeElement, + }); + try { + const ctx = { existing: 1 }; + const result = template.overlaySettingsSignals(ctx); + expect(result).toBe(ctx); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + D8 PIN — settings vs state precedence +*******************************/ + +// Skill `authoring/component-state` claims "state wins over settings". That's +// only true within getDataContext(). overlaySettingsSignals runs AFTER the +// spread, so any setting backed by a Signal in element.settingsVars overrides +// state with the same name. Pin: settings wins when overlaid as a Signal. +// +// This is a SOURCE behavior pin (D8 finding). Skill cleanup is a follow-up. + +describe('Template — D8 PIN: settings overlay wins over state', () => { + it('settings Signal in settingsVars overrides state Signal of same name', () => { + const settingsColorSignal = new Signal('settings-blue'); + const fakeElement = { + settings: { color: 'settings-blue' }, + settingsVars: new Map([['color', settingsColorSignal]]), + defaultSettings: { color: 'settings-blue' }, + }; + const { template, cleanup } = freshTemplate({ + element: fakeElement, + defaultState: { color: 'state-red' }, + }); + try { + // Mimic the render flow: spread, then overlay. + const ctx = template.getDataContext(); + expect(ctx.color).toBeInstanceOf(Signal); + expect(ctx.color.peek()).toBe('state-red'); // state wins the spread + + template.overlaySettingsSignals(ctx); + // Settings overlay ran AFTER spread → settings Signal wins + expect(ctx.color).toBe(settingsColorSignal); + expect(ctx.color.peek()).toBe('settings-blue'); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + extend semantics +*******************************/ + +// getDataContext relies on extend's shallow last-source-wins semantics. +// Pin the contract here so a regression to extend (e.g., switching to +// deepExtend) breaks the surface where it actually matters. + +describe('Template — extend (utils) shallow last-wins', () => { + it('extend({}, a, b, c) merges shallowly with last source winning', () => { + const a = { name: 'a', age: 1 }; + const b = { name: 'b', city: 'NYC' }; + const c = { name: 'c' }; + const result = extend({}, a, b, c); + expect(result).toEqual({ name: 'c', age: 1, city: 'NYC' }); + }); + + it('extend is shallow (not deep) — nested objects replace, not merge', () => { + const a = { settings: { color: 'red', size: 'large' } }; + const b = { settings: { color: 'blue' } }; + const result = extend({}, a, b); + // Shallow: b.settings completely replaces a.settings, no nested merge + expect(result.settings).toEqual({ color: 'blue' }); + expect(result.settings.size).toBeUndefined(); + }); + + it('extend mutates first arg and returns it', () => { + const target = { a: 1 }; + const result = extend(target, { b: 2 }); + expect(result).toBe(target); + expect(target).toEqual({ a: 1, b: 2 }); + }); +}); + +/******************************* + markRendered +*******************************/ + +describe('Template — markRendered', () => { + it('sets rendered=true and destroyed=false', () => { + const { template, cleanup } = freshTemplate(); + try { + template.markRendered(); + expect(template.rendered).toBe(true); + expect(template.destroyed).toBe(false); + } + finally { + cleanup(); + } + }); + + it('is idempotent', () => { + const { template, cleanup } = freshTemplate(); + try { + template.markRendered(); + template.markRendered(); + template.markRendered(); + expect(template.rendered).toBe(true); + expect(template.destroyed).toBe(false); + } + finally { + cleanup(); + } + }); + + it('revives a destroyed template (engine-facing contract)', () => { + const { template, cleanup } = freshTemplate(); + try { + template.destroyed = true; + template.rendered = false; + template.markRendered(); + expect(template.rendered).toBe(true); + expect(template.destroyed).toBe(false); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + L3 PIN — dataReplaced sticky +*******************************/ + +// L3: After first render, dataReplaced is left set if anything in +// setDataContext changed. The first-render branch (template.js:739–744) +// only paths through `if (!this.rendered)` — it never clears dataReplaced. +// Practical impact: a future engine watching the flag sees it stuck-true +// after the first render walk-through. +// +// We can't easily call render() in the node project (renderer needs DOM +// in some engines), so this test pins by direct flag inspection through +// the same code path setDataContext uses. + +describe('Template — L3 PIN: dataReplaced flag is sticky', () => { + it('first render leaves dataReplaced true after walk-through', () => { + const { template, cleanup } = freshTemplate({ + data: { a: 1 }, + }); + try { + template.dataReplaced = false; + // simulate render's internal merge-and-update + template.setDataContext({ a: 1, b: 2 }, { rerender: false }); + expect(template.dataReplaced).toBe(true); + // First-render branch (`if !rendered`) does not clear it. + // Only the else-if branch in render() does. + template.rendered = true; // simulate post-first-render markRendered + // Flag remains stuck-true — pin the bug for red-team Stage 3. + expect(template.dataReplaced).toBe(true); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + C3 — subtemplate settings (Surface 7) symmetric with falsy data +*******************************/ + +// Surface 7's createSubtemplateSettings (template.js:933) uses `!== undefined` +// to seed settings from parent data. Falsy values (0, false, '', null) ARE +// applied. This protects against regression when B1 fix lands — the two +// surfaces should agree on falsy-override behavior. +// +// Pin behavior: subtemplate settings DO take falsy values from data. + +describe('Template — C3 convergent: subtemplate settings respects falsy data', () => { + it('subtemplate settings seeds from data: { count: 0 }', () => { + const { template: parent, cleanup: cleanupParent } = freshTemplate(); + const { template: child, cleanup: cleanupChild } = freshTemplate({ + defaultSettings: { count: 5 }, + data: { count: 0 }, + }); + child.setParent(parent); + try { + child.initialize(); + // Settings proxy is set up by initialize via createSubtemplateSettings. + expect(child.settings.count).toBe(0); + } + finally { + cleanupChild(); + cleanupParent(); + } + }); + + it('subtemplate settings seeds from data: { active: false }', () => { + const { template: parent, cleanup: cleanupParent } = freshTemplate(); + const { template: child, cleanup: cleanupChild } = freshTemplate({ + defaultSettings: { active: true }, + data: { active: false }, + }); + child.setParent(parent); + try { + child.initialize(); + expect(child.settings.active).toBe(false); + } + finally { + cleanupChild(); + cleanupParent(); + } + }); + + it('subtemplate settings seeds from data: { name: "" }', () => { + const { template: parent, cleanup: cleanupParent } = freshTemplate(); + const { template: child, cleanup: cleanupChild } = freshTemplate({ + defaultSettings: { name: 'default' }, + data: { name: '' }, + }); + child.setParent(parent); + try { + child.initialize(); + expect(child.settings.name).toBe(''); + } + finally { + cleanupChild(); + cleanupParent(); + } + }); + + it('subtemplate settings seeds from data: { value: null }', () => { + const { template: parent, cleanup: cleanupParent } = freshTemplate(); + const { template: child, cleanup: cleanupChild } = freshTemplate({ + defaultSettings: { value: 'default' }, + data: { value: null }, + }); + child.setParent(parent); + try { + child.initialize(); + expect(child.settings.value).toBe(null); + } + finally { + cleanupChild(); + cleanupParent(); + } + }); + + it('subtemplate settings falls through to default when data omits key', () => { + const { template: parent, cleanup: cleanupParent } = freshTemplate(); + const { template: child, cleanup: cleanupChild } = freshTemplate({ + defaultSettings: { value: 'default' }, + data: {}, + }); + child.setParent(parent); + try { + child.initialize(); + expect(child.settings.value).toBe('default'); + } + finally { + cleanupChild(); + cleanupParent(); + } + }); +}); diff --git a/packages/templating/test/dom/key-bindings.test.js b/packages/templating/test/dom/key-bindings.test.js new file mode 100644 index 000000000..6db52e704 --- /dev/null +++ b/packages/templating/test/dom/key-bindings.test.js @@ -0,0 +1,782 @@ +// Tests for Template's `keys` binding system. Covers single keys, comma-list +// alternates, modifier combos, sequences with the 500ms timeout, the +// inputFocused/repeatedKey/event callback extras, the return-value contract, +// dynamic bindKey/unbindKey, the SSR guard, and AbortController cleanup. +// +// jsdom is enough — Template's keydown listener attaches to document +// and there is no shadow DOM dependency for keys. Source: template.js:620-686. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Template } from '../../src/template.js'; +import { pressKey, pressKeyCombo, pressKeys } from '../_helpers/dispatch.js'; +import { freshTemplate } from '../_helpers/fresh-template.js'; +import { clearTemplateRegistry } from '../_helpers/registry-cleanup.js'; + +/** + * Spin up a Template with the given keys/options, run initialize(), and + * attach() it to a real renderRoot so eventController is wired and bindKeys() + * registers its document listeners. Returns { template, root, cleanup }. + * + * The element is a plain
attached to body, the renderRoot is a separate + *
appended into element. Neither shadow DOM nor a real component is + * needed — Template's key listener registers on document, not on renderRoot. + */ +async function mountKeyTemplate(opts = {}) { + const element = document.createElement('div'); + document.body.appendChild(element); + const root = document.createElement('div'); + element.appendChild(root); + + const { template, cleanup: cleanupTemplate } = freshTemplate({ + element, + ...opts, + }); + await template.attach(root); + + return { + template, + element, + root, + cleanup: () => { + cleanupTemplate(); + element.remove(); + }, + }; +} + +/** Dispatch a keydown only (no matching keyup). For repeatedKey tests. */ +function pressKeyDown(key, init = {}) { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + composed: true, + cancelable: true, + ...init, + }), + ); +} + +/** Dispatch a keyup only. */ +function pressKeyUp(key, init = {}) { + document.dispatchEvent( + new KeyboardEvent('keyup', { + key, + bubbles: true, + composed: true, + cancelable: true, + ...init, + }), + ); +} + +describe('Template — key bindings', () => { + afterEach(() => { + document.body.innerHTML = ''; + clearTemplateRegistry(); + }); + + /******************************* + Single-key descriptors + *******************************/ + + describe('single-key descriptor', () => { + it('fires the handler when the registered key is pressed', async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ keys: { esc: handler } }); + try { + pressKey('Escape'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('does not fire on unrelated keys', async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ keys: { esc: handler } }); + try { + pressKey('a'); + pressKey('b'); + expect(handler).not.toHaveBeenCalled(); + } + finally { + cleanup(); + } + }); + + it('normalizes uppercase key presses to lowercase via getKeyFromEvent', async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ keys: { a: handler } }); + try { + pressKey('A'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('calls preventDefault when the handler returns undefined', async () => { + const { cleanup } = await mountKeyTemplate({ + keys: { esc: () => undefined }, + }); + try { + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + const spy = vi.spyOn(event, 'preventDefault'); + document.dispatchEvent(event); + expect(spy).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('does NOT call preventDefault when the handler returns true', async () => { + const { cleanup } = await mountKeyTemplate({ + keys: { esc: () => true }, + }); + try { + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + const spy = vi.spyOn(event, 'preventDefault'); + document.dispatchEvent(event); + expect(spy).not.toHaveBeenCalled(); + } + finally { + cleanup(); + } + }); + + it('calls preventDefault when the handler returns false (only === true opts out)', async () => { + const { cleanup } = await mountKeyTemplate({ + keys: { esc: () => false }, + }); + try { + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + const spy = vi.spyOn(event, 'preventDefault'); + document.dispatchEvent(event); + expect(spy).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + }); + + /******************************* + Comma-separated descriptors (B4 pin) + *******************************/ + + describe('comma-separated descriptors', () => { + it("fires for the first listed key from a cold buffer ('up, down')", async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'up, down': handler }, + }); + try { + pressKey('ArrowUp'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + // B4 pin — EXPECTED TO FAIL today. + // Source line 640: `keySequence.split(',')` is not trimmed, so the + // alternate becomes ' down' (leading space). The buffer-space mechanic + // (line 660 appends ' ' AFTER the matching pass) only saves this when a + // prior keystroke left a trailing space — the FIRST press from a fresh + // buffer cannot match ' down'. Documented in the Stage 1 sketch (#7) and + // locked as B4 in Stage 1.5. + it.fails('fires for the second listed key from a cold buffer (B4 PIN — leading-space alternate)', async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'up, down': handler }, + }); + try { + pressKey('ArrowDown'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('fires for the second listed key after a prior keystroke seeded the buffer', async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'up, down': handler }, + }); + try { + pressKey('ArrowUp'); // buffer becomes 'up ' + handler.mockClear(); + pressKey('ArrowDown'); // buffer becomes 'up down'; endsWith(' down') matches + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + // B4 pin — `keys: { 'up , down ': handler }` (extra whitespace). After + // the B4 fix trims each split alternate, this should parse to + // ['up', 'down'] and ArrowUp from a cold buffer should fire. Today, the + // alternate is 'up ' (trailing space) which does not endsWith match the + // cold-buffer 'up'. EXPECTED TO FAIL today; passes after fix. + it.fails("parses cleanly with extra whitespace ('up , down ') (B4 PIN — passes after fix)", async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'up , down ': handler }, + }); + try { + pressKey('ArrowUp'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + }); + + /******************************* + Modifier combinations + *******************************/ + + describe('modifier combinations with +', () => { + it("fires on Ctrl+F when registered as 'ctrl + f'", async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'ctrl + f': handler }, + }); + try { + pressKeyCombo('f', { ctrl: true }); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it("treats 'ctrl + f' and 'ctrl+f' as identical (spacing around + is normalized)", async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'ctrl+f': handler }, + }); + try { + pressKeyCombo('f', { ctrl: true }); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('fires on multi-modifier combos (ctrl + shift + a)', async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'ctrl+shift+a': handler }, + }); + try { + pressKeyCombo('a', { ctrl: true, shift: true }); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('does not fire when only some modifiers are pressed', async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'ctrl+f': handler }, + }); + try { + pressKey('f'); + expect(handler).not.toHaveBeenCalled(); + } + finally { + cleanup(); + } + }); + }); + + /******************************* + Key sequences + *******************************/ + + describe('key sequences (space-separated)', () => { + it("fires when the second key is pressed within 500ms ('g i')", async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'g i': handler }, + }); + try { + pressKey('g'); + pressKey('i'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('does NOT fire when the second key arrives after the 500ms timeout', async () => { + vi.useFakeTimers(); + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'g i': handler }, + }); + try { + pressKey('g'); + // Advance timers past the 500ms reset window; buffer clears. + vi.advanceTimersByTime(501); + pressKey('i'); + expect(handler).not.toHaveBeenCalled(); + } + finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it('the timeout is sliding — each press extends the window', async () => { + vi.useFakeTimers(); + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'g i': handler }, + }); + try { + pressKey('g'); + vi.advanceTimersByTime(400); // < 500ms, buffer still alive + pressKey('i'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it('continues to work for sequences after the comma-split fix (sequences are independent of comma-split)', async () => { + const handler = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { 'g i': handler }, + }); + try { + pressKey('g'); + pressKey('i'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + }); + + /******************************* + Single + sequence co-fire (Stage 1 ambiguity) + *******************************/ + + describe('single-key and matching-suffix sequence co-fire', () => { + // Stage 1 inference: registering both 'i' and 'g i' should cause BOTH + // to fire when the user presses 'g' then 'i' — because endsWith('g i') + // and endsWith('i') both match the buffer 'g i'. Pin current behavior. + it("fires both 'i' and 'g i' handlers when a sequence completes", async () => { + const single = vi.fn(); + const sequence = vi.fn(); + const { cleanup } = await mountKeyTemplate({ + keys: { i: single, 'g i': sequence }, + }); + try { + pressKey('g'); + pressKey('i'); + expect(single).toHaveBeenCalledTimes(1); + expect(sequence).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + }); + + /******************************* + Callback param: inputFocused + *******************************/ + + describe('inputFocused callback param', () => { + it('is true when an is focused', async () => { + const handler = vi.fn(); + const { element, cleanup } = await mountKeyTemplate({ + keys: { esc: handler }, + }); + try { + const input = document.createElement('input'); + element.appendChild(input); + input.focus(); + pressKey('Escape'); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].inputFocused).toBe(true); + } + finally { + cleanup(); + } + }); + + it('is true when a