Files
frontend/test/managers/notification-manager.test.ts
d03ba15c09 Fix notification stacking (#53486)
* Stack notifications independently

* Preserve identified notifications on legacy close

* Preserve updated notifications during close

* Fix notification stack positioning

* Update src/managers/notification-manager.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-08-05 11:54:11 +03:00

363 lines
11 KiB
TypeScript

import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import "../../src/managers/notification-manager";
const localize = vi.fn((key: string, args?: Record<string, string | number>) =>
args ? `${key}:${JSON.stringify(args)}` : key
);
vi.mock("../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-icon-button", () => {
customElements.define("ha-icon-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-toast", () => {
customElements.define(
"ha-toast",
class extends HTMLElement {
public show = vi.fn();
public hide = vi.fn();
}
);
return {};
});
@customElement("test-notification-manager-host")
class TestNotificationManagerHost extends LitElement {
@property({ attribute: false }) public hass = {
localize,
} as unknown as HomeAssistant;
protected render() {
return html`<notification-manager
.hass=${this.hass}
></notification-manager>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-notification-manager-host": TestNotificationManagerHost;
}
}
let host: TestNotificationManagerHost | undefined;
const mountManager = async () => {
host = document.createElement(
"test-notification-manager-host"
) as TestNotificationManagerHost;
document.body.append(host);
await host.updateComplete;
return host.shadowRoot!.querySelector("notification-manager")!;
};
afterEach(() => {
host?.remove();
host = undefined;
vi.clearAllMocks();
});
describe("notification-manager", () => {
it("shows a message with the default duration", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("0px");
expect(toast.show).toHaveBeenCalledOnce();
});
it("renders a dismiss button and closes with the dismiss reason", async () => {
const manager = await mountManager();
await manager.showDialog({
message: "Connection lost",
dismissable: true,
duration: -1,
bottomOffset: 16,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.timeoutMs).toBe(-1);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("16px");
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
it("clears the latest anonymous notification when duration is zero without an ID", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "First message", duration: -1 });
await manager.showDialog({
id: "identified",
message: "Identified message",
duration: -1,
});
await manager.showDialog({ message: "Second message", duration: -1 });
await manager.showDialog({ message: "", duration: 0 });
await manager.updateComplete;
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["First message", "Identified message"]);
});
it.each([
{ duration: 1, expected: 4000 },
{ duration: 4000, expected: 4000 },
{ duration: 4001, expected: 4001 },
{ duration: -1, expected: -1 },
])(
"normalizes a $duration ms duration to $expected ms",
async ({ duration, expected }) => {
const manager = await mountManager();
await manager.showDialog({ message: "Message", duration });
expect(manager.shadowRoot!.querySelector("ha-toast")!.timeoutMs).toBe(
expected
);
}
);
it("localizes visible and assistive messages with numeric arguments", async () => {
const manager = await mountManager();
await manager.showDialog({
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toContain('"seconds":59');
expect(toast.announceText).toContain('"seconds":60');
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 59 }
);
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 60 }
);
});
it("renders and invokes primary and secondary actions", async () => {
const manager = await mountManager();
const primary = vi.fn();
const secondary = vi.fn();
await manager.showDialog({
message: "Update available",
action: { action: primary, primary: true, text: "Update now" },
secondaryAction: { action: secondary, text: "Cancel" },
duration: -1,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const buttons = manager.shadowRoot!.querySelectorAll("ha-button");
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent?.trim()).toBe("Cancel");
expect(buttons[0].getAttribute("appearance")).toBe("plain");
expect(buttons[1].textContent?.trim()).toBe("Update now");
expect(buttons[1].getAttribute("appearance")).toBe("filled");
buttons[0].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(secondary).toHaveBeenCalledOnce();
expect(primary).not.toHaveBeenCalled();
buttons[1].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(primary).toHaveBeenCalledOnce();
});
it("invokes the action belonging to the selected stacked toast", async () => {
const manager = await mountManager();
const firstAction = vi.fn();
const secondAction = vi.fn();
await manager.showDialog({
id: "first",
message: "First",
action: { action: firstAction, text: "First action" },
duration: -1,
});
await manager.showDialog({
id: "second",
message: "Second",
action: { action: secondAction, text: "Second action" },
duration: -1,
});
manager.shadowRoot!.querySelectorAll("ha-button")[1].click();
expect(secondAction).toHaveBeenCalledOnce();
expect(firstAction).not.toHaveBeenCalled();
expect(
manager.shadowRoot!.querySelectorAll("ha-toast")[1].hide
).toHaveBeenCalledWith("action");
});
it("keeps a same-ID toast visible while replacing its content", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "status", message: "Second" });
expect(toast.hide).not.toHaveBeenCalled();
expect(toast.labelText).toBe("Second");
});
it("stacks toasts with different IDs", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
await manager.showDialog({ id: "second", message: "Second" });
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(2);
expect([...toasts].map((toast) => toast.labelText)).toEqual([
"First",
"Second",
]);
});
it("uses the largest bottom offset for the notification stack", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "first",
message: "First",
bottomOffset: 16,
});
await manager.showDialog({
id: "second",
message: "Second",
bottomOffset: 48,
});
expect(
manager
.shadowRoot!.querySelector<HTMLElement>(".stack")!
.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("48px");
});
it("closes only the toast matching a duration-zero ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
await manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "first", message: "", duration: 0 });
await manager.updateComplete;
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(1);
expect(toasts[0].labelText).toBe("Second");
});
it("keeps a same-ID update that arrives while the previous toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
let finishHide: () => void;
vi.mocked(toast.hide).mockImplementation(
() =>
new Promise<void>((resolve) => {
finishHide = resolve;
})
);
const closing = manager.showDialog({
id: "status",
message: "",
duration: 0,
});
await Promise.resolve();
await manager.showDialog({ id: "status", message: "Second" });
finishHide!();
await closing;
await manager.updateComplete;
const remainingToast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(remainingToast.labelText).toBe("Second");
});
it("keeps a frontend update visible throughout server startup", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "frontend-update-available",
message: "Frontend update available",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Home Assistant is starting",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Starting recorder",
duration: -1,
});
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Starting recorder"]);
await manager.showDialog({
id: "server-startup",
message: "Home Assistant started",
duration: 5000,
});
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Home Assistant started"]);
});
it("clears rendered state after the toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Message" });
manager.shadowRoot!.querySelector("ha-toast")!.dispatchEvent(
new CustomEvent("toast-closed", {
detail: { reason: "programmatic" },
})
);
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
});