Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion expert_backend/services/recommender_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def _enrich_actions(self, prioritized_actions_dict):
if action_obj is not None:
topo = {}
# pypowsybl Actions use these fields
for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "substations", "switches"):
for field in ("lines_ex_bus", "lines_or_bus", "gens_bus", "loads_bus", "pst_tap", "substations", "switches"):
val = getattr(action_obj, field, None)
if val is None and isinstance(action_obj, dict):
val = action_obj.get(field)
Expand Down
117 changes: 116 additions & 1 deletion frontend/src/components/ActionFeed.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';

// Mocking dependencies
vi.mock('../api', () => ({
Expand All @@ -20,6 +20,7 @@ vi.mock('../utils/svgUtils', () => ({
}));

import ActionFeed from './ActionFeed';
import { api } from '../api';
import type { ActionDetail, AnalysisResult } from '../types';

describe('ActionFeed', () => {
Expand Down Expand Up @@ -263,4 +264,118 @@ describe('ActionFeed', () => {

expect(goodIndex).toBeLessThan(badIndex);
});

it('filters PST actions based on the PST checkbox', async () => {
const pstAction = { id: 'pst_tap_up', description: 'PST action', type: 'pst_tap_change' };
const regularAction = { id: 'line_reco_1', description: 'Regular action', type: 'line_reconnection' };

// Mock API to return both actions
vi.mocked(api.getAvailableActions).mockResolvedValueOnce([pstAction, regularAction]);

render(<ActionFeed {...defaultProps} />);

// Open search
fireEvent.click(screen.getByText('+ Manual Selection'));

// Both should be visible initially (PST filter is true by default)
expect(await screen.findByText('pst_tap_up')).toBeInTheDocument();
expect(screen.getByText('line_reco_1')).toBeInTheDocument();

// Find and click the PST checkbox to uncheck it
const pstCheckbox = screen.getByLabelText('PST');
fireEvent.click(pstCheckbox);

// PST action should be hidden, regular action should remain
expect(screen.queryByText('pst_tap_up')).not.toBeInTheDocument();
expect(screen.getByText('line_reco_1')).toBeInTheDocument();

// Check it again
fireEvent.click(pstCheckbox);
expect(await screen.findByText('pst_tap_up')).toBeInTheDocument();
});

it('hides PST actions matching search query when PST filter is unchecked', async () => {
const pstAction = { id: 'pst_tap_up', description: 'PST action' };

vi.mocked(api.getAvailableActions).mockResolvedValueOnce([pstAction]);

render(<ActionFeed {...defaultProps} />);

// Open search
fireEvent.click(screen.getByText('+ Manual Selection'));

// Uncheck PST filter
const pstCheckbox = screen.getByLabelText('PST');
fireEvent.click(pstCheckbox);

// Type "pst" in search
const searchInput = screen.getByPlaceholderText(/Search action/);
fireEvent.change(searchInput, { target: { value: 'pst' } });

// Wait for loading to finish if it hasn't already
await waitFor(() => {
expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument();
});

// PST action should NOT be visible even if it matches search query
expect(screen.queryByText('pst_tap_up')).not.toBeInTheDocument();
expect(screen.getByText('No matching actions')).toBeInTheDocument();
});

it('shows ONLY PST actions when only PST filter is checked', async () => {
const pstAction = { id: 'pst_1', description: 'PST action' };
const discoAction = { id: 'abc', description: 'Ouverture de ligne' }; // Should be recognized as disco
const unknownAction = { id: 'xyz', description: 'Some unknown action' };

vi.mocked(api.getAvailableActions).mockResolvedValueOnce([pstAction, discoAction, unknownAction]);

render(<ActionFeed {...defaultProps} />);

// Open search
fireEvent.click(screen.getByText('+ Manual Selection'));

// Uncheck everything except PST
fireEvent.click(screen.getByLabelText('Disconnections'));
fireEvent.click(screen.getByLabelText('Reconnections'));
fireEvent.click(screen.getByLabelText('Open coupling'));
fireEvent.click(screen.getByLabelText('Close coupling'));

// PST should be visible
expect(await screen.findByText('pst_1')).toBeInTheDocument();

// Disco and Unknown should NOT be visible
expect(screen.queryByText('abc')).not.toBeInTheDocument();
expect(screen.queryByText('xyz')).not.toBeInTheDocument();
});

it('hides disconnections on PST branches when Disconnections filter is off but PST is on', async () => {
const pstDiscoAction = {
id: 'disco_pst_branch',
description: 'Ouverture de la branche PST',
type: 'pst_tap_change' // Simulating backend tagging it as PST
};

vi.mocked(api.getAvailableActions).mockResolvedValueOnce([pstDiscoAction]);

render(<ActionFeed {...defaultProps} />);

// Open search
fireEvent.click(screen.getByText('+ Manual Selection'));

// Ensure Disconnections is unchecked, PST is checked
const discoCheckbox = screen.getByLabelText('Disconnections') as HTMLInputElement;
if (discoCheckbox.checked) fireEvent.click(discoCheckbox);

const pstCheckbox = screen.getByLabelText('PST') as HTMLInputElement;
if (!pstCheckbox.checked) fireEvent.click(pstCheckbox);

// PST branch disco should NOT be visible because it's a disconnection
// even if it has "pst" in id/type
await waitFor(() => {
expect(screen.queryByText('Loading actions...')).not.toBeInTheDocument();
});

expect(screen.queryByText('disco_pst_branch')).not.toBeInTheDocument();
expect(screen.getByText('All actions already added')).toBeInTheDocument();
});
});
83 changes: 64 additions & 19 deletions frontend/src/components/ActionFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const ActionFeed: React.FC<ActionFeedProps> = ({
const [loadingActions, setLoadingActions] = useState(false);
const [simulating, setSimulating] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [typeFilters, setTypeFilters] = useState({ disco: true, reco: true, open: true, close: true });
const [typeFilters, setTypeFilters] = useState({ disco: true, reco: true, open: true, close: true, pst: true });
const searchInputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [tooltip, setTooltip] = useState<{ content: React.ReactNode; x: number; y: number } | null>(null);
Expand Down Expand Up @@ -110,15 +110,24 @@ const ActionFeed: React.FC<ActionFeedProps> = ({
return availableActions
.filter(a => !alreadyShown.has(a.id))
.filter(a => {
const t = a.type || 'unknown';
if ((t.includes('disco') || t.includes('open_line') || t.includes('open_load')) && !typeFilters.disco) return false;
if ((t.includes('reco') || t.includes('close_line') || t.includes('close_load')) && !typeFilters.reco) return false;
if (t === 'open_coupling' && !typeFilters.open) return false;
if (t === 'close_coupling' && !typeFilters.close) return false;
// If it's truly 'unknown' or something else, only show if all are checked or perhaps none?
// For now, if all filters are unchecked, return false.
if (!typeFilters.disco && !typeFilters.reco && !typeFilters.open && !typeFilters.close) return false;
return true;
const t = (a.type || 'unknown').toLowerCase();
const actionId = a.id.toLowerCase();
const actionDesc = (a.description || '').toLowerCase();

const isDisco = t.includes('disco') || t.includes('open_line') || t.includes('open_load') || actionDesc.includes('ouverture');
const isReco = t.includes('reco') || t.includes('close_line') || t.includes('close_load') || actionDesc.includes('fermeture');
const isOpenCoupling = t.includes('open_coupling');
const isCloseCoupling = t.includes('close_coupling');
const isPstAction = (actionId.includes('pst') || actionDesc.includes('pst') || t.includes('pst')) && !isDisco && !isReco && !isOpenCoupling && !isCloseCoupling;

if (isDisco) return typeFilters.disco;
if (isReco) return typeFilters.reco;
if (isOpenCoupling) return typeFilters.open;
if (isCloseCoupling) return typeFilters.close;
if (isPstAction) return typeFilters.pst;

// Handle unknown or categories not explicitly listed above
return typeFilters.disco && typeFilters.reco && typeFilters.open && typeFilters.close && typeFilters.pst;
})
.filter(a => a.id.toLowerCase().includes(q) || (a.description || '').toLowerCase().includes(q))
.slice(0, 20);
Expand All @@ -129,14 +138,50 @@ const ActionFeed: React.FC<ActionFeedProps> = ({
if (!actionScores) return [];
const list: { type: string; actionId: string; score: number }[] = [];
for (const [type, data] of Object.entries(actionScores)) {
// Apply filtering
if (type === 'line_disconnection' && !typeFilters.disco) continue;
if (type === 'line_reconnection' && !typeFilters.reco) continue;
if (type === 'open_coupling' && !typeFilters.open) continue;
if (type === 'close_coupling' && !typeFilters.close) continue;
// Apply filtering logic consistent with the search dropdown
const isDiscoType = type === 'line_disconnection';
const isRecoType = type === 'line_reconnection';
const isOpenType = type === 'open_coupling';
const isCloseType = type === 'close_coupling';
const isPstType = type === 'pst_tap_change' || type.includes('pst');

if (isDiscoType && !typeFilters.disco) continue;
if (isRecoType && !typeFilters.reco) continue;
if (isOpenType && !typeFilters.open) continue;
if (isCloseType && !typeFilters.close) continue;
if (isPstType && !typeFilters.pst) continue;

// If it's a known type but its filter is off, it's already skipped.
// If it's an unknown type, we show it only if ALL filters are active.
const isKnownType = isDiscoType || isRecoType || isOpenType || isCloseType || isPstType;
if (!isKnownType && !(typeFilters.disco && typeFilters.reco && typeFilters.open && typeFilters.close && typeFilters.pst)) {
continue;
}

const scores = data?.scores || {};
for (const [actionId, score] of Object.entries(scores)) {
// Determine if this specific action should be filtered out
const actionDetail = actions[actionId];
const actionDesc = (actionDetail?.description_unitaire || '').toLowerCase();
const aid = actionId.toLowerCase();
const t = type.toLowerCase();

const isDisco = t.includes('disco') || t.includes('open_line') || t.includes('open_load') || actionDesc.includes('ouverture');
const isReco = t.includes('reco') || t.includes('close_line') || t.includes('close_load') || actionDesc.includes('fermeture');
const isOpenCoupling = t.includes('open_coupling');
const isCloseCoupling = t.includes('close_coupling');
const isPstAction = (aid.includes('pst') || actionDesc.includes('pst') || t.includes('pst')) && !isDisco && !isReco && !isOpenCoupling && !isCloseCoupling;

let shouldShow = false;
if (isDisco) shouldShow = typeFilters.disco;
else if (isReco) shouldShow = typeFilters.reco;
else if (isOpenCoupling) shouldShow = typeFilters.open;
else if (isCloseCoupling) shouldShow = typeFilters.close;
else if (isPstAction) shouldShow = typeFilters.pst;
else shouldShow = typeFilters.disco && typeFilters.reco && typeFilters.open && typeFilters.close && typeFilters.pst;

if (!shouldShow) continue;

list.push({ type, actionId, score: Number(score) });
}
}
Expand All @@ -148,7 +193,7 @@ const ActionFeed: React.FC<ActionFeedProps> = ({
}
return b.score - a.score;
});
}, [actionScores, typeFilters]);
}, [actionScores, typeFilters, actions]);

// Close dropdown on outside click
useEffect(() => {
Expand Down Expand Up @@ -450,12 +495,12 @@ const ActionFeed: React.FC<ActionFeedProps> = ({
</div>
{/* Action type filter checkboxes */}
<div style={{ padding: '4px 8px', display: 'flex', flexWrap: 'wrap', gap: '6px', borderTop: '1px solid #eee', fontSize: '11px' }}>
{([['disco', 'Disconnections'], ['reco', 'Reconnections'], ['open', 'Open coupling'], ['close', 'Close coupling']] as const).map(([key, label]) => (
{([['disco', 'Disconnections'], ['reco', 'Reconnections'], ['pst', 'PST'], ['open', 'Open coupling'], ['close', 'Close coupling']] as const).map(([key, label]) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: '3px', cursor: 'pointer', color: '#555' }}>
<input
type="checkbox"
checked={typeFilters[key]}
onChange={() => setTypeFilters(prev => ({ ...prev, [key]: !prev[key] }))}
checked={typeFilters[key as keyof typeof typeFilters]}
onChange={() => setTypeFilters(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }))}
style={{ margin: 0, cursor: 'pointer' }}
/>
{label}
Expand Down
Loading
Loading