diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index cfcdd887..30b1d57c 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -354,15 +354,15 @@ advanced_camera_card_action: notification ### `notification` -| Parameter | Description | -| ------------- | ---------------------------------------------------------------------------------------------------------- | -| `heading` | An optional heading. Uses the same format as [`metadata`](README.md?id=metadata) below. | -| `body` | An optional body. Uses the same format as [`metadata`](README.md?id=metadata) below. | -| `metadata` | An optional list of metadata to show with the notification. See [`metadata`](README.md?id=metadata) below. | -| `context` | An optional list of preformatted text strings shown as a technical detail block (e.g. YAML). | -| `link` | An optional link displayed below the body. See [`link`](#link) below. | -| `in_progress` | If `true`, shows a loading indicator. | -| `controls` | An optional list of controls. See [`controls`](README.md?id=controls) below. | +| Parameter | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------- | +| `heading` | An optional heading. Uses the same format as [`metadata`](README.md?id=metadata) below. | +| `body` | An optional body. Uses the same format as [`metadata`](README.md?id=metadata) below. | +| `metadata` | An optional list of metadata to show with the notification. See [`metadata`](README.md?id=metadata) below. | +| `context` | An optional list of items shown as a technical detail block: strings are shown as-is, objects are rendered as YAML. | +| `link` | An optional link displayed below the body. See [`link`](#link) below. | +| `in_progress` | If `true`, shows a loading indicator. | +| `controls` | An optional list of controls. See [`controls`](README.md?id=controls) below. | ### Metadata diff --git a/src/components-lib/notification/data-to-context.ts b/src/components-lib/notification/data-to-context.ts deleted file mode 100644 index 1ee49383..00000000 --- a/src/components-lib/notification/data-to-context.ts +++ /dev/null @@ -1,12 +0,0 @@ -import yaml from 'js-yaml'; - -// Converts a structured data object into preformatted text strings for a -// notification's context section. Arrays produce one string per item (strings -// pass through; objects are YAML-dumped); all other objects produce a single -// YAML-dumped string. -export const dataToContext = (data: object): string[] => { - if (Array.isArray(data)) { - return data.map((item) => (typeof item === 'string' ? item : yaml.dump(item))); - } - return [yaml.dump(data)]; -}; diff --git a/src/components-lib/notification/factory.ts b/src/components-lib/notification/factory.ts index 9e64b14f..efa075cd 100644 --- a/src/components-lib/notification/factory.ts +++ b/src/components-lib/notification/factory.ts @@ -4,7 +4,6 @@ import type { } from '../../config/schema/actions/types.js'; import type { Link } from '../../config/schema/common/link.js'; import { getContextFromError } from '../../utils/error-context.js'; -import { dataToContext } from './data-to-context.js'; const DEFAULT_ERROR_ICON = 'mdi:alert'; @@ -31,7 +30,7 @@ export const createNotificationFromText = ( ...(options?.metadata && { metadata: options.metadata }), ...(options?.link && { link: options.link }), ...(options?.context && { - context: dataToContext(options.context), + context: [options.context], }), ...(options?.in_progress !== undefined && { in_progress: options.in_progress }), }); diff --git a/src/components-lib/notification/notification-context-controller.ts b/src/components-lib/notification/notification-context-controller.ts new file mode 100644 index 00000000..39e93731 --- /dev/null +++ b/src/components-lib/notification/notification-context-controller.ts @@ -0,0 +1,52 @@ +import type * as JsYaml from 'js-yaml'; +import type { ReactiveControllerHost } from 'lit'; + +import type { Notification } from '../../config/schema/actions/types.js'; + +// `js-yaml` (~107KB) is only needed to render a notification's diagnostic +// context when that context contains a structured object, which most cards +// never show. It is imported on demand and shared across every notification. +export class NotificationContextController { + private static jsYamlDump: typeof JsYaml.dump | null = null; + + private _host: ReactiveControllerHost; + + constructor(host: ReactiveControllerHost) { + this._host = host; + } + + /** + * Resolve a notification's context into display strings. String items pass + * through unchanged; object items are YAML-dumped, loading `js-yaml` on + * demand and re-rendering once it is ready. Returns an empty array while a + * load needed for an object item is still in flight. + */ + public getContext(notification: Notification): string[] { + const context = notification.context ?? []; + const dump = NotificationContextController.jsYamlDump; + + const result: string[] = []; + for (const item of context) { + if (typeof item === 'string') { + result.push(item); + } else if (dump) { + result.push(dump(item)); + } else { + // Object item needs js-yaml; load it and re-render once ready. A failed + // load is swallowed: the notification renders without the dumped + // context. + this._loadDumper().catch(() => {}); + return []; + } + } + return result; + } + + private async _loadDumper(): Promise { + // `import()` is module-cached, so repeat/concurrent calls share one + // download; a failed load is not cached, so a later render retries. + const module = await import('js-yaml'); + NotificationContextController.jsYamlDump = module.dump; + this._host.requestUpdate(); + } +} diff --git a/src/components-lib/notification/notification-popup-controller.ts b/src/components-lib/notification/notification-popup-controller.ts new file mode 100644 index 00000000..78b2734a --- /dev/null +++ b/src/components-lib/notification/notification-popup-controller.ts @@ -0,0 +1,65 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import { hasPopOutAnimationEnded } from '../../utils/animation.js'; +import { dispatchDismissNotificationEvent } from '../../utils/notification.js'; + +// Manages the popup notification's modal interaction: dismiss on outside +// interaction or Escape, and emit the dismiss event once the pop-out animation +// finishes. +export class NotificationPopupController implements ReactiveController { + private _host: ReactiveControllerHost & HTMLElement; + private _getNotificationElement: () => HTMLElement | null; + + constructor( + host: ReactiveControllerHost & HTMLElement, + getNotificationElement: () => HTMLElement | null, + ) { + this._host = host; + this._getNotificationElement = getNotificationElement; + host.addController(this); + } + + public hostConnected(): void { + window.addEventListener('click', this._handleOutsideInteraction); + window.addEventListener('focusin', this._handleOutsideInteraction); + + // Escape is claimed in the capture phase: the popup is a modal surface and + // must consume Escape before non-modal background controls (e.g. the call + // controls) that also listen on `window`. + window.addEventListener('keydown', this._handleKeyDown, { capture: true }); + } + + public hostDisconnected(): void { + window.removeEventListener('click', this._handleOutsideInteraction); + window.removeEventListener('focusin', this._handleOutsideInteraction); + window.removeEventListener('keydown', this._handleKeyDown, { capture: true }); + } + + public dismiss = (): void => { + this._getNotificationElement()?.classList.add('exiting'); + }; + + public handleAnimationEnd = (ev: AnimationEvent): void => { + if (hasPopOutAnimationEnded(ev)) { + dispatchDismissNotificationEvent(this._host); + } + }; + + private _handleOutsideInteraction = (ev: Event): void => { + if (!ev.composedPath().includes(this._host)) { + this.dismiss(); + } + }; + + private _handleKeyDown = (ev: KeyboardEvent): void => { + if (ev.key === 'Escape') { + this.dismiss(); + + // `stopImmediatePropagation()` (not `stopPropagation()`) is required to + // block sibling `window` listeners -- `stopPropagation()` only stops + // propagation to other targets, not other listeners on `window` itself. + ev.stopImmediatePropagation(); + ev.preventDefault(); + } + }; +} diff --git a/src/components/image-updating-player.ts b/src/components/image-updating-player.ts index 1c48ea36..19ad227c 100644 --- a/src/components/image-updating-player.ts +++ b/src/components/image-updating-player.ts @@ -16,7 +16,6 @@ import type { IssueTriggerEventData } from '../card-controller/issues/types.js'; import { CachedValueController } from '../components-lib/cached-value-controller.js'; import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js'; import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js'; -import { dataToContext } from '../components-lib/notification/data-to-context.js'; import { SignedURLController } from '../components-lib/signed-url-controller.js'; import type { Notification } from '../config/schema/actions/types.js'; import type { CameraConfig } from '../config/schema/cameras.js'; @@ -418,7 +417,7 @@ export class AdvancedCameraCardImageUpdatingPlayer icon: 'mdi:alert-circle', }, link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') }, - context: this.proxyConfig ? dataToContext(this.proxyConfig) : undefined, + context: this.proxyConfig ? [this.proxyConfig] : undefined, }; } if (this._imageLoadError) { @@ -428,7 +427,7 @@ export class AdvancedCameraCardImageUpdatingPlayer icon: 'mdi:alert-circle', }, link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') }, - context: this.imageConfig ? dataToContext(this.imageConfig) : undefined, + context: this.imageConfig ? [this.imageConfig] : undefined, }; } return null; diff --git a/src/components/notification/block.ts b/src/components/notification/block.ts index b32f10ba..037879b1 100644 --- a/src/components/notification/block.ts +++ b/src/components/notification/block.ts @@ -12,6 +12,7 @@ import { createNotificationFromText, type NotificationOptions, } from '../../components-lib/notification/factory.js'; +import { NotificationContextController } from '../../components-lib/notification/notification-context-controller.js'; import type { Notification } from '../../config/schema/actions/types.js'; import { localize } from '../../localize/localize.js'; import notificationBlockStyle from '../../scss/notification-block.scss'; @@ -41,11 +42,14 @@ export class AdvancedCameraCardNotificationBlock extends LitElement { @property({ attribute: false }) public notification: Notification | null = null; + private _contextController = new NotificationContextController(this); + protected render(): TemplateResult | void { if (!this.notification) { return; } + const context = this._contextController.getContext(this.notification); const { heading, in_progress } = this.notification; const controls = this.notification.controls ?? []; @@ -78,7 +82,11 @@ export class AdvancedCameraCardNotificationBlock extends LitElement { : ''} ` : ''} - ${renderNotificationBody(this.notification, spinnerInBody ? spinner : undefined)} + ${renderNotificationBody( + this.notification, + context, + spinnerInBody ? spinner : undefined, + )} `; } diff --git a/src/components/notification/common-rendering.ts b/src/components/notification/common-rendering.ts index f4b0e5a6..f85fa54a 100644 --- a/src/components/notification/common-rendering.ts +++ b/src/components/notification/common-rendering.ts @@ -67,11 +67,11 @@ export function renderControl( } export function renderNotificationBody( - notification: Notification, + notification: Omit, + context: string[], bodyIconOverride?: TemplateResult, ): TemplateResult { const { body, link } = notification; - const context = notification.context ?? []; const metadata = notification.metadata ?? []; return html` ${metadata.map((detail) => renderDetail(detail, 'metadata'))} diff --git a/src/components/notification/popup.ts b/src/components/notification/popup.ts index 5d67cb34..28f0b124 100644 --- a/src/components/notification/popup.ts +++ b/src/components/notification/popup.ts @@ -9,11 +9,11 @@ import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import { handleControlAction } from '../../components-lib/notification/action.js'; +import { NotificationContextController } from '../../components-lib/notification/notification-context-controller.js'; +import { NotificationPopupController } from '../../components-lib/notification/notification-popup-controller.js'; import type { Notification } from '../../config/schema/actions/types.js'; import { localize } from '../../localize/localize.js'; import notificationPopupStyle from '../../scss/notification-popup.scss'; -import { hasPopOutAnimationEnded } from '../../utils/animation.js'; -import { dispatchDismissNotificationEvent } from '../../utils/notification.js'; import { renderControl, renderDetail, @@ -26,39 +26,27 @@ export class AdvancedCameraCardNotification extends LitElement { public notification: Notification | null = null; private _refNotification: Ref = createRef(); - - public connectedCallback(): void { - super.connectedCallback(); - window.addEventListener('click', this._handleOutsideInteraction); - window.addEventListener('focusin', this._handleOutsideInteraction); - - // Escape is claimed in the capture phase: the popup is a modal surface and - // must consume Escape before non-modal background controls (e.g. the call - // controls) that also listen on `window`. - window.addEventListener('keydown', this._handleKeyDown, { capture: true }); - } - - public disconnectedCallback(): void { - window.removeEventListener('click', this._handleOutsideInteraction); - window.removeEventListener('focusin', this._handleOutsideInteraction); - window.removeEventListener('keydown', this._handleKeyDown, { capture: true }); - super.disconnectedCallback(); - } + private _popupController = new NotificationPopupController( + this, + () => this._refNotification.value ?? null, + ); + private _contextController = new NotificationContextController(this); protected render(): TemplateResult | void { if (!this.notification) { return; } + const context = this._contextController.getContext(this.notification); const { heading, in_progress } = this.notification; const controls = this.notification.controls ?? []; return html` -
+
${controls.length || in_progress ? html`
@@ -69,52 +57,24 @@ export class AdvancedCameraCardNotification extends LitElement { : ''} ${controls.map((control) => renderControl(control, (ev, c) => - handleControlAction(ev, c, this, this._dismiss), + handleControlAction(ev, c, this, this._popupController.dismiss), ), )}
` : ''} -
+
${heading ? renderDetail(heading, 'heading') : ''} - ${renderNotificationBody(this.notification)} + ${renderNotificationBody(this.notification, context)}
`; } - private _dismiss = (): void => { - this._refNotification.value?.classList.add('exiting'); - }; - - private _handleAnimationEnd = (ev: AnimationEvent): void => { - if (hasPopOutAnimationEnded(ev)) { - dispatchDismissNotificationEvent(this); - } - }; - - private _handleOutsideInteraction = (ev: Event): void => { - if (!ev.composedPath().includes(this)) { - this._dismiss(); - } - }; - - private _handleKeyDown = (ev: KeyboardEvent): void => { - if (ev.key === 'Escape') { - this._dismiss(); - - // `stopImmediatePropagation()` (not `stopPropagation()`) is required to - // block sibling `window` listeners -- `stopPropagation()` only stops - // propagation to other targets, not other listeners on `window` itself. - ev.stopImmediatePropagation(); - ev.preventDefault(); - } - }; - static get styles(): CSSResultGroup { return unsafeCSS(notificationPopupStyle); } diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index 5616b3df..1df83583 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { isRecord } from '../../../utils/basic'; import { linkSchema } from '../common/link'; import { preprocessToArray } from '../common/preprocess-to-array'; import { severitySchema } from '../common/severity'; @@ -187,11 +188,15 @@ const notificationControlSchema = notificationBaseSchema.extend({ }); export type NotificationControl = z.infer; +// A context item is a preformatted string or a structured object that is +// YAML-dumped at render time (see NotificationContextController). +const notificationContextItemSchema = z.union([z.string(), z.custom(isRecord)]); + const notificationSchema = z.object({ heading: notificationDetailSchema.optional(), body: notificationDetailSchema.optional(), metadata: notificationDetailSchema.array().optional(), - context: z.string().array().optional(), + context: notificationContextItemSchema.array().optional(), link: linkSchema.optional(), in_progress: z.boolean().optional(), controls: notificationControlSchema.array().optional(), diff --git a/src/utils/error-context.ts b/src/utils/error-context.ts index dd20a623..7ba73b43 100644 --- a/src/utils/error-context.ts +++ b/src/utils/error-context.ts @@ -1,12 +1,11 @@ import { AdvancedCameraCardError } from '../types.js'; +import { isRecord } from './basic.js'; -// Narrows an unknown error to its structured object `context` -- non-null and -// of object type -- or null if the error is not an AdvancedCameraCardError or -// has no usable context. Consolidates the instanceof + typeof + null-guard -// dance that notification builders and error handlers would otherwise repeat. -export const getContextFromError = (error: unknown): object | null => - error instanceof AdvancedCameraCardError && - typeof error.context === 'object' && - error.context !== null +// Narrows an unknown error to its structured object `context` -- a non-null +// record -- or null if the error is not an AdvancedCameraCardError or has no +// usable context. Consolidates the instanceof + null-guard dance that +// notification builders and error handlers would otherwise repeat. +export const getContextFromError = (error: unknown): Record | null => + error instanceof AdvancedCameraCardError && isRecord(error.context) ? error.context : null; diff --git a/tests/card-controller/call/tones/custom.test.ts b/tests/card-controller/call/tones/custom.test.ts index 5c97c582..cbb6f2bd 100644 --- a/tests/card-controller/call/tones/custom.test.ts +++ b/tests/card-controller/call/tones/custom.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { CustomTone } from '../../../../src/card-controller/call/tones/custom'; +import { flushPromises } from '../../../test-utils'; interface AudioMocks { // Each call to `new Audio(...)` is delegated to a real jsdom Audio element @@ -109,8 +110,8 @@ describe('start', () => { const onFinished = vi.fn(); new CustomTone('http://example/ring.mp3', 0).start(onFinished); - // Let the rejected promise settle. - await new Promise((resolve) => setTimeout(resolve, 0)); + + await flushPromises(); expect(onFinished).toBeCalled(); }); diff --git a/tests/card-controller/issues/issues/view-incompatible.test.ts b/tests/card-controller/issues/issues/view-incompatible.test.ts index cc5d9a85..3f6785af 100644 --- a/tests/card-controller/issues/issues/view-incompatible.test.ts +++ b/tests/card-controller/issues/issues/view-incompatible.test.ts @@ -84,7 +84,7 @@ describe('ViewIncompatibleIssue', () => { const result = issue.getIssue(); expect(result?.notification.context).toEqual([ - expect.stringContaining('view: snapshot'), + { view: 'snapshot', camera: 'cam.office' }, ]); }); diff --git a/tests/components-lib/notification/data-to-context.test.ts b/tests/components-lib/notification/data-to-context.test.ts deleted file mode 100644 index 70b8506b..00000000 --- a/tests/components-lib/notification/data-to-context.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { dataToContext } from '../../../src/components-lib/notification/data-to-context'; - -describe('dataToContext', () => { - it('should return an array of string items unchanged when input is an array of strings', () => { - expect(dataToContext(['line one', 'line two'])).toEqual(['line one', 'line two']); - }); - - it('should YAML-dump object items when input is an array containing objects', () => { - const result = dataToContext([{ key: 'value' }]); - expect(result).toHaveLength(1); - expect(result[0]).toContain('key: value'); - }); - - it('should handle a mixed array of strings and objects', () => { - const result = dataToContext(['plain string', { foo: 'bar' }]); - expect(result).toHaveLength(2); - expect(result[0]).toBe('plain string'); - expect(result[1]).toContain('foo: bar'); - }); - - it('should return a single YAML-dumped string for a plain object', () => { - const result = dataToContext({ error: 'something went wrong', code: 42 }); - expect(result).toHaveLength(1); - expect(result[0]).toContain('error: something went wrong'); - expect(result[0]).toContain('code: 42'); - }); - - it('should return an empty array for an empty array input', () => { - expect(dataToContext([])).toEqual([]); - }); -}); diff --git a/tests/components-lib/notification/factory.test.ts b/tests/components-lib/notification/factory.test.ts index e3f03e59..9d15a57b 100644 --- a/tests/components-lib/notification/factory.test.ts +++ b/tests/components-lib/notification/factory.test.ts @@ -67,8 +67,7 @@ describe('createNotificationFromText', () => { const notification = createNotificationFromText('oops', { context: { detail: 'extra info' }, }); - expect(notification.context).toBeDefined(); - expect(notification.context?.join(' ')).toContain('detail: extra info'); + expect(notification.context).toEqual([{ detail: 'extra info' }]); }); it('should omit context when not provided', () => { @@ -136,8 +135,7 @@ describe('createNotificationFromError', () => { const error = new AdvancedCameraCardError('boom', { reason: 'network' }); const notification = createNotificationFromError(error); assert(notification); - expect(notification.context).toBeDefined(); - expect(notification.context?.join(' ')).toContain('reason: network'); + expect(notification.context).toEqual([{ reason: 'network' }]); }); it('should use explicit context option over AdvancedCameraCardError context', () => { @@ -146,8 +144,7 @@ describe('createNotificationFromError', () => { context: { override: 'explicit' }, }); assert(notification); - expect(notification.context?.join(' ')).toContain('override: explicit'); - expect(notification.context?.join(' ')).not.toContain('reason: network'); + expect(notification.context).toEqual([{ override: 'explicit' }]); }); it('should not include context when AdvancedCameraCardError has a non-object context', () => { diff --git a/tests/components-lib/notification/notification-context-controller.test.ts b/tests/components-lib/notification/notification-context-controller.test.ts new file mode 100644 index 00000000..55584273 --- /dev/null +++ b/tests/components-lib/notification/notification-context-controller.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Notification } from '../../../src/config/schema/actions/types'; +import { createLitElement, flushPromises } from '../../test-utils'; + +// @vitest-environment jsdom +describe('NotificationContextController', () => { + // Each test re-imports the controller after resetting the module registry so its + // module-level `js-yaml` singleton starts unloaded. + const loadController = async () => { + const module = await import( + '../../../src/components-lib/notification/notification-context-controller' + ); + return module.NotificationContextController; + }; + + const createNotification = (context?: Notification['context']): Notification => ({ + body: { text: 'oops' }, + ...(context && { context }), + }); + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('js-yaml'); + }); + + it('should return an empty array when the notification has no context', async () => { + const NotificationContextController = await loadController(); + const controller = new NotificationContextController(createLitElement()); + + expect(controller.getContext(createNotification())).toEqual([]); + }); + + it('should return string context items unchanged without loading the library', async () => { + const NotificationContextController = await loadController(); + const host = createLitElement(); + const controller = new NotificationContextController(host); + + expect(controller.getContext(createNotification(['line one', 'line two']))).toEqual([ + 'line one', + 'line two', + ]); + expect(host.requestUpdate).not.toHaveBeenCalled(); + }); + + it('should YAML-dump object context items once the library has loaded', async () => { + const NotificationContextController = await loadController(); + const host = createLitElement(); + const controller = new NotificationContextController(host); + const notification = createNotification([{ foo: 'bar' }]); + + // The library is not yet loaded, so the first render defers. + expect(controller.getContext(notification)).toEqual([]); + + await vi.waitFor(() => expect(host.requestUpdate).toHaveBeenCalled()); + + const result = controller.getContext(notification); + expect(result).toHaveLength(1); + expect(result[0]).toContain('foo: bar'); + }); + + it('should defer the whole context until the library loads when any item is an object', async () => { + const NotificationContextController = await loadController(); + const host = createLitElement(); + const controller = new NotificationContextController(host); + const notification = createNotification(['plain string', { foo: 'bar' }]); + + expect(controller.getContext(notification)).toEqual([]); + + await vi.waitFor(() => expect(host.requestUpdate).toHaveBeenCalled()); + + const result = controller.getContext(notification); + expect(result[0]).toBe('plain string'); + expect(result[1]).toContain('foo: bar'); + }); + + it('should swallow a failed library load and render without the dumped context', async () => { + vi.doMock('js-yaml', () => { + throw new Error('chunk load failed'); + }); + const NotificationContextController = await loadController(); + const host = createLitElement(); + const controller = new NotificationContextController(host); + const notification = createNotification([{ foo: 'bar' }]); + + expect(controller.getContext(notification)).toEqual([]); + + // Allow the rejected dynamic import to settle. + await flushPromises(); + + expect(host.requestUpdate).not.toHaveBeenCalled(); + expect(controller.getContext(notification)).toEqual([]); + }); +}); diff --git a/tests/components-lib/notification/notification-popup-controller.test.ts b/tests/components-lib/notification/notification-popup-controller.test.ts new file mode 100644 index 00000000..7f04be44 --- /dev/null +++ b/tests/components-lib/notification/notification-popup-controller.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest'; + +import { NotificationPopupController } from '../../../src/components-lib/notification/notification-popup-controller'; +import { POP_OUT_ANIMATION_NAME } from '../../../src/const'; +import { createLitElement } from '../../test-utils'; + +// @vitest-environment jsdom +describe('NotificationPopupController', () => { + const create = (getNotificationElement?: () => HTMLElement | null) => { + const host = createLitElement(); + document.body.appendChild(host); + const popup = document.createElement('div'); + + const controller = new NotificationPopupController( + host, + getNotificationElement ?? (() => popup), + ); + controller.hostConnected(); + + // Cleanup (disconnecting the window listeners and clearing the DOM) is + // registered per test, so leaked listeners cannot bleed into later tests. + onTestFinished(() => { + controller.hostDisconnected(); + document.body.replaceChildren(); + }); + + return { host, popup, controller }; + }; + + it('should add itself to the host', () => { + const { host, controller } = create(); + expect(host.addController).toHaveBeenCalledWith(controller); + }); + + describe('dismiss', () => { + it('should mark the notification element as exiting', () => { + const { popup, controller } = create(); + controller.dismiss(); + expect(popup.classList.contains('exiting')).toBe(true); + }); + + it('should do nothing when there is no notification element', () => { + const controller = new NotificationPopupController(createLitElement(), () => null); + expect(() => controller.dismiss()).not.toThrow(); + }); + }); + + describe('outside interaction', () => { + it('should dismiss on a click outside the host', () => { + const { popup } = create(); + const outside = document.createElement('div'); + document.body.appendChild(outside); + outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + expect(popup.classList.contains('exiting')).toBe(true); + }); + + it('should dismiss on a focus outside the host', () => { + const { popup } = create(); + const outside = document.createElement('div'); + document.body.appendChild(outside); + outside.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + expect(popup.classList.contains('exiting')).toBe(true); + }); + + it('should not dismiss on an interaction inside the host', () => { + const { host, popup } = create(); + host.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + expect(popup.classList.contains('exiting')).toBe(false); + }); + + it('should stop listening once disconnected', () => { + const { popup, controller } = create(); + controller.hostDisconnected(); + + const outside = document.createElement('div'); + document.body.appendChild(outside); + outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + expect(popup.classList.contains('exiting')).toBe(false); + }); + }); + + describe('keydown', () => { + it('should dismiss and consume the Escape key', () => { + const { popup } = create(); + const ev = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + document.body.dispatchEvent(ev); + expect(popup.classList.contains('exiting')).toBe(true); + expect(ev.defaultPrevented).toBe(true); + }); + + it('should ignore other keys', () => { + const { popup } = create(); + const ev = new KeyboardEvent('keydown', { + key: 'a', + bubbles: true, + cancelable: true, + }); + document.body.dispatchEvent(ev); + expect(popup.classList.contains('exiting')).toBe(false); + expect(ev.defaultPrevented).toBe(false); + }); + }); + + describe('animation end', () => { + // Dispatch a real `animationend` event on the element the handler is bound + // to, so `target` and `currentTarget` are genuinely the same node. + const dispatchAnimationEnd = ( + controller: NotificationPopupController, + animationName: string, + ): void => { + const element = document.createElement('div'); + element.addEventListener('animationend', controller.handleAnimationEnd); + + const ev = new Event('animationend'); + Object.defineProperty(ev, 'animationName', { value: animationName }); + element.dispatchEvent(ev); + }; + + it('should dispatch the dismiss event when the pop-out animation ends', () => { + const { host, controller } = create(); + const dismissed = vi.fn(); + host.addEventListener('advanced-camera-card:notification:dismiss', dismissed); + dispatchAnimationEnd(controller, POP_OUT_ANIMATION_NAME); + expect(dismissed).toHaveBeenCalled(); + }); + + it('should ignore other animations ending', () => { + const { host, controller } = create(); + const dismissed = vi.fn(); + host.addEventListener('advanced-camera-card:notification:dismiss', dismissed); + dispatchAnimationEnd(controller, 'pop-in'); + expect(dismissed).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/ha/resolved-media.test.ts b/tests/ha/resolved-media.test.ts index 41f06e68..e16164b0 100644 --- a/tests/ha/resolved-media.test.ts +++ b/tests/ha/resolved-media.test.ts @@ -4,12 +4,14 @@ import { ResolvedMediaCache, resolveMedia } from '../../src/ha/resolved-media'; import { resolvedMediaSchema, type ResolvedMedia } from '../../src/ha/types'; import { homeAssistantWSRequest } from '../../src/ha/ws-request'; import { errorToConsole } from '../../src/utils/basic'; +import type * as UtilsBasic from '../../src/utils/basic'; import { createHASS } from '../test-utils'; vi.mock('../../src/ha/ws-request', () => ({ homeAssistantWSRequest: vi.fn(), })); -vi.mock('../../src/utils/basic', () => ({ +vi.mock('../../src/utils/basic', async (importOriginal) => ({ + ...(await importOriginal()), errorToConsole: vi.fn(), }));