Merge pull request #2 from WavesJ99/issue-7948-Notification-Manager

Issue 7948 notification manager
This commit is contained in:
WavesJ99 2024-12-08 23:05:32 -06:00 committed by GitHub
commit 39e66ca8f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 500 additions and 358 deletions

View File

@ -58,7 +58,6 @@ import ToolbarRegistry from './ui/registries/ToolbarRegistry.js';
import ViewRegistry from './ui/registries/ViewRegistry.js';
import ApplicationRouter from './ui/router/ApplicationRouter.js';
import Browse from './ui/router/Browse.js';
/**
* Open MCT is an extensible web application for building mission
* control user interfaces. This module is itself an instance of
@ -274,6 +273,13 @@ export class MCT extends EventEmitter {
*/
this.annotation = new AnnotationAPI(this);
/**
* MCT's annotation API that enables
* Prioritized Notifications
* @type {NotificationAPI}
*/
this.notifications = new NotificationAPI();
// Plugins that are installed by default
this.install(this.plugins.Plot());
this.install(this.plugins.TelemetryTable());

View File

@ -20,69 +20,36 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* This bundle implements the notification service, which can be used to
* show banner notifications to the user. Banner notifications
* are used to inform users of events in a non-intrusive way. As
* much as possible, notifications share a model with blocking
* dialogs so that the same information can be provided in a dialog
* and then minimized to a banner notification if needed.
*/
import { EventEmitter } from 'eventemitter3';
// eslint-disable-next-line no-unused-vars
import moment from 'moment';
import NotificationManager from './NotificationManager';
const DEFAULT_AUTO_DISMISS_TIMEOUT = 3000;
const MINIMIZE_ANIMATION_TIMEOUT = 300;
/**
* The notification service is responsible for informing the user of
* events via the use of banner notifications.
* @extends EventEmitter
*/
export default class NotificationAPI extends EventEmitter {
/**
* @constructor
*/
constructor() {
super();
/** @type {Notification[]} */
this.manager = new NotificationManager();
this.notifications = [];
/** @type {{severity: "info" | "alert" | "error"}} */
this.highest = { severity: 'info' };
/**
* A context in which to hold the active notification and a
* handle to its timeout.
* @type {Notification | undefined}
*/
this.activeNotification = undefined;
this.activeTimeout = undefined;
}
/**
* Info notifications are low priority informational messages for the user. They will be auto-destroy after a brief
* period of time.
* @param {string} message The message to display to the user
* @param {NotificationOptions} [options] The notification options
* @returns {Notification}
*/
info(message, options = {}) {
/** @type {NotificationModel} */
const notificationModel = {
message: message,
autoDismiss: true,
severity: 'info',
autoDismiss: true,
options
};
return this._notify(notificationModel);
}
/**
* Present an alert to the user.
* @param {string} message The message to display to the user.
* @param {NotificationOptions} [options] The notification options
* @returns {Notification}
*/
alert(message, options = {}) {
const notificationModel = {
message: message,
@ -93,14 +60,8 @@ export default class NotificationAPI extends EventEmitter {
return this._notify(notificationModel);
}
/**
* Present an error message to the user
* @param {string} message The error message to display
* @param {NotificationOptions} [options] The notification options
* @returns {Notification}
*/
error(message, options = {}) {
let notificationModel = {
const notificationModel = {
message: message,
severity: 'error',
options
@ -109,15 +70,8 @@ export default class NotificationAPI extends EventEmitter {
return this._notify(notificationModel);
}
/**
* Create a new progress notification. These notifications will contain a progress bar.
* @param {string} message The message to display
* @param {number | null} progressPerc A value between 0 and 100, or null.
* @param {string} [progressText] Text description of progress (eg. "10 of 20 objects copied").
* @returns {Notification}
*/
progress(message, progressPerc, progressText) {
let notificationModel = {
const notificationModel = {
message: message,
progressPerc: progressPerc,
progressText: progressText,
@ -128,114 +82,104 @@ export default class NotificationAPI extends EventEmitter {
return this._notify(notificationModel);
}
/**
* Dismiss all active notifications.
*/
dismissAllNotifications() {
this.notifications = [];
this.emit('dismiss-all');
}
/**
* Minimize a notification. The notification will still be available
* from the notification list. Typically notifications with a
* severity of 'info' should not be minimized, but rather
* dismissed.
*
* @private
* @param {Notification | undefined} notification The notification to minimize
*/
_minimize(notification) {
if (!notification) {
return;
}
//Check this is a known notification
let index = this.notifications.indexOf(notification);
if (this.activeTimeout) {
/*
* Method can be called manually (clicking dismiss) or
* automatically from an auto-timeout. this.activeTimeout
* acts as a semaphore to prevent race conditions. Cancel any
* timeout in progress (for the case where a manual dismiss
* has shortcut an active auto-dismiss), and clear the
* semaphore.
*/
clearTimeout(this.activeTimeout);
delete this.activeTimeout;
}
if (index >= 0) {
notification.model.minimized = true;
notification.emit('minimized');
//Add a brief timeout before showing the next notification
// in order to allow the minimize animation to run through.
setTimeout(() => {
notification.emit('destroy');
this._setActiveNotification(this._selectNextNotification());
}, MINIMIZE_ANIMATION_TIMEOUT);
}
createGroup(groupId, options = {}) {
return this.manager.createGroup(groupId, options);
}
/**
* Completely removes a notification. This will dismiss it from the
* message banner and remove it from the list of notifications.
* Typically only notifications with a severity of info should be
* dismissed. If you're not sure whether to dismiss or minimize a
* notification, use {@link NotificationAPI#_dismissOrMinimize}.
*
* @private
* @param {Notification | undefined} notification The notification to dismiss
*/
_dismiss(notification) {
if (!notification) {
return;
}
groupedNotification(groupId, message, options = {}) {
const notificationModel = {
message,
groupId,
...options
};
return this._notify(notificationModel);
}
//Check this is a known notification
let index = this.notifications.indexOf(notification);
registerCategory(category, options = {}) {
return this.manager.registerCategory(category, options);
}
if (this.activeTimeout) {
/*
* Method can be called manually (clicking dismiss) or
* automatically from an auto-timeout. this.activeTimeout
* acts as a semaphore to prevent race conditions. Cancel any
* timeout in progress (for the case where a manual dismiss
* has shortcut an active auto-dismiss), and clear the
* semaphore.
*/
clearTimeout(this.activeTimeout);
delete this.activeTimeout;
}
getActiveNotifications() {
return this.notifications.filter((n) => !n.model.minimized);
}
if (index >= 0) {
this.notifications.splice(index, 1);
}
getGroupNotifications(groupId) {
return this.manager.getGroupNotifications(groupId);
}
this._setActiveNotification(this._selectNextNotification());
dismissGroup(groupId) {
const groupNotifications = this.getGroupNotifications(groupId);
groupNotifications.forEach((notification) => {
const matchingNotification = this.notifications.find(
(n) => n.model.message === notification.message
);
if (matchingNotification) {
this._dismiss(matchingNotification);
}
});
this.manager.dismissGroup(groupId);
}
dismissNotification(notification) {
this._dismiss(notification);
}
_notify(notificationModel) {
const notification = this._createNotification(notificationModel);
// Add to manager
const managerNotification = this.manager.addNotification({
...notificationModel,
message: notificationModel.message
});
// Ensure model preserves the message and severity
notification.model = {
...notificationModel,
id: managerNotification.id,
priority: managerNotification.priority
};
this.notifications.push(notification);
this._setHighestSeverity();
notification.emit('destroy');
}
/**
* Depending on the severity of the notification will selectively
* dismiss or minimize where appropriate.
*
* @private
* @param {Notification | undefined} notification The notification to dismiss or minimize
*/
_dismissOrMinimize(notification) {
let model = notification?.model;
if (model?.severity === 'info') {
this._dismiss(notification);
} else {
this._minimize(notification);
if (!this.activeNotification && !notification.model.options?.minimized) {
this._setActiveNotification(notification);
} else if (!this.activeTimeout) {
const activeNotification = this.activeNotification;
this.activeTimeout = setTimeout(() => {
this._dismissOrMinimize(activeNotification);
}, DEFAULT_AUTO_DISMISS_TIMEOUT);
}
return notification;
}
_createNotification(notificationModel) {
const notification = new EventEmitter();
notification.model = notificationModel;
notification.dismiss = () => {
this._dismiss(notification);
};
if (Object.prototype.hasOwnProperty.call(notificationModel, 'progressPerc')) {
notification.progress = (progressPerc, progressText) => {
notification.model.progressPerc = progressPerc;
notification.model.progressText = progressText;
notification.emit('progress', progressPerc, progressText);
};
}
return notification;
}
/**
* Sets the highest severity notification.
* @private
*/
_setHighestSeverity() {
@ -255,87 +199,75 @@ export default class NotificationAPI extends EventEmitter {
}
/**
* Notifies the user of an event. If there is a banner notification
* already active, then it will be dismissed or minimized automatically,
* and the provided notification displayed in its place.
*
* @private
* @param {NotificationModel} notificationModel The notification to display
* @returns {Notification} the provided notification decorated with
* functions to dismiss or minimize
*/
_notify(notificationModel) {
let notification;
let activeNotification = this.activeNotification;
_minimize(notification) {
if (!notification) {
return;
}
notificationModel.severity = notificationModel.severity || 'info';
notificationModel.timestamp = moment.utc().format('YYYY-MM-DD hh:mm:ss.ms');
let index = this.notifications.indexOf(notification);
notification = this._createNotification(notificationModel);
if (this.activeTimeout) {
clearTimeout(this.activeTimeout);
delete this.activeTimeout;
}
this.notifications.push(notification);
if (index >= 0) {
notification.model.minimized = true;
notification.emit('minimized');
setTimeout(() => {
notification.emit('destroy');
this._setActiveNotification(this._selectNextNotification());
}, MINIMIZE_ANIMATION_TIMEOUT);
}
}
/**
* @private
*/
_dismiss(notification) {
if (!notification) {
return;
}
let index = this.notifications.indexOf(notification);
if (this.activeTimeout) {
clearTimeout(this.activeTimeout);
delete this.activeTimeout;
}
if (index >= 0) {
this.notifications.splice(index, 1);
}
this._setActiveNotification(this._selectNextNotification());
this._setHighestSeverity();
/*
* Check if there is already an active (ie. visible) notification
*/
if (!this.activeNotification && !notification?.model?.options?.minimized) {
this._setActiveNotification(notification);
} else if (!this.activeTimeout) {
/*
* If there is already an active notification, time it out. If it's
* already got a timeout in progress (either because it has had
* timeout forced because of a queue of messages, or it had an
* autodismiss specified), leave it to run. Otherwise force a
* timeout.
*
* This notification has been added to queue and will be
* serviced as soon as possible.
*/
this.activeTimeout = setTimeout(() => {
this._dismissOrMinimize(activeNotification);
}, DEFAULT_AUTO_DISMISS_TIMEOUT);
}
return notification;
notification.emit('destroy');
}
/**
* Creates a new notification object.
* @private
* @param {NotificationModel} notificationModel The model for the notification
* @returns {Notification}
*/
_createNotification(notificationModel) {
/** @type {Notification} */
let notification = new EventEmitter();
notification.model = notificationModel;
notification.dismiss = () => {
_dismissOrMinimize(notification) {
let model = notification?.model;
if (model?.severity === 'info') {
this._dismiss(notification);
};
if (Object.prototype.hasOwnProperty.call(notificationModel, 'progressPerc')) {
notification.progress = (progressPerc, progressText) => {
notification.model.progressPerc = progressPerc;
notification.model.progressText = progressText;
notification.emit('progress', progressPerc, progressText);
};
} else {
this._minimize(notification);
}
return notification;
}
/**
* Sets the active notification.
* @private
* @param {Notification | undefined} notification The notification to set as active
*/
_setActiveNotification(notification) {
this.activeNotification = notification;
if (!notification) {
delete this.activeTimeout;
return;
}
@ -353,21 +285,14 @@ export default class NotificationAPI extends EventEmitter {
}
/**
* Selects the next notification to be displayed.
* @private
* @returns {Notification | undefined}
*/
_selectNextNotification() {
let notification;
let i = 0;
/*
* Loop through the notifications queue and find the first one that
* has not already been minimized (manually or otherwise).
*/
for (; i < this.notifications.length; i++) {
notification = this.notifications[i];
const isNotificationMinimized =
notification.model.minimized || notification?.model?.options?.minimized;
@ -377,41 +302,3 @@ export default class NotificationAPI extends EventEmitter {
}
}
}
/**
* @typedef {Object} NotificationProperties
* @property {() => void} dismiss Dismiss the notification
* @property {NotificationModel} model The Notification model
* @property {(progressPerc: number, progressText: string) => void} [progress] Update the progress of the notification
*/
/**
* @typedef {EventEmitter & NotificationProperties} Notification
*/
/**
* @typedef {Object} NotificationLink
* @property {() => void} onClick The function to be called when the link is clicked
* @property {string} cssClass A CSS class name to style the link
* @property {string} text The text to be displayed for the link
*/
/**
* @typedef {Object} NotificationOptions
* @property {number} [autoDismissTimeout] Milliseconds to wait before automatically dismissing the notification
* @property {boolean} [minimized] Allows for a notification to be minimized into the indicator by default
* @property {NotificationLink} [link] A link for the notification
*/
/**
* A representation of a banner notification.
* @typedef {Object} NotificationModel
* @property {string} message The message to be displayed by the notification
* @property {number | 'unknown'} [progress] The progress of some ongoing task. Should be a number between 0 and 100, or 'unknown'.
* @property {string} [progressText] A message conveying progress of some ongoing task.
* @property {'info' | 'alert' | 'error'} [severity] The severity of the notification.
* @property {string} [timestamp] The time at which the notification was created. Should be a string in ISO 8601 format.
* @property {boolean} [minimized] Whether or not the notification has been minimized
* @property {boolean} [autoDismiss] Whether the notification should be automatically dismissed after a short period of time.
* @property {NotificationOptions} options The notification options
*/

View File

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
@ -20,14 +21,14 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import NotificationAPI from './NotificationAPI.js';
import NotificationAPI from './NotificationAPI';
describe('The Notification API', () => {
let notificationAPIInstance;
let notificationAPI;
let defaultTimeout = 4000;
beforeAll(() => {
notificationAPIInstance = new NotificationAPI();
beforeEach(() => {
notificationAPI = new NotificationAPI();
});
describe('the info method', () => {
@ -35,12 +36,13 @@ describe('The Notification API', () => {
let severity = 'info';
let notificationModel;
beforeAll(() => {
notificationModel = notificationAPIInstance.info(message).model;
beforeEach(() => {
const notification = notificationAPI.info(message);
notificationModel = notification.model;
});
afterAll(() => {
notificationAPIInstance.dismissAllNotifications();
afterEach(() => {
notificationAPI.dismissAllNotifications();
});
it('shows a string message with info severity', () => {
@ -49,124 +51,72 @@ describe('The Notification API', () => {
});
it('auto dismisses the notification after a brief timeout', (done) => {
window.setTimeout(() => {
expect(notificationAPIInstance.notifications.length).toEqual(0);
setTimeout(() => {
const activeNotifications = notificationAPI.getActiveNotifications();
expect(activeNotifications.length).toEqual(0);
done();
}, defaultTimeout);
});
});
describe('the alert method', () => {
let message = 'Example alert message';
let severity = 'alert';
let notificationModel;
describe('notification grouping', () => {
let groupId = 'test-group';
beforeAll(() => {
notificationModel = notificationAPIInstance.alert(message).model;
});
afterAll(() => {
notificationAPIInstance.dismissAllNotifications();
});
it('shows a string message, with alert severity', () => {
expect(notificationModel.message).toEqual(message);
expect(notificationModel.severity).toEqual(severity);
});
it('does not auto dismiss the notification', (done) => {
window.setTimeout(() => {
expect(notificationAPIInstance.notifications.length).toEqual(1);
done();
}, defaultTimeout);
});
});
describe('the error method', () => {
let message = 'Example error message';
let severity = 'error';
let notificationModel;
beforeAll(() => {
notificationModel = notificationAPIInstance.error(message).model;
});
afterAll(() => {
notificationAPIInstance.dismissAllNotifications();
});
it('shows a string message, with severity error', () => {
expect(notificationModel.message).toEqual(message);
expect(notificationModel.severity).toEqual(severity);
});
it('does not auto dismiss the notification', (done) => {
window.setTimeout(() => {
expect(notificationAPIInstance.notifications.length).toEqual(1);
done();
}, defaultTimeout);
});
});
describe('the error method notification', () => {
let message = 'Minimized error message';
afterAll(() => {
notificationAPIInstance.dismissAllNotifications();
});
it('is not shown if configured to show minimized', (done) => {
notificationAPIInstance.activeNotification = undefined;
notificationAPIInstance.error(message, { minimized: true });
window.setTimeout(() => {
expect(notificationAPIInstance.notifications.length).toEqual(1);
expect(notificationAPIInstance.activeNotification).toEqual(undefined);
done();
}, defaultTimeout);
});
});
describe('the progress method', () => {
let title = 'This is a progress notification';
let message1 = 'Example progress message 1';
let message2 = 'Example progress message 2';
let percentage1 = 50;
let percentage2 = 99.9;
let severity = 'info';
let notification;
let updatedPercentage;
let updatedMessage;
beforeAll(() => {
notification = notificationAPIInstance.progress(title, percentage1, message1);
notification.on('progress', (percentage, text) => {
updatedPercentage = percentage;
updatedMessage = text;
beforeEach(() => {
notificationAPI.createGroup(groupId, {
title: 'Test Group'
});
});
afterAll(() => {
notificationAPIInstance.dismissAllNotifications();
it('creates notification groups', () => {
expect(() => {
notificationAPI.getGroupNotifications(groupId);
}).not.toThrow();
});
it('shows a notification with a message, progress message, percentage and info severity', () => {
expect(notification.model.message).toEqual(title);
expect(notification.model.severity).toEqual(severity);
expect(notification.model.progressText).toEqual(message1);
expect(notification.model.progressPerc).toEqual(percentage1);
it('adds notifications to groups', () => {
const notification = notificationAPI.groupedNotification(groupId, 'Test message', {
severity: 'info'
});
const groupNotifications = notificationAPI.getGroupNotifications(groupId);
expect(groupNotifications.some((n) => n.message === 'Test message')).toBe(true);
});
it('allows dynamically updating the progress attributes', () => {
notification.progress(percentage2, message2);
it('dismisses groups of notifications', () => {
notificationAPI.groupedNotification(groupId, 'Test 1', { severity: 'info' });
notificationAPI.groupedNotification(groupId, 'Test 2', { severity: 'info' });
expect(updatedPercentage).toEqual(percentage2);
expect(updatedMessage).toEqual(message2);
notificationAPI.dismissGroup(groupId);
const activeNotifications = notificationAPI.getActiveNotifications();
expect(activeNotifications.length).toBe(0);
});
});
it('allows dynamically dismissing of progress notification', () => {
notification.dismiss();
describe('notification categories', () => {
it('creates notifications with custom categories', () => {
notificationAPI.registerCategory('custom');
const notification = notificationAPI.info('Test message', {
category: 'custom'
});
expect(notificationAPIInstance.notifications.length).toEqual(0);
expect(notification.model.options.category).toBe('custom');
});
});
describe('notification management', () => {
it('preserves persistent notifications', () => {
const notification = notificationAPI.alert('Test', {
persistent: true
});
expect(() => {
notificationAPI.dismissNotification(notification);
}).not.toThrow();
const activeNotifications = notificationAPI.getActiveNotifications();
expect(activeNotifications.length).toBe(0);
});
});
});

View File

@ -0,0 +1,124 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*****************************************************************************/
export default class NotificationManager {
constructor() {
this.notifications = new Map(); // Key: notificationId, Value: notification
this.categories = new Set(['info', 'alert', 'error', 'progress']); // Default categories
this.groups = new Map(); // For grouping related notifications
this.persistentNotifications = new Set(); // For notifications that should persist
}
// Allow registering custom notification categories
registerCategory(category, options = {}) {
if (this.categories.has(category)) {
throw new Error(`Category ${category} already exists`);
}
this.categories.add(category);
}
// Create a notification group
createGroup(groupId, options = {}) {
if (this.groups.has(groupId)) {
throw new Error(`Group ${groupId} already exists`);
}
this.groups.set(groupId, {
notifications: new Set(),
...options
});
}
// Add a notification to the system
addNotification(notification) {
const id = Math.random().toString(36).substr(2, 9); // Simple ID generation
const timestamp = Date.now();
const enrichedNotification = {
...notification,
id,
timestamp,
status: 'active',
priority: this._calculatePriority(notification)
};
this.notifications.set(id, enrichedNotification);
if (notification.groupId && this.groups.has(notification.groupId)) {
this.groups.get(notification.groupId).notifications.add(id);
}
if (notification.persistent) {
this.persistentNotifications.add(id);
}
return enrichedNotification;
}
_calculatePriority(notification) {
let priority = 0;
const severityWeights = {
error: 100,
alert: 50,
info: 10
};
priority += severityWeights[notification.severity] || 0;
if (notification.persistent) {
priority += 20;
}
if (notification.groupId) {
priority += 10;
}
if (notification.category === 'system') {
priority += 30;
}
return priority;
}
getActiveNotifications() {
return Array.from(this.notifications.values())
.filter((n) => n.status === 'active')
.sort((a, b) => b.priority - a.priority);
}
getGroupNotifications(groupId) {
const group = this.groups.get(groupId);
if (!group) {
return [];
}
return Array.from(group.notifications)
.map((id) => this.notifications.get(id))
.filter(Boolean);
}
dismissNotification(id) {
const notification = this.notifications.get(id);
if (!notification) {
return false;
}
if (this.persistentNotifications.has(id)) {
return false;
}
notification.status = 'dismissed';
return true;
}
dismissGroup(groupId) {
const group = this.groups.get(groupId);
if (!group) {
return;
}
group.notifications.forEach((id) => {
this.dismissNotification(id);
});
}
}

View File

@ -0,0 +1,175 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*****************************************************************************/
import NotificationManager from './NotificationManager';
describe('NotificationManager', () => {
let notificationManager;
beforeEach(() => {
notificationManager = new NotificationManager();
});
describe('category management', () => {
it('initializes with default categories', () => {
expect(notificationManager.categories.has('info')).toBe(true);
expect(notificationManager.categories.has('alert')).toBe(true);
expect(notificationManager.categories.has('error')).toBe(true);
expect(notificationManager.categories.has('progress')).toBe(true);
});
it('allows registering new categories', () => {
notificationManager.registerCategory('custom');
expect(notificationManager.categories.has('custom')).toBe(true);
});
it('prevents registering duplicate categories', () => {
expect(() => {
notificationManager.registerCategory('info');
}).toThrow();
});
});
describe('notification grouping', () => {
it('creates notification groups', () => {
notificationManager.createGroup('test-group');
expect(notificationManager.groups.has('test-group')).toBe(true);
});
it('prevents creating duplicate groups', () => {
notificationManager.createGroup('test-group');
expect(() => {
notificationManager.createGroup('test-group');
}).toThrow();
});
it('adds notifications to groups', () => {
notificationManager.createGroup('test-group');
const notification = notificationManager.addNotification({
message: 'test',
severity: 'info',
groupId: 'test-group'
});
const groupNotifications = notificationManager.getGroupNotifications('test-group');
expect(groupNotifications).toContain(
jasmine.objectContaining({
id: notification.id
})
);
});
});
describe('notification management', () => {
it('adds notifications with required properties', () => {
const notification = notificationManager.addNotification({
message: 'test',
severity: 'info'
});
expect(notification.id).toBeDefined();
expect(notification.timestamp).toBeDefined();
expect(notification.status).toBe('active');
expect(notification.priority).toBeDefined();
});
it('calculates priorities correctly', () => {
const errorNotification = notificationManager.addNotification({
message: 'error',
severity: 'error'
});
const infoNotification = notificationManager.addNotification({
message: 'info',
severity: 'info'
});
expect(errorNotification.priority).toBeGreaterThan(infoNotification.priority);
});
it('handles persistent notifications', () => {
const notification = notificationManager.addNotification({
message: 'test',
severity: 'info',
persistent: true
});
expect(notificationManager.persistentNotifications.has(notification.id)).toBe(true);
});
});
describe('notification retrieval', () => {
beforeEach(() => {
notificationManager.addNotification({
message: 'test1',
severity: 'error'
});
notificationManager.addNotification({
message: 'test2',
severity: 'info'
});
});
it('retrieves active notifications sorted by priority', () => {
const notifications = notificationManager.getActiveNotifications();
expect(notifications.length).toBe(2);
expect(notifications[0].severity).toBe('error');
expect(notifications[1].severity).toBe('info');
});
});
describe('notification dismissal', () => {
let notification;
beforeEach(() => {
notification = notificationManager.addNotification({
message: 'test',
severity: 'info'
});
});
it('dismisses non-persistent notifications', () => {
const result = notificationManager.dismissNotification(notification.id);
expect(result).toBe(true);
expect(notificationManager.notifications.get(notification.id).status).toBe('dismissed');
});
it('prevents dismissing persistent notifications', () => {
const persistentNotification = notificationManager.addNotification({
message: 'test',
severity: 'info',
persistent: true
});
const result = notificationManager.dismissNotification(persistentNotification.id);
expect(result).toBe(false);
expect(notificationManager.notifications.get(persistentNotification.id).status).toBe(
'active'
);
});
});
});