Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce3d1d1992 | ||
|
|
2c86e00629 | ||
|
|
f0ff953a5e | ||
|
|
7fd8d1ce30 | ||
|
|
6785d06a9a | ||
|
|
c2bd1317ed | ||
|
|
d276070ee2 | ||
|
|
e10ab44dc7 | ||
|
|
32dd0587e9 | ||
|
|
83caa9006e | ||
|
|
91752a2b0b | ||
|
|
3c378c177f | ||
|
|
2adfdf0eb0 | ||
|
|
44f3416ffe | ||
|
|
342128ccff | ||
|
|
c3fd9327ea |
@@ -5,11 +5,15 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "apps/frontend/**"
|
- "apps/frontend/**"
|
||||||
- "apps/backend/**"
|
- "apps/backend/**"
|
||||||
|
- "ml/**"
|
||||||
|
- "etl/airflow/**"
|
||||||
- ".github/workflows/sonarqube.yml"
|
- ".github/workflows/sonarqube.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "apps/frontend/**"
|
- "apps/frontend/**"
|
||||||
- "apps/backend/**"
|
- "apps/backend/**"
|
||||||
|
- "ml/**"
|
||||||
|
- "etl/airflow/**"
|
||||||
- ".github/workflows/sonarqube.yml"
|
- ".github/workflows/sonarqube.yml"
|
||||||
|
|
||||||
|
|
||||||
@@ -108,8 +112,36 @@ jobs:
|
|||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: apps/backend/coverage.xml
|
path: apps/backend/coverage.xml
|
||||||
|
|
||||||
|
test-ml:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: ml/uv.lock
|
||||||
|
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
run: uv python install
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Synchronise les dépendances sans dévier du verrou
|
||||||
|
run: uv sync --all-groups --frozen
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Lancement des tests et génération du rapport de couverture (ML)
|
||||||
|
run: uv run pytest --cov-report=xml
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ml-coverage
|
||||||
|
path: ml/coverage.xml
|
||||||
|
|
||||||
sonarqube:
|
sonarqube:
|
||||||
needs: [build-front, build-back, test-front, test-back]
|
needs: [build-front, build-back, test-front, test-back, test-ml]
|
||||||
name: SonarQube
|
name: SonarQube
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -126,6 +158,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: apps/backend
|
path: apps/backend
|
||||||
|
- name: Téléchargement du rapport de couverture (ML)
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ml-coverage
|
||||||
|
path: ml
|
||||||
- name: SonarQube Scan
|
- name: SonarQube Scan
|
||||||
uses: SonarSource/sonarqube-scan-action@v8
|
uses: SonarSource/sonarqube-scan-action@v8
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ WORKDIR /app
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
uv sync --locked --no-install-project --no-dev
|
uv sync --locked --no-install-project --no-dev --no-build
|
||||||
|
|
||||||
|
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
|
||||||
|
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
|
||||||
|
# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que
|
||||||
|
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
||||||
uv sync --locked --no-dev
|
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.14-slim AS runtime
|
FROM python:3.14-slim AS runtime
|
||||||
|
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ async def test_duplicate_reading_is_rejected_when_key_matches(
|
|||||||
)
|
)
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
@@ -147,9 +149,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail(
|
|||||||
}
|
}
|
||||||
values.update(changes)
|
values.update(changes)
|
||||||
|
|
||||||
|
statement = insert(Reading).values(**values)
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(insert(Reading).values(**values))
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
async def test_prediction_requires_period_when_energy_is_predicted(
|
async def test_prediction_requires_period_when_energy_is_predicted(
|
||||||
@@ -164,8 +169,10 @@ async def test_prediction_requires_period_when_energy_is_predicted(
|
|||||||
model_reference="test-model/1",
|
model_reference="test-model/1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
@@ -212,21 +219,22 @@ async def test_alert_rejects_prediction_when_site_differs(
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
statement = insert(Alert).values(
|
||||||
|
source_alert_id=str(uuid4()),
|
||||||
|
site_id=other_site,
|
||||||
|
source="enervision",
|
||||||
|
timestamp=MOMENT,
|
||||||
|
type="spike",
|
||||||
|
severity="high",
|
||||||
|
message="Test",
|
||||||
|
prediction_id=prediction_id,
|
||||||
|
raw_data={},
|
||||||
|
)
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(
|
await data_connection.execute(statement)
|
||||||
insert(Alert).values(
|
|
||||||
source_alert_id=str(uuid4()),
|
|
||||||
site_id=other_site,
|
|
||||||
source="enervision",
|
|
||||||
timestamp=MOMENT,
|
|
||||||
type="spike",
|
|
||||||
severity="high",
|
|
||||||
message="Test",
|
|
||||||
prediction_id=prediction_id,
|
|
||||||
raw_data={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
||||||
@@ -256,6 +264,8 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
|
|||||||
)
|
)
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|||||||
@@ -106,13 +106,15 @@ def test_validate_source_accepts_valid_dataset():
|
|||||||
def test_validate_source_rejects_missing_column():
|
def test_validate_source_rejects_missing_column():
|
||||||
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
||||||
|
|
||||||
|
metadata = make_metadata()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="Colonnes obligatoires absentes",
|
match="Colonnes obligatoires absentes",
|
||||||
):
|
):
|
||||||
validate_source(
|
validate_source(
|
||||||
frame,
|
frame,
|
||||||
make_metadata(),
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,13 +126,15 @@ def test_validate_source_rejects_duplicates():
|
|||||||
"timestamp",
|
"timestamp",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
metadata = make_metadata()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="doublons",
|
match="doublons",
|
||||||
):
|
):
|
||||||
validate_source(
|
validate_source(
|
||||||
frame,
|
frame,
|
||||||
make_metadata(),
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,13 +143,15 @@ def test_validate_source_rejects_unknown_site():
|
|||||||
|
|
||||||
frame.loc[1, "site_id"] = "SITE999"
|
frame.loc[1, "site_id"] = "SITE999"
|
||||||
|
|
||||||
|
metadata = make_metadata()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="Sites incohérents",
|
match="Sites incohérents",
|
||||||
):
|
):
|
||||||
validate_source(
|
validate_source(
|
||||||
frame,
|
frame,
|
||||||
make_metadata(),
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log(
|
|||||||
) -> None:
|
) -> None:
|
||||||
await une_ligne(session)
|
await une_ligne(session)
|
||||||
|
|
||||||
|
requete = text(instruction)
|
||||||
|
|
||||||
with pytest.raises(DBAPIError, match="ajout seul"):
|
with pytest.raises(DBAPIError, match="ajout seul"):
|
||||||
await session.execute(text(instruction))
|
await session.execute(requete)
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
|||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
empreinte = fingerprint_refresh(secret)
|
||||||
|
expiration = datetime.now(UTC) + DUREE
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await depot.create(
|
await depot.create(
|
||||||
user_id=compte,
|
user_id=compte,
|
||||||
token_hash=fingerprint_refresh(secret),
|
token_hash=empreinte,
|
||||||
expires_at=datetime.now(UTC) + DUREE,
|
expires_at=expiration,
|
||||||
client_ip=None,
|
client_ip=None,
|
||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -178,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
|||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
famille = uuid.uuid4()
|
||||||
|
empreinte = fingerprint_refresh(secret)
|
||||||
|
expiration = datetime.now(UTC) + DUREE
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await depot.create(
|
await depot.create(
|
||||||
user_id=compte,
|
user_id=compte,
|
||||||
family_id=uuid.uuid4(),
|
family_id=famille,
|
||||||
token_hash=fingerprint_refresh(secret),
|
token_hash=empreinte,
|
||||||
expires_at=datetime.now(UTC) + DUREE,
|
expires_at=expiration,
|
||||||
client_ip=None,
|
client_ip=None,
|
||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case(
|
|||||||
) -> None:
|
) -> None:
|
||||||
saisie = adresse().upper()
|
saisie = adresse().upper()
|
||||||
|
|
||||||
|
requete = text(
|
||||||
|
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await session.execute(
|
await session.execute(requete, {"e": saisie})
|
||||||
text(
|
|
||||||
"insert into app_user (email, password_hash, role) "
|
|
||||||
"values (:e, '$argon2id$x', 'lecteur')"
|
|
||||||
),
|
|
||||||
{"e": saisie},
|
|
||||||
)
|
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,13 +116,11 @@ async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
|
|||||||
async def test_list_history_raises_when_start_is_after_end() -> None:
|
async def test_list_history_raises_when_start_is_after_end() -> None:
|
||||||
service = ReadingService(readings=FakeRepository([]))
|
service = ReadingService(readings=FakeRepository([]))
|
||||||
|
|
||||||
|
debut = datetime(2026, 9, 2, tzinfo=UTC)
|
||||||
|
fin = datetime(2026, 9, 1, tzinfo=UTC)
|
||||||
|
|
||||||
with pytest.raises(FenetreInverseeError):
|
with pytest.raises(FenetreInverseeError):
|
||||||
await service.list_history(
|
await service.list_history(start=debut, end=fin, limit=500, offset=0)
|
||||||
start=datetime(2026, 9, 2, tzinfo=UTC),
|
|
||||||
end=datetime(2026, 9, 1, tzinfo=UTC),
|
|
||||||
limit=500,
|
|
||||||
offset=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_history_raises_when_start_equals_end() -> None:
|
async def test_list_history_raises_when_start_equals_end() -> None:
|
||||||
|
|||||||
@@ -235,5 +235,7 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None:
|
|||||||
if action == "set_active":
|
if action == "set_active":
|
||||||
arguments["is_active"] = False
|
arguments["is_active"] = False
|
||||||
|
|
||||||
|
methode = getattr(attirail.service, action)
|
||||||
|
|
||||||
with pytest.raises(UserNotFoundError):
|
with pytest.raises(UserNotFoundError):
|
||||||
await getattr(attirail.service, action)(**arguments)
|
await methode(**arguments)
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ def test_build_parser_reads_the_create_admin_arguments() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_build_parser_requires_a_subcommand() -> None:
|
def test_build_parser_requires_a_subcommand() -> None:
|
||||||
|
parser = cli.build_parser()
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cli.build_parser().parse_args([])
|
parser.parse_args([])
|
||||||
|
|
||||||
|
|
||||||
def test_build_parser_requires_an_email() -> None:
|
def test_build_parser_requires_an_email() -> None:
|
||||||
|
parser = cli.build_parser()
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cli.build_parser().parse_args(["create-admin"])
|
parser.parse_args(["create-admin"])
|
||||||
|
|
||||||
|
|
||||||
def test_read_password_generates_a_long_secret_when_asked(
|
def test_read_password_generates_a_long_secret_when_asked(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Conventions de tests unitaires — Frontend
|
# Conventions de tests unitaires : Frontend
|
||||||
|
|
||||||
## Outil
|
## Outil
|
||||||
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
|
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
|
||||||
@@ -83,3 +83,6 @@ describe('MonComposant', () => {
|
|||||||
## Lancer les tests
|
## Lancer les tests
|
||||||
- Développement (mode watch) : `npm test`
|
- Développement (mode watch) : `npm test`
|
||||||
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
|
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
|
||||||
|
- Un fichier ou un dossier seulement :
|
||||||
|
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
|
||||||
|
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
|
|||||||
describe('mockApiInterceptor', () => {
|
describe('mockApiInterceptor', () => {
|
||||||
let http: HttpClient;
|
let http: HttpClient;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
let useMockFixturesInitial: boolean;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
useMockFixturesInitial = environment.useMockFixtures;
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
||||||
@@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
environment.useMockFixtures = true;
|
environment.useMockFixtures = useMockFixturesInitial;
|
||||||
httpMock.verify();
|
httpMock.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
|
|||||||
|
|
||||||
export const ALERTS_FIXTURE: Alert[] = [
|
export const ALERTS_FIXTURE: Alert[] = [
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE002-1718458320',
|
alert_id: 5,
|
||||||
timestamp: '2026-09-15T11:12:00',
|
|
||||||
site_id: 'SITE002',
|
site_id: 'SITE002',
|
||||||
|
timestamp: '2026-09-15T11:12:00Z',
|
||||||
|
type: 'threshold',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
type: 'outage',
|
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
|
||||||
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
|
|
||||||
value: 812.5,
|
value: 812.5,
|
||||||
threshold: 720.0,
|
threshold: 720.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE003-1718458321',
|
alert_id: 4,
|
||||||
timestamp: '2026-09-15T11:05:00',
|
|
||||||
site_id: 'SITE003',
|
site_id: 'SITE003',
|
||||||
|
timestamp: '2026-09-15T11:05:00Z',
|
||||||
|
type: 'outage',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
type: 'sensor',
|
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
|
||||||
message: 'Perte réseau totale sur Data Center Marseille',
|
value: null,
|
||||||
value: 0,
|
threshold: null,
|
||||||
threshold: 0,
|
metric: null,
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE005-1718458322',
|
alert_id: 3,
|
||||||
timestamp: '2026-09-15T10:47:00',
|
|
||||||
site_id: 'SITE005',
|
site_id: 'SITE005',
|
||||||
|
timestamp: '2026-09-15T10:47:00Z',
|
||||||
|
type: 'spike',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
type: 'threshold',
|
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
|
||||||
message: 'Usine Toulouse approche de son seuil de capacité',
|
|
||||||
value: 410.0,
|
value: 410.0,
|
||||||
threshold: 480.0,
|
threshold: 260.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE006-1718458323',
|
alert_id: 2,
|
||||||
timestamp: '2026-09-15T10:30:00',
|
|
||||||
site_id: 'SITE006',
|
site_id: 'SITE006',
|
||||||
severity: 'medium',
|
timestamp: '2026-09-15T10:30:00Z',
|
||||||
type: 'sensor',
|
type: 'sensor',
|
||||||
message: 'Capteur de température défaillant sur Bureau Lille',
|
severity: 'medium',
|
||||||
value: 0,
|
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
|
||||||
threshold: 0,
|
value: null,
|
||||||
|
threshold: null,
|
||||||
|
metric: null,
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE004-1718458324',
|
alert_id: 1,
|
||||||
timestamp: '2026-09-15T09:58:00',
|
|
||||||
site_id: 'SITE004',
|
site_id: 'SITE004',
|
||||||
severity: 'low',
|
timestamp: '2026-09-15T09:58:00Z',
|
||||||
type: 'anomaly',
|
type: 'anomaly',
|
||||||
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
|
severity: 'low',
|
||||||
|
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
|
||||||
value: 62.0,
|
value: 62.0,
|
||||||
threshold: 55.0,
|
threshold: 55.0,
|
||||||
|
metric: 'consumption_kwh',
|
||||||
|
prediction_id: 42,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
|
|||||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
import { AlertsService } from './alerts.service';
|
import { AlertsService } from './alerts.service';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Alert } from '../../shared/models/alert.model';
|
||||||
|
|
||||||
|
const ALERT_API: Alert = {
|
||||||
|
alert_id: 1,
|
||||||
|
site_id: 'site-1',
|
||||||
|
timestamp: '2026-09-16T00:00:00Z',
|
||||||
|
type: 'threshold',
|
||||||
|
severity: 'high',
|
||||||
|
message: 'Dépassement du seuil configuré',
|
||||||
|
value: 812.5,
|
||||||
|
threshold: 720.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
describe('AlertsService', () => {
|
describe('AlertsService', () => {
|
||||||
let service: AlertsService;
|
let service: AlertsService;
|
||||||
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
|
|||||||
|
|
||||||
afterEach(() => httpMock.verify());
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
|
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
|
||||||
let result: unknown;
|
let result: Alert[] = [];
|
||||||
service.getAlerts().subscribe((r) => (result = r));
|
service.getAlerts().subscribe((r) => (result = r));
|
||||||
|
|
||||||
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
|
const req = httpMock.expectOne(
|
||||||
expect(req.request.method).toBe('GET');
|
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
|
||||||
|
);
|
||||||
|
expect(req.request.params.keys()).toEqual([]);
|
||||||
|
req.flush([ALERT_API]);
|
||||||
|
|
||||||
req.flush([
|
expect(result.length).toBe(1);
|
||||||
{
|
expect(result[0].alert_id).toBe(1);
|
||||||
alert_id: 'ALR-TEST-1',
|
expect(result[0].prediction_id).toBeNull();
|
||||||
timestamp: '2026-09-15T12:00:00',
|
});
|
||||||
site_id: 'SITE001',
|
|
||||||
severity: 'high',
|
|
||||||
type: 'threshold',
|
|
||||||
message: 'Test',
|
|
||||||
value: 100,
|
|
||||||
threshold: 90,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect((result as unknown[]).length).toBe(1);
|
it('transmet les filtres site_id et severity en paramètres de requête', () => {
|
||||||
|
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||||
|
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||||
|
expect(req.request.params.get('severity')).toBe('high');
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne pose pas de paramètre pour un filtre omis', () => {
|
||||||
|
service.getAlerts({ site_id: 'SITE001' }).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||||
|
expect(req.request.params.has('severity')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { Service, inject } from '@angular/core';
|
import { Service, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { Alert } from '../../shared/models/alert.model';
|
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
||||||
|
|
||||||
|
export interface AlertFilters {
|
||||||
|
site_id?: string;
|
||||||
|
severity?: AlertSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class AlertsService {
|
export class AlertsService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
getAlerts() {
|
getAlerts(filters: AlertFilters = {}) {
|
||||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
|
let params = new HttpParams();
|
||||||
|
if (filters.site_id) {
|
||||||
|
params = params.set('site_id', filters.site_id);
|
||||||
|
}
|
||||||
|
if (filters.severity) {
|
||||||
|
params = params.set('severity', filters.severity);
|
||||||
|
}
|
||||||
|
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,87 +27,120 @@
|
|||||||
@if (statsError(); as message) {
|
@if (statsError(); as message) {
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
}
|
}
|
||||||
@if (alertsError(); as message) {
|
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
|
||||||
}
|
|
||||||
@if (predictionsError(); as message) {
|
@if (predictionsError(); as message) {
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (stats(); as s) {
|
@if (stats(); as s) {
|
||||||
<section class="overview">
|
<p class="dashboard__status">
|
||||||
<ev-card class="card card--gauge">
|
<span class="dashboard__pulse" aria-hidden="true"></span>
|
||||||
<span class="card__label">Consommation vs capacité</span>
|
Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="overview" aria-label="Indicateurs du parc">
|
||||||
|
<ev-card class="kpi kpi--gauge">
|
||||||
|
<span class="kpi__label">Consommation vs capacité</span>
|
||||||
<app-consumption-gauge
|
<app-consumption-gauge
|
||||||
[consumption]="s.total_consumption_kw"
|
[consumption]="s.total_consumption_kw"
|
||||||
[capacity]="s.total_capacity_kw"
|
[capacity]="s.total_capacity_kw"
|
||||||
/>
|
/>
|
||||||
<span class="card__value"
|
<span class="kpi__value">
|
||||||
>{{ s.total_consumption_kw | number: '1.0-1' }} /
|
{{ s.total_consumption_kw | number: '1.0-1' }}
|
||||||
{{ s.total_capacity_kw | number }} kW</span
|
<small>/ {{ s.total_capacity_kw | number }} kW</small>
|
||||||
|
</span>
|
||||||
|
</ev-card>
|
||||||
|
|
||||||
|
<ev-card class="kpi">
|
||||||
|
<span class="kpi__label">Charge moyenne du parc</span>
|
||||||
|
<span class="kpi__value"
|
||||||
|
>{{ s.average_load_percent | number: '1.0-0' }} <small>%</small></span
|
||||||
>
|
>
|
||||||
</ev-card>
|
<div
|
||||||
|
class="progress-bar"
|
||||||
<ev-card class="card">
|
role="progressbar"
|
||||||
<span class="card__label">Charge moyenne du parc</span>
|
aria-valuemin="0"
|
||||||
<span class="card__value">{{ s.average_load_percent }} %</span>
|
aria-valuemax="100"
|
||||||
<div class="progress-bar">
|
[attr.aria-valuenow]="s.average_load_percent"
|
||||||
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
|
>
|
||||||
|
<div
|
||||||
|
class="progress-bar__fill"
|
||||||
|
[class]="'progress-bar__fill--' + loadTone(s.average_load_percent)"
|
||||||
|
[style.width.%]="s.average_load_percent"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="kpi__hint">{{ loadHint(s.average_load_percent) }}</span>
|
||||||
</ev-card>
|
</ev-card>
|
||||||
|
|
||||||
<ev-card class="card">
|
<ev-card class="kpi">
|
||||||
<span class="card__label">Sites suivis</span>
|
<span class="kpi__label">Sites suivis</span>
|
||||||
<span class="card__value">{{ s.total_sites }}</span>
|
<span class="kpi__value">{{ s.total_sites }}</span>
|
||||||
|
<a routerLink="/sites" class="ev-link kpi__link">Voir la liste des sites</a>
|
||||||
</ev-card>
|
</ev-card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="chart-section">
|
|
||||||
<h2>Charge et alerte visuelle par site</h2>
|
|
||||||
<app-site-load-chart [sites]="s.sites" />
|
|
||||||
</section>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (alerts().length > 0) {
|
<div class="dashboard__grid">
|
||||||
<section class="alerts-section">
|
<div class="dashboard__main">
|
||||||
<h2>Alertes actives</h2>
|
@if (stats(); as s) {
|
||||||
<ul class="alerts-list">
|
<section class="chart-section">
|
||||||
@for (alert of alerts(); track alert.alert_id) {
|
<h2>Charge par site</h2>
|
||||||
<li class="alert-item">
|
<ev-card class="chart-card">
|
||||||
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
|
<app-site-load-chart [sites]="s.sites" />
|
||||||
<span class="alert-item__message">{{ alert.message }}</span>
|
</ev-card>
|
||||||
</li>
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="predictions-section">
|
||||||
|
<h2>Prévisions de consommation</h2>
|
||||||
|
@if (predictions().length > 0) {
|
||||||
|
<ev-card class="ev-table-card">
|
||||||
|
<table class="ev-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Prévision</th>
|
||||||
|
<th>Échéance</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (site of predictions(); track site.site_id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ site.site_name }}</td>
|
||||||
|
@if (site.prediction; as prediction) {
|
||||||
|
@if (prediction.status === 'available') {
|
||||||
|
<td class="ev-table__number">
|
||||||
|
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
||||||
|
</td>
|
||||||
|
<td>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</td>
|
||||||
|
} @else {
|
||||||
|
<td>
|
||||||
|
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
|
||||||
|
prediction.status === 'insufficient_data'
|
||||||
|
? 'Historique insuffisant'
|
||||||
|
: 'Erreur'
|
||||||
|
}}</ev-badge>
|
||||||
|
</td>
|
||||||
|
<td class="ev-table__muted">-</td>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<td><ev-badge tone="neutral">Pas encore de prévision</ev-badge></td>
|
||||||
|
<td class="ev-table__muted">-</td>
|
||||||
|
}
|
||||||
|
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ev-card>
|
||||||
|
} @else if (!predictionsError()) {
|
||||||
|
<p class="dashboard__empty">Aucune prévision disponible pour le moment.</p>
|
||||||
}
|
}
|
||||||
</ul>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (predictions().length > 0) {
|
<aside class="dashboard__side" aria-label="Alertes actives">
|
||||||
<section class="predictions-section">
|
<app-alert-feed />
|
||||||
<h2>Prévisions de consommation</h2>
|
</aside>
|
||||||
<ul class="predictions-list">
|
</div>
|
||||||
@for (site of predictions(); track site.site_id) {
|
|
||||||
<li class="prediction-item">
|
|
||||||
<span class="prediction-item__site">{{ site.site_name }}</span>
|
|
||||||
@if (site.prediction; as prediction) {
|
|
||||||
@if (prediction.status === 'available') {
|
|
||||||
<span class="prediction-item__value">
|
|
||||||
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
|
||||||
<span class="prediction-item__target"
|
|
||||||
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
|
|
||||||
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
|
|
||||||
}}</ev-badge>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<ev-badge tone="neutral">Pas encore de prévision</ev-badge>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
|
|
||||||
.dashboard__header {
|
.dashboard__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 2rem;
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__brand {
|
.dashboard__brand {
|
||||||
@@ -20,8 +22,9 @@
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.75rem;
|
font-size: var(--font-size-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +34,69 @@
|
|||||||
|
|
||||||
.dashboard__subtitle {
|
.dashboard__subtitle {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__actions {
|
.dashboard__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
.ev-link {
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__pulse {
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-success);
|
||||||
|
animation: pulse 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(22, 163, 74, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dashboard__pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.1rem;
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-error {
|
.banner-error {
|
||||||
@@ -54,116 +107,124 @@ h2 {
|
|||||||
.overview {
|
.overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 1rem;
|
gap: var(--space-3);
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.kpi {
|
||||||
padding: 1.25rem;
|
position: relative;
|
||||||
gap: 0.35rem;
|
overflow: hidden;
|
||||||
|
padding: var(--space-4);
|
||||||
|
gap: var(--space-1);
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card--gauge {
|
.kpi--gauge {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card--link {
|
.kpi__label {
|
||||||
cursor: pointer;
|
font-size: var(--font-size-xs);
|
||||||
transition: border-color 0.15s ease;
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
.kpi__value {
|
||||||
border-color: var(--color-primary);
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__label {
|
.kpi__hint {
|
||||||
font-size: 0.8rem;
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__value {
|
.kpi__link {
|
||||||
font-size: 1.6rem;
|
margin-top: auto;
|
||||||
font-weight: 700;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 6px;
|
height: 8px;
|
||||||
|
margin: var(--space-1) 0;
|
||||||
background: var(--color-border-light);
|
background: var(--color-border-light);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar__fill {
|
.progress-bar__fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-primary);
|
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-success);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-bar__fill--warning {
|
||||||
|
background: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar__fill--danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__side {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.chart-section {
|
.chart-section {
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alerts-list {
|
.chart-card {
|
||||||
list-style: none;
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
font-size: var(--font-size-sm);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-danger-bg);
|
|
||||||
border: 1px solid var(--color-danger-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item__message {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.predictions-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item__site {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item__value {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item__target {
|
|
||||||
margin-left: 0.35rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dashboard__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__side {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { of, throwError } from 'rxjs';
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
|
import { Router, provideRouter } from '@angular/router';
|
||||||
import { Dashboard } from './dashboard';
|
import { Dashboard } from './dashboard';
|
||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import { AlertsService } from '../../core/services/alerts.service';
|
import { AlertsService } from '../../core/services/alerts.service';
|
||||||
|
import { SitesService } from '../../core/services/sites.service';
|
||||||
import { PredictionsService } from '../../core/services/predictions.service';
|
import { PredictionsService } from '../../core/services/predictions.service';
|
||||||
import {AuthService} from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
import {Router, provideRouter} from '@angular/router';
|
|
||||||
|
|
||||||
vi.mock('chart.js', () => {
|
vi.mock('chart.js', () => {
|
||||||
class ChartMock {
|
class ChartMock {
|
||||||
@@ -18,66 +19,73 @@ vi.mock('chart.js', () => {
|
|||||||
return { Chart: ChartMock, registerables: [] };
|
return { Chart: ChartMock, registerables: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const STATS = { total_sites: 7, sites: [] };
|
||||||
|
|
||||||
function predictionsMock(sites: unknown[] = []) {
|
function predictionsMock(sites: unknown[] = []) {
|
||||||
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
|
return {
|
||||||
|
getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
options: {
|
||||||
|
stats?: Observable<unknown>;
|
||||||
|
predictions?: { getPredictions: ReturnType<typeof vi.fn> };
|
||||||
|
auth?: Record<string, unknown>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const statsMock = { getSummary: vi.fn().mockReturnValue(options.stats ?? of(STATS)) };
|
||||||
|
const predictions = options.predictions ?? predictionsMock();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [Dashboard],
|
||||||
|
providers: [
|
||||||
|
{ provide: StatsService, useValue: statsMock },
|
||||||
|
{ provide: AlertsService, useValue: { getAlerts: vi.fn().mockReturnValue(of([])) } },
|
||||||
|
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of([])) } },
|
||||||
|
{ provide: PredictionsService, useValue: predictions },
|
||||||
|
...(options.auth ? [{ provide: AuthService, useValue: options.auth }] : []),
|
||||||
|
provideRouter([]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return { fixture: TestBed.createComponent(Dashboard), statsMock, predictions };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Dashboard', () => {
|
describe('Dashboard', () => {
|
||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
it('charge les stats, les alertes et les prévisions au démarrage', async () => {
|
it('charge les stats et les prévisions au démarrage', async () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture, statsMock, predictions } = setup({
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
|
predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]),
|
||||||
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictions },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
// laisse le timer(0, ...) se déclencher avant de vérifier
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
expect(statsMock.getSummary).toHaveBeenCalled();
|
||||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
|
||||||
expect(predictions.getPredictions).toHaveBeenCalled();
|
expect(predictions.getPredictions).toHaveBeenCalled();
|
||||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
|
||||||
expect(fixture.componentInstance.predictions().length).toBe(1);
|
expect(fixture.componentInstance.predictions().length).toBe(1);
|
||||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||||
expect(fixture.componentInstance.alertsError()).toBeNull();
|
|
||||||
expect(fixture.componentInstance.predictionsError()).toBeNull();
|
expect(fixture.componentInstance.predictionsError()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('délègue les alertes au widget app-alert-feed', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('app-alert-feed')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const statsMock = {
|
const { fixture, statsMock } = setup({
|
||||||
getSummary: vi
|
stats: throwError(() => new Error('API injoignable')),
|
||||||
.fn()
|
|
||||||
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
|
||||||
.mockReturnValue(of({ total_sites: 7, sites: [] })),
|
|
||||||
};
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
statsMock.getSummary
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||||
|
.mockReturnValue(of(STATS));
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
vi.advanceTimersByTime(1);
|
vi.advanceTimersByTime(1);
|
||||||
@@ -91,45 +99,11 @@ describe('Dashboard', () => {
|
|||||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
|
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.alerts().length).toBe(0);
|
|
||||||
expect(fixture.componentInstance.alertsError()).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
|
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture } = setup({
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
const predictions = {
|
|
||||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
|
||||||
};
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictions },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.componentInstance.predictions().length).toBe(0);
|
expect(fixture.componentInstance.predictions().length).toBe(0);
|
||||||
@@ -138,25 +112,11 @@ describe('Dashboard', () => {
|
|||||||
|
|
||||||
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
|
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture } = setup({
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
const predictions = {
|
|
||||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
|
||||||
};
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictions },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||||
|
|
||||||
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
|
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
|
||||||
@@ -168,109 +128,106 @@ describe('Dashboard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
const authMock = {
|
|
||||||
logout: vi.fn().mockReturnValue(of(undefined)),
|
|
||||||
clearSession: vi.fn(),
|
|
||||||
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
|
||||||
};
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
{ provide: AuthService, useValue: authMock },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const router = TestBed.inject(Router);
|
|
||||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
|
||||||
button.click();
|
|
||||||
|
|
||||||
expect(authMock.logout).toHaveBeenCalled();
|
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
|
||||||
});
|
|
||||||
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
const authMock = {
|
const authMock = {
|
||||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||||
clearSession: vi.fn(),
|
clearSession: vi.fn(),
|
||||||
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
};
|
};
|
||||||
TestBed.configureTestingModule({
|
const { fixture } = setup({ auth: authMock });
|
||||||
imports: [Dashboard],
|
fixture.detectChanges();
|
||||||
providers: [
|
const router = TestBed.inject(Router);
|
||||||
{ provide: StatsService, useValue: statsMock },
|
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
fixture.nativeElement.querySelector('.logout-button').click();
|
||||||
{ provide: AuthService, useValue: authMock },
|
|
||||||
provideRouter([]),
|
expect(authMock.logout).toHaveBeenCalled();
|
||||||
],
|
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
||||||
fixture.detectChanges();
|
const authMock = {
|
||||||
|
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
|
};
|
||||||
|
const { fixture } = setup({ auth: authMock });
|
||||||
|
fixture.detectChanges();
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
|
||||||
const router = TestBed.inject(Router);
|
fixture.nativeElement.querySelector('.logout-button').click();
|
||||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
expect(authMock.clearSession).toHaveBeenCalled();
|
||||||
button.click();
|
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||||
|
});
|
||||||
|
|
||||||
expect(authMock.clearSession).toHaveBeenCalled();
|
it('affiche l’heure du dernier relevé et les indicateurs du parc', () => {
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
vi.useFakeTimers();
|
||||||
});
|
const { fixture } = setup({
|
||||||
|
stats: of({
|
||||||
it('distingue le ton des sévérités high et critical', () => {
|
timestamp: '2026-09-18T09:00:00Z',
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
total_sites: 7,
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
total_consumption_kw: 1234.5,
|
||||||
|
total_capacity_kw: 5000,
|
||||||
TestBed.configureTestingModule({
|
average_load_percent: 24.7,
|
||||||
imports: [Dashboard],
|
sites: [],
|
||||||
providers: [
|
}),
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const texte = fixture.nativeElement.textContent as string;
|
||||||
|
expect(texte).toContain('Actualisé à');
|
||||||
|
expect(texte).toContain('7 sites suivis');
|
||||||
|
expect(texte).toContain('Marge confortable');
|
||||||
|
expect(fixture.nativeElement.querySelector('.progress-bar__fill--success')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('présente les prévisions en tableau avec un lien vers chaque site', () => {
|
||||||
|
const { fixture } = setup({
|
||||||
|
predictions: predictionsMock([
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
prediction: {
|
||||||
|
target_at: '2026-09-18T10:00:00Z',
|
||||||
|
target_metric: 'consumption_kwh',
|
||||||
|
period_minutes: 60,
|
||||||
|
predicted_value: 118.4,
|
||||||
|
status: 'available',
|
||||||
|
failure_reason: null,
|
||||||
|
model_reference: 'lightgbm-v1',
|
||||||
|
created_at: '2026-09-18T09:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ site_id: 'SITE002', site_name: 'Bureau Lille', prediction: null },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const table = fixture.nativeElement.querySelector('table.ev-table');
|
||||||
|
expect(table).not.toBeNull();
|
||||||
|
expect(table.textContent).toContain('118.4 kWh');
|
||||||
|
expect(table.textContent).toContain('Pas encore de prévision');
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE001"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colore la charge moyenne selon les seuils 70 % et 90 %', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
const dashboard = fixture.componentInstance;
|
const dashboard = fixture.componentInstance;
|
||||||
|
|
||||||
expect(dashboard.badgeToneForSeverity('low')).toBe('success');
|
expect(dashboard.loadTone(69.9)).toBe('success');
|
||||||
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
|
expect(dashboard.loadTone(70)).toBe('warning');
|
||||||
expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
|
expect(dashboard.loadTone(89.9)).toBe('warning');
|
||||||
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
|
expect(dashboard.loadTone(90)).toBe('danger');
|
||||||
expect(dashboard.badgeToneForSeverity('high')).not.toBe(
|
expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc');
|
||||||
dashboard.badgeToneForSeverity('critical'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('distingue le ton des statuts de prévision', () => {
|
it('distingue le ton des statuts de prévision', () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture } = setup();
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
const dashboard = fixture.componentInstance;
|
const dashboard = fixture.componentInstance;
|
||||||
|
|
||||||
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
|
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router';
|
|||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
||||||
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
||||||
import { AlertsService } from '../../core/services/alerts.service';
|
import { AlertFeed } from '../../shared/components/alert-feed/alert-feed';
|
||||||
import { PredictionsService } from '../../core/services/predictions.service';
|
import { PredictionsService } from '../../core/services/predictions.service';
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
import { StatsSummary } from '../../shared/models/stats.model';
|
import { StatsSummary } from '../../shared/models/stats.model';
|
||||||
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
|
||||||
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
|
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
|
||||||
import { Card } from '../../shared/components/ui/card/card';
|
import { Card } from '../../shared/components/ui/card/card';
|
||||||
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||||
@@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000;
|
|||||||
const UNAVAILABLE_MESSAGE =
|
const UNAVAILABLE_MESSAGE =
|
||||||
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
||||||
|
|
||||||
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
// `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` :
|
||||||
low: 'success',
|
// mieux vaut un ton défini que `undefined` le jour où ce statut apparaît.
|
||||||
medium: 'warning',
|
|
||||||
high: 'danger',
|
|
||||||
critical: 'critical',
|
|
||||||
};
|
|
||||||
|
|
||||||
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
|
|
||||||
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
|
|
||||||
// tomber sur `undefined` si ce statut apparaît un jour.
|
|
||||||
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||||
available: 'success',
|
available: 'success',
|
||||||
insufficient_data: 'warning',
|
insufficient_data: 'warning',
|
||||||
error: 'danger',
|
error: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SEUIL_CHARGE_SOUTENUE = 70;
|
||||||
|
const SEUIL_CHARGE_CRITIQUE = 90;
|
||||||
|
|
||||||
|
export type LoadTone = 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
|||||||
RouterLink,
|
RouterLink,
|
||||||
ConsumptionGauge,
|
ConsumptionGauge,
|
||||||
SiteLoadChart,
|
SiteLoadChart,
|
||||||
|
AlertFeed,
|
||||||
Card,
|
Card,
|
||||||
EvAlert,
|
EvAlert,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
|||||||
})
|
})
|
||||||
export class Dashboard implements OnInit {
|
export class Dashboard implements OnInit {
|
||||||
private statsService = inject(StatsService);
|
private statsService = inject(StatsService);
|
||||||
private alertsService = inject(AlertsService);
|
|
||||||
public auth = inject(AuthService);
|
public auth = inject(AuthService);
|
||||||
private predictionsService = inject(PredictionsService);
|
private predictionsService = inject(PredictionsService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
stats = signal<StatsSummary | null>(null);
|
stats = signal<StatsSummary | null>(null);
|
||||||
alerts = signal<Alert[]>([]);
|
|
||||||
predictions = signal<SitePredictionSummary[]>([]);
|
predictions = signal<SitePredictionSummary[]>([]);
|
||||||
|
|
||||||
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
|
// Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en
|
||||||
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
|
// silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur.
|
||||||
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
|
|
||||||
statsError = signal<string | null>(null);
|
statsError = signal<string | null>(null);
|
||||||
alertsError = signal<string | null>(null);
|
|
||||||
predictionsError = signal<string | null>(null);
|
predictionsError = signal<string | null>(null);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.alertsService
|
|
||||||
.getAlerts()
|
|
||||||
.pipe(catchError(() => this.reportUnavailable(this.alertsError)))
|
|
||||||
.subscribe((alerts) => {
|
|
||||||
this.alertsError.set(null);
|
|
||||||
this.alerts.set(alerts);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
|
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
|
||||||
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
|
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
|
||||||
this.predictionsService
|
this.predictionsService
|
||||||
@@ -99,7 +84,9 @@ export class Dashboard implements OnInit {
|
|||||||
timer(0, REFRESH_INTERVAL_MS)
|
timer(0, REFRESH_INTERVAL_MS)
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(() =>
|
switchMap(() =>
|
||||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
this.statsService
|
||||||
|
.getSummary()
|
||||||
|
.pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
||||||
),
|
),
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
)
|
)
|
||||||
@@ -109,14 +96,28 @@ export class Dashboard implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
|
|
||||||
return TON_PAR_SEVERITE[severity];
|
|
||||||
}
|
|
||||||
|
|
||||||
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
|
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
|
||||||
return TON_PAR_STATUT_PREDICTION[status];
|
return TON_PAR_STATUT_PREDICTION[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadTone(percent: number): LoadTone {
|
||||||
|
if (percent >= SEUIL_CHARGE_CRITIQUE) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
return percent >= SEUIL_CHARGE_SOUTENUE ? 'warning' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHint(percent: number): string {
|
||||||
|
switch (this.loadTone(percent)) {
|
||||||
|
case 'danger':
|
||||||
|
return 'Proche de la capacité du parc';
|
||||||
|
case 'warning':
|
||||||
|
return 'Charge soutenue';
|
||||||
|
default:
|
||||||
|
return 'Marge confortable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onLogout(): void {
|
onLogout(): void {
|
||||||
this.auth.logout().subscribe({
|
this.auth.logout().subscribe({
|
||||||
next: () => this.router.navigate(['/login']),
|
next: () => this.router.navigate(['/login']),
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
}
|
}
|
||||||
|
|
||||||
<ev-card class="table-card">
|
<ev-card class="ev-table-card">
|
||||||
<table class="sites-table">
|
<table class="ev-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
@@ -36,7 +36,9 @@
|
|||||||
<td>{{ site.site_type }}</td>
|
<td>{{ site.site_type }}</td>
|
||||||
<td>{{ site.location || '-' }}</td>
|
<td>{{ site.location || '-' }}</td>
|
||||||
<td>{{ site.capacity_kw ?? '-' }}</td>
|
<td>{{ site.capacity_kw ?? '-' }}</td>
|
||||||
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
|
<td>
|
||||||
|
<ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge>
|
||||||
|
</td>
|
||||||
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,32 +32,3 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-card {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sites-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 0.85rem 1.25rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<section class="alert-feed">
|
||||||
|
<header class="alert-feed__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="alert-feed__title">Alertes actives</h2>
|
||||||
|
@if (!loading() || alerts().length > 0) {
|
||||||
|
<p class="alert-feed__count">
|
||||||
|
{{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="alert-feed__filters">
|
||||||
|
@if (!siteId()) {
|
||||||
|
<label class="alert-feed__filter">
|
||||||
|
<span class="form-label">Site</span>
|
||||||
|
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||||
|
<option value="">Tous les sites</option>
|
||||||
|
@for (site of sites(); track site.site_id) {
|
||||||
|
<option [value]="site.site_id">{{ site.site_name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="alert-feed__filter">
|
||||||
|
<span class="form-label">Sévérité</span>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
data-testid="severity-filter"
|
||||||
|
(change)="onSeverityChange($event)"
|
||||||
|
>
|
||||||
|
<option value="">Toutes</option>
|
||||||
|
@for (severite of severites; track severite) {
|
||||||
|
<option [value]="severite">{{ severityLabel(severite) }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="alert-feed__banner">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading() && alerts().length === 0 && !error()) {
|
||||||
|
<p class="alert-feed__state" aria-live="polite">Chargement des alertes…</p>
|
||||||
|
} @else if (alerts().length === 0 && !error()) {
|
||||||
|
<ev-alert severity="success" class="alert-feed__banner"
|
||||||
|
>Aucune alerte pour ces critères.</ev-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ul class="alert-feed__list" [attr.aria-busy]="loading()">
|
||||||
|
@for (alert of visibleAlerts(); track alert.alert_id) {
|
||||||
|
<li class="alert-feed__item" [class]="'alert-feed__item--' + alert.severity">
|
||||||
|
<span class="alert-feed__icon">
|
||||||
|
<ev-icon [name]="alert.type" [label]="typeLabel(alert.type)" />
|
||||||
|
</span>
|
||||||
|
<div class="alert-feed__body">
|
||||||
|
<div class="alert-feed__meta">
|
||||||
|
<ev-badge [tone]="toneFor(alert.severity)">{{
|
||||||
|
severityLabel(alert.severity)
|
||||||
|
}}</ev-badge>
|
||||||
|
<span class="alert-feed__type">{{ typeLabel(alert.type) }}</span>
|
||||||
|
<span class="alert-feed__site">{{ siteName(alert.site_id) }}</span>
|
||||||
|
<time class="alert-feed__time" [attr.datetime]="alert.timestamp">
|
||||||
|
{{ alert.timestamp | date: 'dd/MM/yyyy HH:mm' }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p class="alert-feed__message">{{ alert.message }}</p>
|
||||||
|
@if (alert.value !== null) {
|
||||||
|
<p class="alert-feed__values">
|
||||||
|
<strong>{{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }}</strong>
|
||||||
|
@if (alert.threshold !== null) {
|
||||||
|
<span
|
||||||
|
>seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (hiddenCount() > 0) {
|
||||||
|
<ev-button
|
||||||
|
variant="secondary"
|
||||||
|
[fullWidth]="false"
|
||||||
|
class="alert-feed__more"
|
||||||
|
data-testid="show-more"
|
||||||
|
(click)="showMore()"
|
||||||
|
>
|
||||||
|
Afficher plus ({{ hiddenCount() }} restantes)
|
||||||
|
</ev-button>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__count {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 10rem;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__state {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-left: 4px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item--medium {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
color: var(--color-warning-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item--high {
|
||||||
|
border-left-color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item--critical {
|
||||||
|
border-left-color: var(--color-critical);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
color: var(--color-critical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__values {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__more {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { AlertFeed } from './alert-feed';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
|
import { Alert } from '../../models/alert.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
|
||||||
|
const SITES: Site[] = [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function alerte(surcharges: Partial<Alert> = {}): Alert {
|
||||||
|
return {
|
||||||
|
alert_id: 1,
|
||||||
|
site_id: 'SITE001',
|
||||||
|
timestamp: '2026-09-15T11:12:00Z',
|
||||||
|
type: 'spike',
|
||||||
|
severity: 'critical',
|
||||||
|
message: 'Variation brutale entre deux lectures consécutives',
|
||||||
|
value: 812.5,
|
||||||
|
threshold: 400,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
|
||||||
|
sitesMock: { getSites: ReturnType<typeof vi.fn> } = {
|
||||||
|
getSites: vi.fn().mockReturnValue(of(SITES)),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [AlertFeed],
|
||||||
|
providers: [
|
||||||
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
{ provide: SitesService, useValue: sitesMock },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return TestBed.createComponent(AlertFeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function premierChargement(fixture: ComponentFixture<AlertFeed>) {
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function texte(fixture: ComponentFixture<AlertFeed>): string {
|
||||||
|
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function choisir(fixture: ComponentFixture<AlertFeed>, testId: string, value: string) {
|
||||||
|
const select = fixture.nativeElement.querySelector(
|
||||||
|
`[data-testid="${testId}"]`,
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
select.value = value;
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AlertFeed', () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('charge les alertes au démarrage sans filtre et les affiche avec leur contexte', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getAlerts.mock.calls[0][0]).toEqual({});
|
||||||
|
const contenu = texte(fixture);
|
||||||
|
expect(contenu).toContain('Usine Nantes');
|
||||||
|
expect(contenu).toContain('Critique');
|
||||||
|
expect(contenu).toContain('Pic de consommation');
|
||||||
|
expect(contenu).toContain('15/09/2026');
|
||||||
|
expect(contenu).toContain('812.5 kW');
|
||||||
|
expect(contenu).toContain('seuil 400 kW');
|
||||||
|
expect(fixture.nativeElement.querySelector('ev-icon svg')).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.alert-feed__item--critical')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annonce le chargement avant la première réponse', () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.loading()).toBe(true);
|
||||||
|
expect(texte(fixture)).toContain('Chargement des alertes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence d'alerte pour les critères choisis", () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('Aucune alerte pour ces critères.');
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relance la requête avec la sévérité choisie et revient à la première page', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
premierChargement(fixture);
|
||||||
|
fixture.componentInstance.showMore();
|
||||||
|
|
||||||
|
choisir(fixture, 'severity-filter', 'high');
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getAlerts.mock.calls[1][0]).toEqual({ severity: 'high' });
|
||||||
|
expect(fixture.componentInstance.visibleCount()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relance la requête avec le site choisi dans le filtre', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
choisir(fixture, 'site-filter', 'SITE001');
|
||||||
|
|
||||||
|
expect(getAlerts.mock.calls[1][0]).toEqual({ site_id: 'SITE001' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('masque le filtre site et force site_id quand le parent fixe le site', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
fixture.componentRef.setInput('siteId', 'SITE001');
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts.mock.calls[0][0]).toEqual({ site_id: 'SITE001' });
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="site-filter"]')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="severity-filter"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'indisponibilité en gardant la liste, puis repart au rafraîchissement suivant", () => {
|
||||||
|
const getAlerts = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(of([alerte()]))
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||||
|
.mockReturnValue(of([alerte(), alerte({ alert_id: 2 })]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||||
|
expect(texte(fixture)).toContain('Alertes indisponibles');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
expect(fixture.componentInstance.alerts().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pagine côté client par dix et dévoile le reste à la demande', () => {
|
||||||
|
const alertes = Array.from({ length: 25 }, (_, i) => alerte({ alert_id: i + 1 }));
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(alertes)) });
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(10);
|
||||||
|
expect(texte(fixture)).toContain('Afficher plus (15 restantes)');
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(20);
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(25);
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="show-more"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replie sur l'identifiant quand le site est inconnu ou que la liste des sites échoue", () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(of([alerte({ site_id: 'SITE999' })])) },
|
||||||
|
{ getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
|
);
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('SITE999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'affiche pas de mesure pour une alerte sans valeur", () => {
|
||||||
|
const fixture = setup({
|
||||||
|
getAlerts: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(
|
||||||
|
of([alerte({ type: 'outage', value: null, threshold: null, metric: null })]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('.alert-feed__values')).toBeNull();
|
||||||
|
expect(texte(fixture)).toContain('Coupure');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { catchError, EMPTY, Observable, of, switchMap, tap, timer } from 'rxjs';
|
||||||
|
import { AlertFilters, AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
|
import { Alert, AlertMetric, AlertSeverity, AlertType } from '../../models/alert.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
SEVERITES,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
UNITE_PAR_METRIQUE,
|
||||||
|
} from '../../models/alert-presentation';
|
||||||
|
import { Badge, BadgeTone } from '../ui/badge/badge';
|
||||||
|
import { Button } from '../ui/button/button';
|
||||||
|
import { Alert as EvAlert } from '../ui/alert/alert';
|
||||||
|
import { Icon } from '../ui/icon/icon';
|
||||||
|
|
||||||
|
// Le DAG de détection tourne toutes les heures : une minute suffit largement pour suivre le flux.
|
||||||
|
const REFRESH_INTERVAL_MS = 60_000;
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
const UNAVAILABLE_MESSAGE = 'Alertes indisponibles, la liste affichée date du dernier chargement.';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-alert-feed',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DatePipe, DecimalPipe, Badge, Button, EvAlert, Icon],
|
||||||
|
templateUrl: './alert-feed.html',
|
||||||
|
styleUrl: './alert-feed.scss',
|
||||||
|
})
|
||||||
|
export class AlertFeed {
|
||||||
|
private alertsService = inject(AlertsService);
|
||||||
|
private sitesService = inject(SitesService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
siteId = input<string | null>(null);
|
||||||
|
|
||||||
|
readonly severites = SEVERITES;
|
||||||
|
severity = signal<AlertSeverity | null>(null);
|
||||||
|
siteFilter = signal<string | null>(null);
|
||||||
|
|
||||||
|
alerts = signal<Alert[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
visibleCount = signal(PAGE_SIZE);
|
||||||
|
|
||||||
|
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||||
|
initialValue: [] as Site[],
|
||||||
|
});
|
||||||
|
|
||||||
|
private filters = computed<AlertFilters>(() => ({
|
||||||
|
site_id: this.siteId() ?? this.siteFilter() ?? undefined,
|
||||||
|
severity: this.severity() ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
private siteNameById = computed(
|
||||||
|
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
|
||||||
|
);
|
||||||
|
|
||||||
|
visibleAlerts = computed(() => this.alerts().slice(0, this.visibleCount()));
|
||||||
|
hiddenCount = computed(() => Math.max(this.alerts().length - this.visibleCount(), 0));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
toObservable(this.filters)
|
||||||
|
.pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.visibleCount.set(PAGE_SIZE);
|
||||||
|
}),
|
||||||
|
// Piège : catchError sur l'observable interne ; sur le flux externe il terminerait le
|
||||||
|
// timer et le rafraîchissement ne repartirait jamais.
|
||||||
|
switchMap((filters) =>
|
||||||
|
timer(0, REFRESH_INTERVAL_MS).pipe(
|
||||||
|
switchMap(() =>
|
||||||
|
this.alertsService
|
||||||
|
.getAlerts(filters)
|
||||||
|
.pipe(catchError(() => this.reportUnavailable())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((alerts) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(null);
|
||||||
|
this.alerts.set(alerts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSiteChange(event: Event): void {
|
||||||
|
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeverityChange(event: Event): void {
|
||||||
|
const value = (event.target as HTMLSelectElement).value;
|
||||||
|
this.severity.set(value ? (value as AlertSeverity) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
showMore(): void {
|
||||||
|
this.visibleCount.update((count) => count + PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
toneFor(severity: AlertSeverity): BadgeTone {
|
||||||
|
return TON_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
severityLabel(severity: AlertSeverity): string {
|
||||||
|
return LIBELLE_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
typeLabel(type: AlertType): string {
|
||||||
|
return LIBELLE_PAR_TYPE[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
siteName(siteId: string): string {
|
||||||
|
return this.siteNameById().get(siteId) ?? siteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
unitFor(metric: AlertMetric | null): string {
|
||||||
|
return metric ? UNITE_PAR_METRIQUE[metric] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
focusable="false"
|
||||||
|
[attr.role]="label() ? 'img' : null"
|
||||||
|
[attr.aria-label]="label()"
|
||||||
|
[attr.aria-hidden]="label() ? null : 'true'"
|
||||||
|
>
|
||||||
|
@switch (name()) {
|
||||||
|
@case ('spike') {
|
||||||
|
<polyline points="3 17 8 12 11 15 21 5" />
|
||||||
|
<polyline points="15 5 21 5 21 11" />
|
||||||
|
}
|
||||||
|
@case ('threshold') {
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" stroke-dasharray="3 3" />
|
||||||
|
<polyline points="4 19 9 13 13 15 20 6" />
|
||||||
|
}
|
||||||
|
@case ('anomaly') {
|
||||||
|
<polyline points="3 12 7 12 9.5 6 12.5 18 15 12 21 12" />
|
||||||
|
}
|
||||||
|
@case ('outage') {
|
||||||
|
<path d="M7.5 6.5a7 7 0 1 0 9 0" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="11" />
|
||||||
|
}
|
||||||
|
@case ('sensor') {
|
||||||
|
<circle cx="12" cy="13" r="2" />
|
||||||
|
<path d="M8.5 9.5a5 5 0 0 0 0 7" />
|
||||||
|
<path d="M15.5 9.5a5 5 0 0 1 0 7" />
|
||||||
|
<path d="M5.5 6.5a9 9 0 0 0 0 13" />
|
||||||
|
<path d="M18.5 6.5a9 9 0 0 1 0 13" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { Icon, IconName } from './icon';
|
||||||
|
|
||||||
|
const NOMS: IconName[] = ['spike', 'threshold', 'anomaly', 'outage', 'sensor'];
|
||||||
|
|
||||||
|
function rendre(name: IconName, label: string | null = null) {
|
||||||
|
const fixture = TestBed.createComponent(Icon);
|
||||||
|
fixture.componentRef.setInput('name', name);
|
||||||
|
fixture.componentRef.setInput('label', label);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Icon', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({ imports: [Icon] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dessine un tracé distinct pour chacun des cinq types', () => {
|
||||||
|
const traces = NOMS.map((name) => rendre(name).querySelector('svg')?.innerHTML.trim());
|
||||||
|
|
||||||
|
for (const trace of traces) {
|
||||||
|
expect(trace).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(new Set(traces).size).toBe(NOMS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reste décoratif sans libellé', () => {
|
||||||
|
const svg = rendre('spike').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(svg?.hasAttribute('role')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expose un rôle image et un libellé accessible quand on lui en donne un', () => {
|
||||||
|
const svg = rendre('outage', 'Coupure').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('role')).toBe('img');
|
||||||
|
expect(svg?.getAttribute('aria-label')).toBe('Coupure');
|
||||||
|
expect(svg?.hasAttribute('aria-hidden')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hérite de la couleur du parent via currentColor', () => {
|
||||||
|
const svg = rendre('sensor').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('stroke')).toBe('currentColor');
|
||||||
|
expect(svg?.getAttribute('fill')).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
export type IconName = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ev-icon',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './icon.html',
|
||||||
|
styleUrl: './icon.scss',
|
||||||
|
})
|
||||||
|
export class Icon {
|
||||||
|
name = input.required<IconName>();
|
||||||
|
label = input<string | null>(null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
SEVERITES,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
TYPES_ALERTE,
|
||||||
|
UNITE_PAR_METRIQUE,
|
||||||
|
} from './alert-presentation';
|
||||||
|
|
||||||
|
describe('alert-presentation', () => {
|
||||||
|
it('distingue le ton des sévérités high et critical', () => {
|
||||||
|
expect(TON_PAR_SEVERITE.high).toBe('danger');
|
||||||
|
expect(TON_PAR_SEVERITE.critical).toBe('critical');
|
||||||
|
expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'affiche pas une alerte faible avec le ton de succès", () => {
|
||||||
|
expect(TON_PAR_SEVERITE.low).toBe('neutral');
|
||||||
|
expect(TON_PAR_SEVERITE.medium).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('donne un libellé français à chaque sévérité et à chaque type', () => {
|
||||||
|
for (const severite of SEVERITES) {
|
||||||
|
expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy();
|
||||||
|
}
|
||||||
|
for (const type of TYPES_ALERTE) {
|
||||||
|
expect(LIBELLE_PAR_TYPE[type]).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(SEVERITES.length).toBe(4);
|
||||||
|
expect(TYPES_ALERTE.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associe une unité à chaque métrique du contrat', () => {
|
||||||
|
expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW');
|
||||||
|
expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { BadgeTone } from '../components/ui/badge/badge';
|
||||||
|
import { AlertMetric, AlertSeverity, AlertType } from './alert.model';
|
||||||
|
|
||||||
|
// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se
|
||||||
|
// lisait comme « tout va bien » à côté des rouges.
|
||||||
|
export const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
||||||
|
low: 'neutral',
|
||||||
|
medium: 'warning',
|
||||||
|
high: 'danger',
|
||||||
|
critical: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLE_PAR_SEVERITE: Record<AlertSeverity, string> = {
|
||||||
|
low: 'Faible',
|
||||||
|
medium: 'Moyenne',
|
||||||
|
high: 'Élevée',
|
||||||
|
critical: 'Critique',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLE_PAR_TYPE: Record<AlertType, string> = {
|
||||||
|
spike: 'Pic de consommation',
|
||||||
|
threshold: 'Seuil dépassé',
|
||||||
|
anomaly: 'Anomalie',
|
||||||
|
outage: 'Coupure',
|
||||||
|
sensor: 'Capteur',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UNITE_PAR_METRIQUE: Record<AlertMetric, string> = {
|
||||||
|
consumption_kw: 'kW',
|
||||||
|
consumption_kwh: 'kWh',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical'];
|
||||||
|
|
||||||
|
export const TYPES_ALERTE: readonly AlertType[] = [
|
||||||
|
'spike',
|
||||||
|
'threshold',
|
||||||
|
'anomaly',
|
||||||
|
'outage',
|
||||||
|
'sensor',
|
||||||
|
];
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||||
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||||
|
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
|
||||||
|
|
||||||
export interface Alert {
|
export interface Alert {
|
||||||
alert_id: string;
|
alert_id: number;
|
||||||
timestamp: string;
|
|
||||||
site_id: string;
|
site_id: string;
|
||||||
severity: AlertSeverity;
|
timestamp: string;
|
||||||
type: AlertType;
|
type: AlertType;
|
||||||
|
severity: AlertSeverity;
|
||||||
message: string;
|
message: string;
|
||||||
value: number;
|
value: number | null;
|
||||||
threshold: number;
|
threshold: number | null;
|
||||||
|
metric: AlertMetric | null;
|
||||||
|
prediction_id: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@use 'styles/forms';
|
@use 'styles/forms';
|
||||||
@use 'styles/auth-page';
|
@use 'styles/auth-page';
|
||||||
@use 'styles/links';
|
@use 'styles/links';
|
||||||
|
@use 'styles/tables';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -29,3 +29,18 @@
|
|||||||
color: var(--color-disabled);
|
color: var(--color-disabled);
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur
|
||||||
|
// reprend en dur la valeur de --color-text-muted.
|
||||||
|
.form-select {
|
||||||
|
@extend .form-input;
|
||||||
|
padding-right: 2.25rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.6rem center;
|
||||||
|
background-size: 1rem;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Piège : `ev-card` pose son padding sur `:host`, compilé en `[_nghost-…]` (même spécificité
|
||||||
|
// qu'une classe) et injecté après la feuille globale ; il faut le sélecteur d'élément pour gagner.
|
||||||
|
ev-card.ev-table-card {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover td {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-table__number {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-table__muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
@@ -29,10 +29,17 @@
|
|||||||
|
|
||||||
// Typo, rayons, ombre
|
// Typo, rayons, ombre
|
||||||
--font-family: 'Segoe UI', system-ui, sans-serif;
|
--font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-size-xs: 0.75rem;
|
||||||
|
--font-size-sm: 0.85rem;
|
||||||
|
--font-size-md: 1rem;
|
||||||
|
--font-size-lg: 1.25rem;
|
||||||
|
--font-size-xl: 1.75rem;
|
||||||
|
--font-size-2xl: 2.25rem;
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--radius-md: 12px;
|
--radius-md: 12px;
|
||||||
--radius-pill: 999px;
|
--radius-pill: 999px;
|
||||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-card-hover: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
// Espacements
|
// Espacements
|
||||||
--space-1: 0.35rem;
|
--space-1: 0.35rem;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
|
|||||||
@@ -4,39 +4,47 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
|
|||||||
|
|
||||||
## État actuel
|
## État actuel
|
||||||
|
|
||||||
Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée
|
Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la
|
||||||
par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API.
|
supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle.
|
||||||
|
|
||||||
Ce qui est en place :
|
Ce qui est en place :
|
||||||
|
|
||||||
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
|
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
|
||||||
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
||||||
`provideHttpClient(withInterceptors([mockApiInterceptor]))`.
|
`provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`.
|
||||||
- Une route `/dashboard` en composant différé, et une redirection depuis la racine.
|
- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`,
|
||||||
- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et
|
`/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine.
|
||||||
`AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification
|
- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses
|
||||||
(jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`,
|
filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`,
|
||||||
`features/dashboard` la page principale, `shared/components` la jauge de consommation et le
|
`SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur
|
||||||
graphique de charge par site, tous deux construits sur Chart.js.
|
d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`.
|
||||||
|
- `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et
|
||||||
|
les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et
|
||||||
|
sévérité, rafraîchi toutes les 60 s, première vue de l'application avec des états chargement /
|
||||||
|
vide / indisponible) et, dans `shared/models`, des types alignés sur les schémas Pydantic du
|
||||||
|
backend, plus les tables de présentation partagées (`alert-presentation.ts` : ton, libellé et
|
||||||
|
unité par sévérité, type et métrique).
|
||||||
- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation,
|
- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation,
|
||||||
changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail :
|
changement de mot de passe, garde de route sur toute la zone authentifiée. Détail :
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||||
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
|
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
|
||||||
`ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit
|
`ev-badge`, `ev-brand`, `ev-icon`, tokens CSS dans `styles/_tokens.scss`, classes globales de
|
||||||
réutiliser plutôt que redéfinir ses propres styles. Détail :
|
formulaire, de navigation et de tableau) que toute nouvelle page doit réutiliser plutôt que
|
||||||
|
redéfinir ses propres styles. Détail :
|
||||||
[32-design-systeme-frontend.md](32-design-systeme-frontend.md).
|
[32-design-systeme-frontend.md](32-design-systeme-frontend.md).
|
||||||
- L'état vit dans des signaux, sans bibliothèque dédiée.
|
- L'état vit dans des signaux, sans bibliothèque dédiée.
|
||||||
|
- TypeScript en `"strict": true` ; `strictTemplates` n'est pas encore activé.
|
||||||
- Vitest via le builder `@angular/build:unit-test`, couverture activée.
|
- Vitest via le builder `@angular/build:unit-test`, couverture activée.
|
||||||
- Prettier configuré, parser `angular` pour les gabarits HTML.
|
- Prettier configuré, parser `angular` pour les gabarits HTML.
|
||||||
|
|
||||||
Ce qui n'existe pas encore :
|
Ce qui n'existe pas encore :
|
||||||
|
|
||||||
- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
|
- **Le mode fixtures est inactif.** `useMockFixtures` vaut `false` dans `environment.ts` comme dans
|
||||||
sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien
|
`environment.development.ts` : `mockApiInterceptor` ne sert `/stats/summary` et `/alerts` que
|
||||||
ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement.
|
dans son propre spec. En développement, toutes les pages exigent un backend joignable et un jeton
|
||||||
`GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette
|
valide.
|
||||||
PR (voir plus bas).
|
- Un état de chargement généralisé : seul `app-alert-feed` en a un, les autres pages restent vides
|
||||||
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
|
tant que la première réponse n'est pas arrivée.
|
||||||
- Aucun lint : ESLint n'est pas installé.
|
- Aucun lint : ESLint n'est pas installé.
|
||||||
|
|
||||||
## Arborescence
|
## Arborescence
|
||||||
@@ -87,11 +95,11 @@ sequenceDiagram
|
|||||||
```
|
```
|
||||||
|
|
||||||
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
|
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
|
||||||
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
|
`environment.useMockFixtures` est vrai. Le drapeau vaut `false` dans les deux fichiers
|
||||||
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
|
d'environnement : en pratique toutes les requêtes suivent le chemin réel et l'intercepteur n'est
|
||||||
`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il
|
exercé que par son spec. `/predictions` et `/auth/*` ne sont de toute façon jamais mockés. En
|
||||||
suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide
|
développement, un jeton valide et un backend joignable sont donc nécessaires pour que le tableau de
|
||||||
et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche.
|
bord s'affiche.
|
||||||
|
|
||||||
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
|
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
|
||||||
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
||||||
|
|||||||
@@ -20,16 +20,18 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
|
|||||||
| `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) |
|
| `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) |
|
||||||
| `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
|
| `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
|
||||||
| `--font-family` | Police unique de l'application |
|
| `--font-family` | Police unique de l'application |
|
||||||
|
| `--font-size-xs` à `--font-size-2xl` | Échelle typographique (0.75rem à 2.25rem) : libellés, corps, titres, grands nombres |
|
||||||
| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) |
|
| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) |
|
||||||
| `--shadow-card` | Ombre portée des cartes |
|
| `--shadow-card`, `--shadow-card-hover` | Ombre portée des cartes, au repos et au survol |
|
||||||
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
|
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
|
||||||
|
|
||||||
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
|
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
|
||||||
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
||||||
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
|
s'appliquent directement à des `<label>`/`<input>`/`<select>` natifs, liés par `formControlName` ou
|
||||||
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
|
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la
|
||||||
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
|
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un
|
||||||
`<ev-alert severity="danger">`, pas une classe dédiée.
|
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de
|
||||||
|
formulaire, elles, s'affichent via `<ev-alert severity="danger">`, pas une classe dédiée.
|
||||||
|
|
||||||
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
|
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
|
||||||
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
|
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
|
||||||
@@ -83,6 +85,23 @@ dans le tableau `imports` du composant qui l'utilise.
|
|||||||
```html
|
```html
|
||||||
<ev-brand />
|
<ev-brand />
|
||||||
```
|
```
|
||||||
|
- **`<ev-icon>`** (`icon/`) : `name` (obligatoire : `spike` / `threshold` / `anomaly` / `outage` /
|
||||||
|
`sensor`, les types d'alerte du contrat) et `label` (facultatif). SVG inline en trait sur
|
||||||
|
`currentColor`, dimensionné par `font-size` comme `ev-brand`. Sans `label` l'icône est décorative
|
||||||
|
(`aria-hidden`) ; avec, elle porte `role="img"` et `aria-label`. Pas de bibliothèque d'icônes : la
|
||||||
|
CSP du reverse proxy (`script-src 'self'`) interdit les scripts tiers, pas le SVG inline.
|
||||||
|
```html
|
||||||
|
<ev-icon name="spike" label="Pic de consommation" />
|
||||||
|
```
|
||||||
|
- **`<app-alert-feed>`** (`shared/components/alert-feed/`) : widget métier plutôt qu'atome du kit,
|
||||||
|
mais réutilisable tel quel. Input `siteId` (facultatif : fige le site et masque son filtre). Il
|
||||||
|
porte ses filtres (`.form-select`), ses états et son rafraîchissement ; le parent ne fait que le
|
||||||
|
poser dans une section.
|
||||||
|
|
||||||
|
Les classes de tableau partagées sont dans `apps/frontend/src/styles/_tables.scss`, importées
|
||||||
|
globalement : `.ev-table-card` sur la `<ev-card>` qui enveloppe un tableau (padding nul),
|
||||||
|
`.ev-table` sur le `<table>`, `.ev-table__number` pour une cellule numérique en chiffres
|
||||||
|
tabulaires, `.ev-table__muted` pour une cellule sans valeur.
|
||||||
|
|
||||||
## Logo
|
## Logo
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ flowchart TB
|
|||||||
subgraph sq["SonarQube · sonarqube.yml"]
|
subgraph sq["SonarQube · sonarqube.yml"]
|
||||||
sb1["build-front / test-front"]
|
sb1["build-front / test-front"]
|
||||||
sb2["build-back / test-back"]
|
sb2["build-back / test-back"]
|
||||||
|
sb3["test-ml"]
|
||||||
sscan["sonarqube<br/>quality gate SonarCloud"]
|
sscan["sonarqube<br/>quality gate SonarCloud"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -132,10 +133,18 @@ partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
|
|||||||
|
|
||||||
## SonarCloud, et l'incident qui a immobilisé trois PR
|
## SonarCloud, et l'incident qui a immobilisé trois PR
|
||||||
|
|
||||||
Le workflow `sonarqube.yml` exécute quatre jobs de préparation (`build-front`, `test-front`,
|
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
|
||||||
`build-back`, `test-back`) qui produisent chacun un rapport de couverture en artefact, puis un
|
`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en
|
||||||
cinquième job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8` avec le secret
|
artefact, puis un dernier job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8`
|
||||||
`SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
|
avec le secret `SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
|
||||||
|
|
||||||
|
Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers
|
||||||
|
ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas
|
||||||
|
Sonar). `ml/` publie `ml/coverage.xml` (`pytest-cov`, même mécanisme que le backend, sans seuil
|
||||||
|
propre : la gate porte sur le code neuf). `etl/airflow` est exclu de la **couverture**
|
||||||
|
(`sonar.coverage.exclusions`) : ses tests ne font que charger les DAGs, ils ne mesurent rien.
|
||||||
|
Piège : tout nouveau dossier de tests doit être déclaré dans `sonar.tests`, faute de quoi il est
|
||||||
|
compté comme code de production non couvert (cf. l'incident ci-dessous).
|
||||||
|
|
||||||
**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont
|
**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont
|
||||||
restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que
|
restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@ prevision (utile plus tard pour comparer prevision et realise, surveillance de d
|
|||||||
uv run ruff check . # lint
|
uv run ruff check . # lint
|
||||||
uv run ruff format . # format
|
uv run ruff format . # format
|
||||||
uv run mypy enervision_ml tests # typage strict
|
uv run mypy enervision_ml tests # typage strict
|
||||||
uv run pytest # tests
|
uv run pytest # tests + couverture (ml/coverage.xml avec --cov-report=xml, lu par Sonar)
|
||||||
```
|
```
|
||||||
|
|
||||||
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
|
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
|
||||||
|
|||||||
+11
-1
@@ -17,6 +17,7 @@ dev = [
|
|||||||
"ruff>=0.16.7",
|
"ruff>=0.16.7",
|
||||||
"mypy>=2.3.1",
|
"mypy>=2.3.1",
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
|
"pytest-cov>=7.1.0",
|
||||||
"pandas-stubs>=3.0.5.260914",
|
"pandas-stubs>=3.0.5.260914",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -75,5 +76,14 @@ ignore_missing_imports = true
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-q --strict-markers -m 'not integration'"
|
addopts = "-q --strict-markers -m 'not integration' --cov=enervision_ml --cov-report=term-missing"
|
||||||
markers = ["integration: requiert une base PostgreSQL joignable"]
|
markers = ["integration: requiert une base PostgreSQL joignable"]
|
||||||
|
|
||||||
|
# Rapport lu par SonarCloud (`ml/coverage.xml`, cf. sonar-project.properties), meme mecanisme que
|
||||||
|
# apps/backend. Pas de seuil ici : celui de la quality gate porte sur le code nouveau.
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["enervision_ml"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
show_missing = true
|
||||||
|
|||||||
Generated
+55
@@ -388,6 +388,45 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.16.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "50.0.1"
|
version = "50.0.1"
|
||||||
@@ -494,6 +533,7 @@ dev = [
|
|||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "pandas-stubs" },
|
{ name = "pandas-stubs" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -512,6 +552,7 @@ dev = [
|
|||||||
{ name = "mypy", specifier = ">=2.3.1" },
|
{ name = "mypy", specifier = ">=2.3.1" },
|
||||||
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
{ name = "ruff", specifier = ">=0.16.7" },
|
{ name = "ruff", specifier = ">=0.16.7" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1591,6 +1632,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ sonar.organization=groupe3-ener-vision
|
|||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
# Dossier contenant le code source
|
# Dossier contenant le code source
|
||||||
sonar.sources=apps/frontend/src,apps/backend
|
sonar.sources=apps/frontend/src,apps/backend,ml,etl/airflow
|
||||||
# Dossier contenant les tests
|
# Dossier contenant les tests
|
||||||
sonar.tests=apps/frontend/src,apps/backend/tests
|
sonar.tests=apps/frontend/src,apps/backend/tests,ml/tests,etl/airflow/tests
|
||||||
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
||||||
|
|
||||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
# Liste des fichiers et dossiers à exclure de l'analyse
|
||||||
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
|
sonar.exclusions=.pytest_cache,.venv,.airflow_home,alembic,tests,ml/data/**,ml/models/**,ml/mlruns/**,ml/mlartifacts/**,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
|
||||||
|
|
||||||
# Chemin vers le rapport de couverture de code
|
# Chemin vers le rapport de couverture de code
|
||||||
# Fichier généré par Pytest
|
# Fichier généré par Pytest
|
||||||
sonar.python.coverage.reportPaths=apps/backend/coverage.xml
|
sonar.python.coverage.reportPaths=apps/backend/coverage.xml,ml/coverage.xml
|
||||||
|
# Les DAGs n'ont pas de couverture mesurable : leurs tests ne font que les charger (DagBag)
|
||||||
|
sonar.coverage.exclusions=etl/airflow/**
|
||||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|||||||
Reference in New Issue
Block a user