Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e8f844ee |
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user