Compare commits

...
Author SHA1 Message Date
Niels Lyngsø 50e8f844ee unit test for controller-api 2026-05-09 19:19:02 +02:00
3 changed files with 394 additions and 0 deletions
@@ -0,0 +1,176 @@
import { UmbClassMixin } from './class.mixin.js';
import { aTimeout, expect, fixture } from '@open-wc/testing';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import {
UmbControllerHostElementMixin,
type UmbControllerHostElement,
} from '@umbraco-cms/backoffice/controller-api';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { UmbBasicState } from '@umbraco-cms/backoffice/observable-api';
@customElement('umb-test-class-mixin-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestClassMixinHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
class UmbTestClass extends UmbClassMixin(EventTarget) {}
describe('UmbClassMixin', () => {
let host: UmbControllerHostElement;
beforeEach(async () => {
host = await fixture(html`<umb-test-class-mixin-host></umb-test-class-mixin-host>`);
});
describe('observe()', () => {
it('subscribes to an observable and forwards values to the callback', async () => {
const ctrl = new UmbTestClass(host);
const state = new UmbBasicState(0);
const values: Array<number> = [];
ctrl.observe(state.asObservable(), (value) => values.push(value));
// Initial value emitted synchronously on subscribe.
expect(values).to.eql([0]);
state.setValue(1);
expect(values).to.eql([0, 1]);
state.setValue(2);
expect(values).to.eql([0, 1, 2]);
ctrl.destroy();
});
it('stops emitting after the controller is destroyed', () => {
const ctrl = new UmbTestClass(host);
const state = new UmbBasicState(0);
const values: Array<number> = [];
ctrl.observe(state.asObservable(), (value) => values.push(value));
expect(values).to.eql([0]);
ctrl.destroy();
state.setValue(99);
// No new value should arrive after destroy.
expect(values).to.eql([0]);
});
it('replaces a previous observation when called twice with the same callback identity', async () => {
const ctrl = new UmbTestClass(host);
const stateA = new UmbBasicState('a1');
const stateB = new UmbBasicState('b1');
const values: Array<string> = [];
// The mixin auto-derives the controller alias from the callback's
// `toString()` hash, so re-observing with the same callback function
// replaces the previous observer.
const callback = (value: string) => values.push(value);
ctrl.observe(stateA.asObservable(), callback);
expect(values).to.eql(['a1']);
ctrl.observe(stateB.asObservable(), callback);
// Second subscription emits its initial value.
expect(values).to.contain('b1');
// Updates to the abandoned observable must not reach the callback.
const lengthBeforeMutation = values.length;
stateA.setValue('a2');
await aTimeout(0);
expect(values.length).to.equal(lengthBeforeMutation);
ctrl.destroy();
});
it('invokes the callback with undefined and removes any prior observer when source is undefined', () => {
const ctrl = new UmbTestClass(host);
const state = new UmbBasicState(0);
const values: Array<number | undefined> = [];
const callback = (value: number | undefined) => values.push(value);
ctrl.observe(state.asObservable(), callback);
expect(values).to.eql([0]);
ctrl.observe(undefined, callback);
// Callback was invoked once more, with undefined.
expect(values[values.length - 1]).to.be.undefined;
// And the abandoned observer must no longer receive updates.
const lengthBefore = values.length;
state.setValue(42);
expect(values.length).to.equal(lengthBefore);
ctrl.destroy();
});
});
describe('provideContext / consumeContext', () => {
// The context API requires every provided instance to satisfy
// `UmbContextMinimal { getHostElement(): Element }` — the consumer reads
// it to scope context lookups. Real callers usually achieve this by
// extending `UmbContextBase`; in this test we're only exercising the
// mixin's wiring, so we attach the method directly.
const minimalApi = <T extends object>(instance: T, hostEl: Element): T & { getHostElement(): Element } =>
Object.assign(instance, { getHostElement: () => hostEl });
it('lets a child controller resolve a context provided by a parent', async () => {
const TOKEN = new UmbContextToken<{ getHostElement(): Element; greeting: string }>(
'UmbTestClassMixin.Greeting',
);
const provider = new UmbTestClass(host);
const apiInstance = minimalApi({ greeting: 'hello' }, host);
provider.provideContext(TOKEN, apiInstance);
let received: typeof apiInstance | undefined;
await new Promise<void>((resolve) => {
const consumerHost = new UmbTestClass(provider);
consumerHost.consumeContext(TOKEN, (value) => {
received = value;
resolve();
});
});
expect(received).to.equal(apiInstance);
provider.destroy();
});
it('getContext resolves the same instance asynchronously', async () => {
const TOKEN = new UmbContextToken<{ getHostElement(): Element; count: number }>('UmbTestClassMixin.Counter');
const provider = new UmbTestClass(host);
const apiInstance = minimalApi({ count: 7 }, host);
provider.provideContext(TOKEN, apiInstance);
const consumerHost = new UmbTestClass(provider);
const result = await consumerHost.getContext(TOKEN);
expect(result).to.equal(apiInstance);
provider.destroy();
});
});
describe('destroy()', () => {
it('removes the controller from its host', () => {
const ctrl = new UmbTestClass(host);
expect(host.hasUmbController(ctrl)).to.be.true;
ctrl.destroy();
expect(host.hasUmbController(ctrl)).to.be.false;
});
it('clears the host reference (getHostElement returns undefined after destroy)', () => {
const ctrl = new UmbTestClass(host);
ctrl.destroy();
expect(ctrl.getHostElement()).to.be.undefined;
});
it('is safe to call repeatedly', () => {
const ctrl = new UmbTestClass(host);
expect(() => {
ctrl.destroy();
ctrl.destroy();
}).to.not.throw();
});
});
});
@@ -0,0 +1,103 @@
import { UmbContextBase } from './context-base.class.js';
import { UmbControllerBase } from './controller-base.class.js';
import { expect, fixture } from '@open-wc/testing';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import {
UmbControllerHostElementMixin,
type UmbControllerHostElement,
} from '@umbraco-cms/backoffice/controller-api';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
@customElement('umb-test-context-base-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestContextBaseHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
interface UmbTestGreetingContext {
greeting: string;
getHostElement(): Element;
}
class UmbTestGreetingContextImpl extends UmbContextBase implements UmbTestGreetingContext {
greeting = 'hello';
}
const UMB_TEST_GREETING_CONTEXT = new UmbContextToken<UmbTestGreetingContext>('UmbTestContextBase.Greeting');
describe('UmbContextBase', () => {
let host: UmbControllerHostElement;
beforeEach(async () => {
host = await fixture(html`<umb-test-context-base-host></umb-test-context-base-host>`);
});
it('registers itself as a controller on the host on construction', () => {
const ctx = new UmbTestGreetingContextImpl(host, UMB_TEST_GREETING_CONTEXT);
expect(host.hasUmbController(ctx)).to.be.true;
});
it('makes itself available to a consumer asking for the same token', async () => {
const ctx = new UmbTestGreetingContextImpl(host, UMB_TEST_GREETING_CONTEXT);
const consumerHost = new UmbControllerBase(host) as UmbControllerBase;
const received = await new Promise<UmbTestGreetingContext | undefined>((resolve) => {
consumerHost.consumeContext(UMB_TEST_GREETING_CONTEXT, (value) => {
resolve(value);
});
});
expect(received).to.equal(ctx);
});
it('is unprovided when destroyed — consumer callback fires again with undefined', async () => {
const ctx = new UmbTestGreetingContextImpl(host, UMB_TEST_GREETING_CONTEXT);
const consumerHost = new UmbControllerBase(host) as UmbControllerBase;
const received: Array<UmbTestGreetingContext | undefined> = [];
await new Promise<void>((resolve) => {
consumerHost.consumeContext(UMB_TEST_GREETING_CONTEXT, (value) => {
received.push(value);
if (received.length === 1) resolve();
});
});
expect(received[0]).to.equal(ctx);
await new Promise<void>((resolve) => {
// `consumeContext` already pushes initial values; we just need to wait
// for the unprovide callback to land after destroying the context.
const originalLength = received.length;
const interval = setInterval(() => {
if (received.length > originalLength) {
clearInterval(interval);
resolve();
}
}, 5);
ctx.destroy();
});
expect(received.at(-1)).to.be.undefined;
});
it('is removed from the host on destroy', () => {
const ctx = new UmbTestGreetingContextImpl(host, UMB_TEST_GREETING_CONTEXT);
expect(host.hasUmbController(ctx)).to.be.true;
ctx.destroy();
expect(host.hasUmbController(ctx)).to.be.false;
});
it('accepts a string contextAlias as well as an UmbContextToken', () => {
// `super(host, contextToken.toString())` — UmbContextBase forwards the
// string form to UmbControllerBase, so the alias-based lookup still works.
class StringAliasContext extends UmbContextBase {
constructor(host: UmbControllerHostElement) {
super(host, 'UmbTestContextBase.StringAlias');
}
}
const ctx = new StringAliasContext(host);
expect(host.hasUmbController(ctx)).to.be.true;
ctx.destroy();
expect(host.hasUmbController(ctx)).to.be.false;
});
});
@@ -0,0 +1,115 @@
import { UmbControllerBase } from './controller-base.class.js';
import { expect, fixture } from '@open-wc/testing';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import {
UmbControllerHostElementMixin,
type UmbControllerHostElement,
} from '@umbraco-cms/backoffice/controller-api';
@customElement('umb-test-controller-base-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestControllerBaseHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
class UmbTestControllerBase extends UmbControllerBase {
hostConnectedCalls = 0;
hostDisconnectedCalls = 0;
override hostConnected() {
super.hostConnected();
this.hostConnectedCalls++;
}
override hostDisconnected() {
super.hostDisconnected();
this.hostDisconnectedCalls++;
}
}
describe('UmbControllerBase', () => {
let host: UmbControllerHostElement;
beforeEach(async () => {
host = await fixture(html`<umb-test-controller-base-host></umb-test-controller-base-host>`);
});
it('registers itself on the host on construction', () => {
const controller = new UmbTestControllerBase(host);
expect(host.hasUmbController(controller)).to.be.true;
});
it('exposes the host element via getHostElement()', () => {
const controller = new UmbTestControllerBase(host);
expect(controller.getHostElement()).to.equal(host);
});
it('auto-assigns a Symbol controllerAlias when none is provided', () => {
const a = new UmbTestControllerBase(host);
const b = new UmbTestControllerBase(host);
// Each controller without an alias gets its own unique Symbol, so they
// MUST coexist on the host (no replacement-by-alias).
expect(typeof a.controllerAlias).to.equal('symbol');
expect(a.controllerAlias).to.not.equal(b.controllerAlias);
expect(host.hasUmbController(a)).to.be.true;
expect(host.hasUmbController(b)).to.be.true;
});
it('replaces a previous controller registered with the same string alias', () => {
const first = new UmbTestControllerBase(host, 'shared-alias');
const second = new UmbTestControllerBase(host, 'shared-alias');
expect(host.hasUmbController(first)).to.be.false;
expect(host.hasUmbController(second)).to.be.true;
});
describe('destroy()', () => {
it('removes itself from the host', () => {
const controller = new UmbTestControllerBase(host);
expect(host.hasUmbController(controller)).to.be.true;
controller.destroy();
expect(host.hasUmbController(controller)).to.be.false;
});
it('clears its _host reference (so getHostElement returns undefined afterwards)', () => {
const controller = new UmbTestControllerBase(host);
controller.destroy();
expect(controller.getHostElement()).to.be.undefined;
});
it('also destroys nested child controllers', () => {
const parent = new UmbTestControllerBase(host);
const child = new UmbTestControllerBase(parent);
expect(parent.hasUmbController(child)).to.be.true;
parent.destroy();
expect(host.hasUmbController(parent)).to.be.false;
expect(parent.hasUmbController(child)).to.be.false;
});
it('is safe to call repeatedly (idempotent)', () => {
const controller = new UmbTestControllerBase(host);
expect(() => {
controller.destroy();
controller.destroy();
controller.destroy();
}).to.not.throw();
expect(host.hasUmbController(controller)).to.be.false;
});
it('triggers hostDisconnected on the controller when removed from a connected host', async () => {
document.body.appendChild(host);
const controller = new UmbTestControllerBase(host);
// Wait one microtask cycle — the host schedules `hostConnected` on the
// next tick when a controller is added to an already-attached host.
await Promise.resolve();
expect(controller.hostConnectedCalls).to.equal(1);
host.removeUmbController(controller);
expect(controller.hostDisconnectedCalls).to.equal(1);
document.body.removeChild(host);
});
});
});