// 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, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useSettings } from './useSettings'; import { interactionLogger } from '../utils/interactionLogger'; // Mock the api module vi.mock('../api', () => ({ api: { pickPath: vi.fn(), getUserConfig: vi.fn().mockResolvedValue({}), getConfigFilePath: vi.fn().mockResolvedValue(''), saveUserConfig: vi.fn().mockResolvedValue({}), setConfigFilePath: vi.fn().mockResolvedValue({ config_file_path: '', config: {} }), }, })); describe('useSettings — interaction logging', () => { beforeEach(() => { interactionLogger.clear(); vi.clearAllMocks(); }); it('logs settings_opened with tab when handleOpenSettings is called', () => { const { result } = renderHook(() => useSettings()); act(() => { result.current.handleOpenSettings('recommender'); }); const log = interactionLogger.getLog(); expect(log).toHaveLength(1); expect(log[0].type).toBe('settings_opened'); expect(log[0].details).toEqual({ tab: 'recommender' }); }); it('logs settings_opened with default tab "paths"', () => { const { result } = renderHook(() => useSettings()); act(() => { result.current.handleOpenSettings(); }); const log = interactionLogger.getLog(); expect(log[0].details).toEqual({ tab: 'paths' }); }); it('logs settings_cancelled when handleCloseSettings is called', () => { const { result } = renderHook(() => useSettings()); // Open first to set backup act(() => { result.current.handleOpenSettings(); }); interactionLogger.clear(); act(() => { result.current.handleCloseSettings(); }); const log = interactionLogger.getLog(); expect(log).toHaveLength(1); expect(log[0].type).toBe('settings_cancelled'); }); it('logs path_picked when pickSettingsPath succeeds', async () => { const { api } = await import('../api'); (api.pickPath as ReturnType).mockResolvedValue('/data/network.xiidm'); const { result } = renderHook(() => useSettings()); const setter = vi.fn(); await act(async () => { await result.current.pickSettingsPath('file', setter); }); const log = interactionLogger.getLog(); expect(log).toHaveLength(1); expect(log[0].type).toBe('path_picked'); expect(log[0].details).toEqual({ type: 'file', path: '/data/network.xiidm' }); expect(setter).toHaveBeenCalledWith('/data/network.xiidm'); }); it('does not log path_picked when pickSettingsPath returns empty', async () => { const { api } = await import('../api'); (api.pickPath as ReturnType).mockResolvedValue(''); const { result } = renderHook(() => useSettings()); await act(async () => { await result.current.pickSettingsPath('dir', vi.fn()); }); expect(interactionLogger.getLog()).toHaveLength(0); }); it('does not log path_picked when pickSettingsPath throws', async () => { const { api } = await import('../api'); (api.pickPath as ReturnType).mockRejectedValue(new Error('No picker')); const { result } = renderHook(() => useSettings()); await act(async () => { await result.current.pickSettingsPath('file', vi.fn()); }); expect(interactionLogger.getLog()).toHaveLength(0); }); it('logs settings_opened for each tab type', () => { const { result } = renderHook(() => useSettings()); const tabs = ['paths', 'recommender', 'configurations'] as const; for (const tab of tabs) { act(() => { result.current.handleOpenSettings(tab); }); } const log = interactionLogger.getLog(); expect(log).toHaveLength(3); expect(log.map(e => e.details.tab)).toEqual(['paths', 'recommender', 'configurations']); }); // Bug 4: the file-picker buttons used to silently fail when the native // dialog couldn't open (no display, tkinter missing). They now surface // the backend error via window.alert so the user understands *why* // nothing is happening, while still leaving the setter untouched. describe('pickSettingsPath error surfacing (Bug 4)', () => { it('calls window.alert with the backend error message when the picker fails', async () => { const { api } = await import('../api'); (api.pickPath as ReturnType).mockRejectedValue( new Error('tkinter: no display'), ); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => { }); const setter = vi.fn(); const { result } = renderHook(() => useSettings()); await act(async () => { await result.current.pickSettingsPath('file', setter); }); expect(alertSpy).toHaveBeenCalledTimes(1); const message = alertSpy.mock.calls[0][0] as string; expect(message).toContain('tkinter: no display'); expect(setter).not.toHaveBeenCalled(); alertSpy.mockRestore(); }); it('does not alert when the picker succeeds', async () => { const { api } = await import('../api'); (api.pickPath as ReturnType).mockResolvedValue('/tmp/x.xiidm'); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => { }); const setter = vi.fn(); const { result } = renderHook(() => useSettings()); await act(async () => { await result.current.pickSettingsPath('file', setter); }); expect(alertSpy).not.toHaveBeenCalled(); expect(setter).toHaveBeenCalledWith('/tmp/x.xiidm'); alertSpy.mockRestore(); }); it('hints at backend version mismatch when the request 404s', async () => { // axios error shape with response.status — what the user // hits when the picker endpoint isn't on the running // backend (stale instance, mismatched code). const { api } = await import('../api'); const axiosLike = Object.assign(new Error('Request failed with status code 404'), { response: { status: 404 }, }); (api.pickPath as ReturnType).mockRejectedValue(axiosLike); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => { }); const { result } = renderHook(() => useSettings()); await act(async () => { await result.current.pickSettingsPath('file', vi.fn()); }); expect(alertSpy).toHaveBeenCalledTimes(1); const message = alertSpy.mock.calls[0][0] as string; expect(message).toContain('Request failed with status code 404'); expect(message).toContain('http://127.0.0.1:8000'); expect(message).toMatch(/restart it after pulling the latest code/); alertSpy.mockRestore(); }); it('hints at backend not running when the request fails with a network error', async () => { const { api } = await import('../api'); const axiosLike = Object.assign(new Error('Network Error'), { code: 'ERR_NETWORK', }); (api.pickPath as ReturnType).mockRejectedValue(axiosLike); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => { }); const { result } = renderHook(() => useSettings()); await act(async () => { await result.current.pickSettingsPath('file', vi.fn()); }); expect(alertSpy).toHaveBeenCalledTimes(1); const message = alertSpy.mock.calls[0][0] as string; expect(message).toContain('http://127.0.0.1:8000'); alertSpy.mockRestore(); }); it('does not add the backend hint for backend-reported errors (e.g. tkinter missing)', async () => { // When the backend itself returns 200 with an `error` field // (the picker subprocess died, tkinter missing, …) the // problem isn't transport — surfacing the backend hint // would be misleading. api.pickPath rethrows the error // string as a plain Error, no `response` attached. const { api } = await import('../api'); (api.pickPath as ReturnType).mockRejectedValue( new Error('No module named tkinter'), ); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => { }); const { result } = renderHook(() => useSettings()); await act(async () => { await result.current.pickSettingsPath('file', vi.fn()); }); const message = alertSpy.mock.calls[0][0] as string; expect(message).toContain('No module named tkinter'); expect(message).not.toContain('http://127.0.0.1:8000'); alertSpy.mockRestore(); }); }); });