diff --git a/src/MCT.js b/src/MCT.js index 6c16c3e8c2e..8d59b9a7fe6 100644 --- a/src/MCT.js +++ b/src/MCT.js @@ -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()); diff --git a/src/api/notifications/NotificationAPI.js b/src/api/notifications/NotificationAPI.js index 1776e7531f0..60fa72c66d4 100644 --- a/src/api/notifications/NotificationAPI.js +++ b/src/api/notifications/NotificationAPI.js @@ -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,40 +82,133 @@ export default class NotificationAPI extends EventEmitter { return this._notify(notificationModel); } - /** - * Dismiss all active notifications. - */ dismissAllNotifications() { this.notifications = []; this.emit('dismiss-all'); } + createGroup(groupId, options = {}) { + return this.manager.createGroup(groupId, options); + } + + groupedNotification(groupId, message, options = {}) { + const notificationModel = { + message, + groupId, + ...options + }; + return this._notify(notificationModel); + } + + registerCategory(category, options = {}) { + return this.manager.registerCategory(category, options); + } + + getActiveNotifications() { + return this.notifications.filter((n) => !n.model.minimized); + } + + getGroupNotifications(groupId) { + return this.manager.getGroupNotifications(groupId); + } + + 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(); + + 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; + } + + /** + * @private + */ + _setHighestSeverity() { + let severity = { + info: 1, + alert: 2, + error: 3 + }; + + this.highest.severity = this.notifications.reduce((previous, notification) => { + if (severity[notification.model.severity] > severity[previous]) { + return notification.model.severity; + } else { + return previous; + } + }, 'info'); + } + /** - * 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; } @@ -169,8 +216,7 @@ export default class NotificationAPI extends EventEmitter { 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()); @@ -179,32 +225,16 @@ export default class NotificationAPI extends EventEmitter { } /** - * 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; } - //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; } @@ -219,11 +249,7 @@ export default class NotificationAPI extends EventEmitter { } /** - * 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; @@ -235,107 +261,13 @@ export default class NotificationAPI extends EventEmitter { } /** - * Sets the highest severity notification. - * @private - */ - _setHighestSeverity() { - let severity = { - info: 1, - alert: 2, - error: 3 - }; - - this.highest.severity = this.notifications.reduce((previous, notification) => { - if (severity[notification.model.severity] > severity[previous]) { - return notification.model.severity; - } else { - return previous; - } - }, 'info'); - } - - /** - * 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; - - notificationModel.severity = notificationModel.severity || 'info'; - notificationModel.timestamp = moment.utc().format('YYYY-MM-DD hh:mm:ss.ms'); - - notification = this._createNotification(notificationModel); - - this.notifications.push(notification); - 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; - } - - /** - * 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 = () => { - 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 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 - */ diff --git a/src/api/notifications/NotificationAPISpec.js b/src/api/notifications/NotificationAPISpec.js index b97abd50131..2f648df10bf 100644 --- a/src/api/notifications/NotificationAPISpec.js +++ b/src/api/notifications/NotificationAPISpec.js @@ -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; + beforeEach(() => { + notificationAPI.createGroup(groupId, { + title: 'Test Group' + }); }); - afterAll(() => { - notificationAPIInstance.dismissAllNotifications(); + it('creates notification groups', () => { + expect(() => { + notificationAPI.getGroupNotifications(groupId); + }).not.toThrow(); }); - it('shows a string message, with alert severity', () => { - expect(notificationModel.message).toEqual(message); - expect(notificationModel.severity).toEqual(severity); - }); + it('adds notifications to groups', () => { + const notification = notificationAPI.groupedNotification(groupId, 'Test message', { + severity: 'info' + }); + const groupNotifications = notificationAPI.getGroupNotifications(groupId); - it('does not auto dismiss the notification', (done) => { - window.setTimeout(() => { - expect(notificationAPIInstance.notifications.length).toEqual(1); - done(); - }, defaultTimeout); + expect(groupNotifications.some((n) => n.message === 'Test message')).toBe(true); }); - }); - describe('the error method', () => { - let message = 'Example error message'; - let severity = 'error'; - let notificationModel; - - beforeAll(() => { - notificationModel = notificationAPIInstance.error(message).model; - }); + it('dismisses groups of notifications', () => { + notificationAPI.groupedNotification(groupId, 'Test 1', { severity: 'info' }); + notificationAPI.groupedNotification(groupId, 'Test 2', { severity: 'info' }); - afterAll(() => { - notificationAPIInstance.dismissAllNotifications(); - }); + notificationAPI.dismissGroup(groupId); + const activeNotifications = notificationAPI.getActiveNotifications(); - 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); + expect(activeNotifications.length).toBe(0); }); }); - describe('the error method notification', () => { - let message = 'Minimized error message'; - - afterAll(() => { - notificationAPIInstance.dismissAllNotifications(); - }); + describe('notification categories', () => { + it('creates notifications with custom categories', () => { + notificationAPI.registerCategory('custom'); + const notification = notificationAPI.info('Test message', { + category: 'custom' + }); - 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); + expect(notification.model.options.category).toBe('custom'); }); }); - 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; + describe('notification management', () => { + it('preserves persistent notifications', () => { + const notification = notificationAPI.alert('Test', { + persistent: true }); - }); - - afterAll(() => { - notificationAPIInstance.dismissAllNotifications(); - }); - - 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('allows dynamically updating the progress attributes', () => { - notification.progress(percentage2, message2); - - expect(updatedPercentage).toEqual(percentage2); - expect(updatedMessage).toEqual(message2); - }); - it('allows dynamically dismissing of progress notification', () => { - notification.dismiss(); + expect(() => { + notificationAPI.dismissNotification(notification); + }).not.toThrow(); - expect(notificationAPIInstance.notifications.length).toEqual(0); + const activeNotifications = notificationAPI.getActiveNotifications(); + expect(activeNotifications.length).toBe(0); }); }); }); diff --git a/src/api/notifications/NotificationManager.js b/src/api/notifications/NotificationManager.js new file mode 100644 index 00000000000..bd7e9c46f5b --- /dev/null +++ b/src/api/notifications/NotificationManager.js @@ -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); + }); + } +} diff --git a/src/api/notifications/NotificationManagerSpec.js b/src/api/notifications/NotificationManagerSpec.js new file mode 100644 index 00000000000..ef3f7fdd530 --- /dev/null +++ b/src/api/notifications/NotificationManagerSpec.js @@ -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' + ); + }); + }); +});