|
6 | 6 | - the daytime tariff pause in _determine_charging_strategy |
7 | 7 | - the deadline current-floor applied in _execute_ev_control's night branch |
8 | 8 | """ |
| 9 | +import logging |
9 | 10 | from datetime import timedelta |
10 | 11 | from unittest.mock import AsyncMock, MagicMock, patch |
11 | 12 |
|
@@ -358,6 +359,130 @@ def test_min_plus_solar_ignores_tariff_signal(self): |
358 | 359 | assert strategy == "solar_only" |
359 | 360 |
|
360 | 361 |
|
| 362 | +class TestTariffPauseWarningFlap: |
| 363 | + """``_tariff_pause_warned`` rate-limits the one-shot ``provider error'' |
| 364 | + warning when the tariff provider raises while ``solar_plus_cheap`` is |
| 365 | + active. v1.6.3 review surfaced an unguarded code path: the original |
| 366 | + factoring cleared the flag in two places (the EXPENSIVE branch and the |
| 367 | + cheap-or-normal branch). The duplicated clear looked like a bug |
| 368 | + (reviewer flagged it), but the actual invariant is |
| 369 | + ``successful get_price_level() ⇒ flag cleared`` regardless of price. |
| 370 | + The refactor pulled the clear above the price-level branch so the |
| 371 | + invariant is local to one line. These tests pin that contract so a |
| 372 | + future split-by-branch doesn't silently restore the multi-warn loop. |
| 373 | + """ |
| 374 | + |
| 375 | + def _power(self, battery_soc=50): |
| 376 | + return PowerReadings( |
| 377 | + solar_power=2000.0, grid_power=-500.0, home_consumption_power=1500.0, |
| 378 | + battery_power=0.0, battery_soc=battery_soc, ev_power=0.0, |
| 379 | + ev_connected=True, ev_charging=False, |
| 380 | + ) |
| 381 | + |
| 382 | + def _drive(self, coord, n=1): |
| 383 | + """Run the daytime strategy ``n`` times for ``solar_plus_cheap``.""" |
| 384 | + cfg = {"id": "keba", "charge_mode": "solar_plus_cheap"} |
| 385 | + coord.time_manager.is_night_mode = MagicMock(return_value=False) |
| 386 | + for _ in range(n): |
| 387 | + coord._determine_charging_strategy( |
| 388 | + self._power(battery_soc=50), MagicMock(daily_ev=0.0), cfg, |
| 389 | + ) |
| 390 | + |
| 391 | + def test_warning_emitted_once_per_provider_outage(self, caplog): |
| 392 | + """A first provider error logs the warning; subsequent ticks |
| 393 | + during the same outage do not re-fire it.""" |
| 394 | + coord = _build_coordinator(tariff_on=True) |
| 395 | + coord._tariff_provider.get_price_level = MagicMock( |
| 396 | + side_effect=RuntimeError("provider down"), |
| 397 | + ) |
| 398 | + |
| 399 | + with caplog.at_level(logging.WARNING, |
| 400 | + logger="custom_components.solar_energy_management.coordinator.coordinator"): |
| 401 | + self._drive(coord, n=5) |
| 402 | + |
| 403 | + msgs = [r.message for r in caplog.records |
| 404 | + if "Tariff-optimized daytime pause disabled" in r.message] |
| 405 | + assert len(msgs) == 1, ( |
| 406 | + f"expected one warning across 5 ticks of a single outage; got {len(msgs)}" |
| 407 | + ) |
| 408 | + assert coord._tariff_pause_warned is True |
| 409 | + |
| 410 | + def test_flag_cleared_on_successful_call_even_when_price_expensive(self): |
| 411 | + """Successful ``get_price_level()`` is the recovery signal — the |
| 412 | + flag must clear regardless of whether the returned price is |
| 413 | + CHEAP or EXPENSIVE. Pinning the v1.6.3 invariant: a provider |
| 414 | + recovery during an expensive window still re-arms the next |
| 415 | + warning rather than letting it silently re-fire.""" |
| 416 | + coord = _build_coordinator(tariff_on=True) |
| 417 | + # Tick 1: provider errors → flag set |
| 418 | + coord._tariff_provider.get_price_level = MagicMock( |
| 419 | + side_effect=RuntimeError("provider down"), |
| 420 | + ) |
| 421 | + self._drive(coord, n=1) |
| 422 | + assert coord._tariff_pause_warned is True |
| 423 | + |
| 424 | + # Tick 2: provider recovers but happens to return EXPENSIVE |
| 425 | + # → flag MUST clear (EXPENSIVE return is still a successful call). |
| 426 | + coord._tariff_provider.get_price_level = MagicMock( |
| 427 | + return_value=PriceLevel.EXPENSIVE, |
| 428 | + ) |
| 429 | + self._drive(coord, n=1) |
| 430 | + assert coord._tariff_pause_warned is False, ( |
| 431 | + "successful read with EXPENSIVE price must clear the flag; " |
| 432 | + "otherwise the next outage silently fails the rate-limit " |
| 433 | + "because the flag is already True" |
| 434 | + ) |
| 435 | + |
| 436 | + def test_flag_cleared_on_successful_call_with_cheap_price(self): |
| 437 | + """Same invariant via the other branch — successful CHEAP read |
| 438 | + also clears the flag.""" |
| 439 | + coord = _build_coordinator(tariff_on=True) |
| 440 | + coord._tariff_provider.get_price_level = MagicMock( |
| 441 | + side_effect=RuntimeError("provider down"), |
| 442 | + ) |
| 443 | + self._drive(coord, n=1) |
| 444 | + assert coord._tariff_pause_warned is True |
| 445 | + |
| 446 | + coord._tariff_provider.get_price_level = MagicMock( |
| 447 | + return_value=PriceLevel.CHEAP, |
| 448 | + ) |
| 449 | + self._drive(coord, n=1) |
| 450 | + assert coord._tariff_pause_warned is False |
| 451 | + |
| 452 | + def test_warning_re_fires_after_recovery_then_new_outage(self, caplog): |
| 453 | + """Recovery resets the rate-limit, so a fresh outage warns |
| 454 | + again. This is the user-visible behaviour the suppression |
| 455 | + wants: notify on transitions, not every tick of a continuous |
| 456 | + outage.""" |
| 457 | + coord = _build_coordinator(tariff_on=True) |
| 458 | + |
| 459 | + # Outage 1 |
| 460 | + coord._tariff_provider.get_price_level = MagicMock( |
| 461 | + side_effect=RuntimeError("provider down"), |
| 462 | + ) |
| 463 | + with caplog.at_level(logging.WARNING, |
| 464 | + logger="custom_components.solar_energy_management.coordinator.coordinator"): |
| 465 | + self._drive(coord, n=3) |
| 466 | + |
| 467 | + # Recovery |
| 468 | + coord._tariff_provider.get_price_level = MagicMock( |
| 469 | + return_value=PriceLevel.CHEAP, |
| 470 | + ) |
| 471 | + self._drive(coord, n=2) |
| 472 | + |
| 473 | + # Outage 2 |
| 474 | + coord._tariff_provider.get_price_level = MagicMock( |
| 475 | + side_effect=RuntimeError("provider down again"), |
| 476 | + ) |
| 477 | + self._drive(coord, n=3) |
| 478 | + |
| 479 | + msgs = [r.message for r in caplog.records |
| 480 | + if "Tariff-optimized daytime pause disabled" in r.message] |
| 481 | + assert len(msgs) == 2, ( |
| 482 | + f"expected one warning per outage across 2 outages; got {len(msgs)}" |
| 483 | + ) |
| 484 | + |
| 485 | + |
361 | 486 | # --------------------------------------------------------------------------- |
362 | 487 | # Deadline floor in _execute_ev_control night branch (#246) |
363 | 488 | # --------------------------------------------------------------------------- |
|
0 commit comments