Spaces:
Sleeping
Sleeping
| // Copyright (c) 2025-2026, RTE (https://www.rte-france.com) | |
| // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. | |
| // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, | |
| // you can obtain one at http://mozilla.org/MPL/2.0/. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. | |
| import { describe, it, expect, beforeEach, vi } from 'vitest'; | |
| import { interactionLogger } from './interactionLogger'; | |
| describe('interactionLogger', () => { | |
| beforeEach(() => { | |
| interactionLogger.clear(); | |
| }); | |
| it('records entries with incrementing seq and correct type', () => { | |
| interactionLogger.record('config_loaded', { network_path: '/data/net.xiidm' }); | |
| interactionLogger.record('contingency_selected', { element: 'LINE_A' }); | |
| const log = interactionLogger.getLog(); | |
| expect(log).toHaveLength(2); | |
| expect(log[0].seq).toBe(0); | |
| expect(log[0].type).toBe('config_loaded'); | |
| expect(log[0].details).toEqual({ network_path: '/data/net.xiidm' }); | |
| expect(log[1].seq).toBe(1); | |
| expect(log[1].type).toBe('contingency_selected'); | |
| }); | |
| it('assigns ISO timestamps to entries', () => { | |
| interactionLogger.record('zoom_in'); | |
| const log = interactionLogger.getLog(); | |
| // Should be a valid ISO date string | |
| expect(() => new Date(log[0].timestamp)).not.toThrow(); | |
| expect(new Date(log[0].timestamp).toISOString()).toBe(log[0].timestamp); | |
| }); | |
| it('returns a correlation_id from record()', () => { | |
| const corrId = interactionLogger.record('analysis_step1_started', { element: 'LINE_X' }); | |
| expect(typeof corrId).toBe('string'); | |
| expect(corrId.length).toBeGreaterThan(0); | |
| const log = interactionLogger.getLog(); | |
| expect(log[0].correlation_id).toBe(corrId); | |
| }); | |
| it('accepts a custom correlation_id', () => { | |
| const customId = 'my-custom-id'; | |
| const returned = interactionLogger.record('overload_toggled', {}, customId); | |
| expect(returned).toBe(customId); | |
| expect(interactionLogger.getLog()[0].correlation_id).toBe(customId); | |
| }); | |
| it('recordCompletion links to correlation_id and computes duration_ms', () => { | |
| const startTs = new Date().toISOString(); | |
| const corrId = interactionLogger.record('analysis_step1_started', { element: 'L1' }); | |
| // Simulate a small delay | |
| vi.useFakeTimers(); | |
| vi.advanceTimersByTime(500); | |
| interactionLogger.recordCompletion('analysis_step1_completed', corrId, { | |
| overloads_found: ['OL1', 'OL2'], | |
| }, startTs); | |
| vi.useRealTimers(); | |
| const log = interactionLogger.getLog(); | |
| expect(log).toHaveLength(2); | |
| expect(log[1].correlation_id).toBe(corrId); | |
| expect(log[1].type).toBe('analysis_step1_completed'); | |
| expect(log[1].duration_ms).toBeGreaterThanOrEqual(0); | |
| expect(log[1].details).toEqual({ overloads_found: ['OL1', 'OL2'] }); | |
| }); | |
| it('getLog returns a copy, not a reference', () => { | |
| interactionLogger.record('zoom_in'); | |
| const log1 = interactionLogger.getLog(); | |
| const log2 = interactionLogger.getLog(); | |
| expect(log1).not.toBe(log2); | |
| expect(log1).toEqual(log2); | |
| }); | |
| it('clear() empties the log and resets seq counter', () => { | |
| interactionLogger.record('action_favorited', { action_id: 'a1' }); | |
| interactionLogger.record('action_rejected', { action_id: 'a2' }); | |
| expect(interactionLogger.getLog()).toHaveLength(2); | |
| interactionLogger.clear(); | |
| expect(interactionLogger.getLog()).toHaveLength(0); | |
| // Seq should reset | |
| interactionLogger.record('zoom_reset'); | |
| expect(interactionLogger.getLog()[0].seq).toBe(0); | |
| }); | |
| it('maintains insertion order across many records', () => { | |
| const types = [ | |
| 'config_loaded', 'contingency_selected', 'analysis_step1_started', | |
| 'overload_toggled', 'action_selected', 'diagram_tab_changed', | |
| ] as const; | |
| for (const t of types) { | |
| interactionLogger.record(t); | |
| } | |
| const log = interactionLogger.getLog(); | |
| expect(log).toHaveLength(types.length); | |
| types.forEach((t, i) => { | |
| expect(log[i].type).toBe(t); | |
| expect(log[i].seq).toBe(i); | |
| }); | |
| }); | |
| it('defaults details to empty object when not provided', () => { | |
| interactionLogger.record('sld_overlay_closed'); | |
| expect(interactionLogger.getLog()[0].details).toEqual({}); | |
| }); | |
| it('tracks multiple concurrent async operations with distinct correlation IDs', () => { | |
| const corr1 = interactionLogger.record('analysis_step1_started', { element: 'LINE_A' }); | |
| const corr2 = interactionLogger.record('analysis_step2_started', { selected_overloads: ['OL1'] }); | |
| expect(corr1).not.toBe(corr2); | |
| const startTs = new Date().toISOString(); | |
| interactionLogger.recordCompletion('analysis_step2_completed', corr2, { actions_count: 5 }, startTs); | |
| interactionLogger.recordCompletion('analysis_step1_completed', corr1, { overloads_detected: 3 }, startTs); | |
| const log = interactionLogger.getLog(); | |
| expect(log).toHaveLength(4); | |
| expect(log[2].correlation_id).toBe(corr2); | |
| expect(log[2].type).toBe('analysis_step2_completed'); | |
| expect(log[3].correlation_id).toBe(corr1); | |
| expect(log[3].type).toBe('analysis_step1_completed'); | |
| }); | |
| it('supports all 34 interaction types without error', () => { | |
| const allTypes: import('../types').InteractionType[] = [ | |
| 'config_loaded', 'settings_opened', 'settings_tab_changed', | |
| 'settings_applied', 'settings_cancelled', 'path_picked', | |
| 'contingency_selected', 'contingency_confirmed', | |
| 'analysis_step1_started', 'analysis_step1_completed', | |
| 'overload_toggled', 'analysis_step2_started', 'analysis_step2_completed', | |
| 'prioritized_actions_displayed', | |
| 'action_selected', 'action_deselected', 'action_favorited', | |
| 'action_unfavorited', 'action_rejected', 'action_unrejected', | |
| 'manual_action_simulated', | |
| 'combine_modal_opened', 'combine_modal_closed', | |
| 'combine_pair_toggled', 'combine_pair_estimated', 'combine_pair_simulated', | |
| 'diagram_tab_changed', 'view_mode_changed', 'voltage_range_changed', | |
| 'asset_clicked', 'zoom_in', 'zoom_out', 'zoom_reset', | |
| 'inspect_query_changed', | |
| 'sld_overlay_opened', 'sld_overlay_tab_changed', 'sld_overlay_closed', | |
| 'session_saved', 'session_reload_modal_opened', 'session_reloaded', | |
| ]; | |
| for (const t of allTypes) { | |
| interactionLogger.record(t, { test: true }); | |
| } | |
| const log = interactionLogger.getLog(); | |
| expect(log).toHaveLength(allTypes.length); | |
| allTypes.forEach((t, i) => { | |
| expect(log[i].type).toBe(t); | |
| }); | |
| }); | |
| it('getLog entries are JSON-serializable', () => { | |
| interactionLogger.record('config_loaded', { path: '/data/net.xiidm', nested: { a: 1 } }); | |
| interactionLogger.record('zoom_in'); | |
| const startTs = new Date().toISOString(); | |
| const corrId = interactionLogger.record('analysis_step1_started'); | |
| interactionLogger.recordCompletion('analysis_step1_completed', corrId, { count: 3 }, startTs); | |
| const log = interactionLogger.getLog(); | |
| const json = JSON.stringify(log); | |
| const parsed = JSON.parse(json); | |
| expect(parsed).toHaveLength(4); | |
| expect(parsed[0].type).toBe('config_loaded'); | |
| expect(parsed[0].details.nested.a).toBe(1); | |
| expect(parsed[3].duration_ms).toBeGreaterThanOrEqual(0); | |
| }); | |
| it('preserves complex detail objects including arrays and nested data', () => { | |
| interactionLogger.record('analysis_step2_started', { | |
| selected_overloads: ['LINE_A', 'LINE_B'], | |
| monitor_deselected: false, | |
| config: { factor: 0.95, threshold: 0.02 }, | |
| }); | |
| const entry = interactionLogger.getLog()[0]; | |
| expect(entry.details.selected_overloads).toEqual(['LINE_A', 'LINE_B']); | |
| expect(entry.details.monitor_deselected).toBe(false); | |
| expect((entry.details.config as Record<string, unknown>).factor).toBe(0.95); | |
| }); | |
| it('duration_ms is positive for recordCompletion with real time gap', async () => { | |
| const startTs = new Date().toISOString(); | |
| const corrId = interactionLogger.record('analysis_step1_started'); | |
| // Use fake timers to simulate 250ms delay | |
| vi.useFakeTimers(); | |
| vi.advanceTimersByTime(250); | |
| interactionLogger.recordCompletion('analysis_step1_completed', corrId, {}, startTs); | |
| vi.useRealTimers(); | |
| const log = interactionLogger.getLog(); | |
| expect(log[1].duration_ms).toBeGreaterThanOrEqual(200); | |
| }); | |
| it('seq continues incrementing across record and recordCompletion calls', () => { | |
| const startTs = new Date().toISOString(); | |
| interactionLogger.record('zoom_in'); // seq 0 | |
| interactionLogger.record('zoom_out'); // seq 1 | |
| const corrId = interactionLogger.record('analysis_step1_started'); // seq 2 | |
| interactionLogger.recordCompletion('analysis_step1_completed', corrId, {}, startTs); // seq 3 | |
| interactionLogger.record('zoom_reset'); // seq 4 | |
| const log = interactionLogger.getLog(); | |
| expect(log.map(e => e.seq)).toEqual([0, 1, 2, 3, 4]); | |
| }); | |
| }); | |