diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index cbc47e8b1..1c01f94c8 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1986,6 +1986,11 @@ def fetch_ml_load_forecast(self, now_utc): and returns it as a minute_data dictionary """ # Use ML Model for load prediction + status = self.get_state_wrapper("sensor." + self.prefix + "_load_ml_forecast") + if status and status != "active": + self.log("ML load forecast is not active (status: {}), falling back to historical data".format(status)) + return {} + load_ml_forecast = self.get_state_wrapper("sensor." + self.prefix + "_load_ml_forecast", attribute="results") if load_ml_forecast: self.log("Loading ML load forecast from sensor.{}_load_ml_forecast".format(self.prefix)) diff --git a/apps/predbat/load_ml_component.py b/apps/predbat/load_ml_component.py index 859e23836..dbde088cc 100644 --- a/apps/predbat/load_ml_component.py +++ b/apps/predbat/load_ml_component.py @@ -23,7 +23,7 @@ import os from datetime import datetime, timezone, timedelta from component_base import ComponentBase -from utils import get_now_from_cumulative, dp2, dp4, minute_data +from utils import get_now_from_cumulative, dp2, dp4, minute_data, safe_float from load_predictor import LoadPredictor, MODEL_VERSION from const import TIME_FORMAT, PREDICT_STEP import json @@ -1054,9 +1054,10 @@ def _publish_entity(self): }, app="load_ml", ) + self.dashboard_item( "sensor." + self.prefix + "_load_ml_stats", - state=round(total_kwh, 2), + state=safe_float(round(total_kwh, 2)), attributes={ "load_today": dp2(self.load_minutes_now), "load_today_h1": dp2(load_today_h1), @@ -1065,10 +1066,10 @@ def _publish_entity(self): "power_today_now": dp2(power_today_now), "power_today_h1": dp2(power_today_h1), "power_today_h8": dp2(power_today_h8), - "mae_kwh": round(float(self.predictor.validation_mae), 4) if self.predictor and self.predictor.validation_mae is not None else None, - "bias_kwh": round(self.predictor.validation_bias, 4) if self.predictor and self.predictor.validation_bias is not None else None, + "mae_kwh": safe_float(self.predictor.validation_mae) if self.predictor else None, + "bias_kwh": safe_float(self.predictor.validation_bias) if self.predictor else None, "last_trained": self.last_train_time.isoformat() if self.last_train_time else None, - "model_age_hours": round(model_age_hours, 1) if model_age_hours is not None else None, + "model_age_hours": safe_float(model_age_hours), "training_days": self.load_data_age_days, "status": self.model_status, "model_version": MODEL_VERSION, diff --git a/apps/predbat/load_predictor.py b/apps/predbat/load_predictor.py index 25bf6cc85..a345d9b07 100644 --- a/apps/predbat/load_predictor.py +++ b/apps/predbat/load_predictor.py @@ -1345,7 +1345,7 @@ def train_curriculum( ) total_passes = len(window_sizes) + 1 # intermediate passes + final full pass - self.log("ML Predictor: Curriculum training - {} passes, window {:.1f}→{:.1f} days + final full pass ({:.1f} days)".format(total_passes, window_sizes[0] / day_minutes, window_sizes[-1] / day_minutes, max_minute / day_minutes)) + self.log("ML Predictor: Curriculum training - {} passes, window {:.1f}->{:.1f} days + final full pass ({:.1f} days)".format(total_passes, window_sizes[0] / day_minutes, window_sizes[-1] / day_minutes, max_minute / day_minutes)) val_mae = None for pass_idx, window in enumerate(window_sizes): diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 47ce8b6b3..0e474fc78 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -354,6 +354,10 @@ def reset(self): self.max_days_previous = max(self.days_previous) + 1 self.forecast_days = 0 self.forecast_minutes = 0 + self.inverter_clock_skew_start = 0 + self.inverter_clock_skew_end = 0 + self.inverter_clock_skew_discharge_start = 0 + self.inverter_clock_skew_discharge_end = 0 self.soc_kw = 0 self.soc_percent = 0 self.soc_max = 10.0 @@ -1602,7 +1606,7 @@ def initialize(self): self.record_status("Error: Some components failed to start (phase 2)", had_errors=True) self.load_user_config(quiet=False, register=True) - self.auto_config(final=True) + self.auto_config(final=False) self.validate_config() # Restore the last saved plan so it is immediately active before the first calculation @@ -1770,6 +1774,12 @@ def run_time_loop(self, cb_args): self.validate_config() config_changed = True + # Retry auto config if we have unmatched regexes + if hasattr(self, "unmatched_args") and len(self.unmatched_args) > 0: + # Give HA 10 minutes from startup to fully load entities before final disable + time_since_start = (self.now_utc_real - self.started_time).total_seconds() / 60.0 + self.auto_config(final=(time_since_start > 10)) + # Run the prediction self.update_pred(scheduled=True) diff --git a/apps/predbat/tests/open_meteo_live.py b/apps/predbat/tests/open_meteo_live.py index 3ac6ff669..e69989ae3 100644 --- a/apps/predbat/tests/open_meteo_live.py +++ b/apps/predbat/tests/open_meteo_live.py @@ -159,7 +159,7 @@ def print_comparison_table(label: str, sources: dict, day_str: str, tz_name: str """Print a side-by-side comparison table for one day. sources is an ordered dict of {source_name: hourly_dict} where each hourly_dict - maps 'YYYY-MM-DDTHH' → (kwh_p50, kwh_p10) as returned by _aggregate_to_hourly(). + maps 'YYYY-MM-DDTHH' -> (kwh_p50, kwh_p10) as returned by _aggregate_to_hourly(). Hours are shown in local time (tz_name). """ source_names = list(sources.keys()) @@ -211,8 +211,8 @@ def print_comparison_table(label: str, sources: dict, day_str: str, tz_name: str async def run(fs_api_key: str = None, solcast_api_key: str = None, solcast_host: str = None) -> None: """Fetch live data via fetch_pv_forecast() for each source and print comparison. - Runs the full Predbat PV fetch pipeline (download → minute_data → pv_calibration - → publish_pv_stats) for each enabled source, then reads the detailedForecast that + Runs the full Predbat PV fetch pipeline (download -> minute_data -> pv_calibration + -> publish_pv_stats) for each enabled source, then reads the detailedForecast that Predbat would store in sensor.predbat_pv_today. """ now_local = datetime.now(tz=LOCAL_TZ) @@ -228,10 +228,10 @@ async def run(fs_api_key: str = None, solcast_api_key: str = None, solcast_host: _tmp = SolarAPI.__new__(SolarAPI) for i, a in enumerate(ARRAYS, 1): az_api = SolarAPI.convert_azimuth(_tmp, a["azimuth"]) - print(f" [{i}] postcode={a['postcode']} kwp={a['kwp']} declination={a['declination']}° azimuth={a['azimuth']}° (→API: {az_api:.0f}°) efficiency={a.get('efficiency', 1.0):.0%}") + print(f" [{i}] postcode={a['postcode']} kwp={a['kwp']} declination={a['declination']}° azimuth={a['azimuth']}° (->API: {az_api:.0f}°) efficiency={a.get('efficiency', 1.0):.0%}") print(f" cache: {_CACHE_ROOT}/cache/") - # results maps source_name → {"today": hourly_dict, "tomorrow": hourly_dict} + # results maps source_name -> {"today": hourly_dict, "tomorrow": hourly_dict} results: dict = {} # ── Open-Meteo ──────────────────────────────────────────────────────────── diff --git a/apps/predbat/tests/test_auto_config.py b/apps/predbat/tests/test_auto_config.py new file mode 100644 index 000000000..02c25ca33 --- /dev/null +++ b/apps/predbat/tests/test_auto_config.py @@ -0,0 +1,77 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# ----------------------------------------------------------------------------- + + +def run_auto_config_tests(my_predbat): + """ + Test auto_config retry logic and list/dict matching + """ + failed = False + print("\n============================================================") + print("Running auto_config tests") + print("============================================================") + + # Backup original + original_args = my_predbat.args.copy() + original_unmatched = getattr(my_predbat, "unmatched_args", {}).copy() + + try: + # Test 1: List matching retains 're:' elements if they fail + print("\n=== Test 1: List regex matching ===") + my_predbat.args = {"pv_forecast_raw": ["sensor.inv1", "re:.*inv2"]} + my_predbat.unmatched_args = {} + + # This simulates < 10 mins, so final=False + my_predbat.auto_config(final=False) + + # It should still be in args exactly as it was, because matched=True for lists + val = my_predbat.args.get("pv_forecast_raw") + if val != ["sensor.inv1", "re:.*inv2"]: + print(f"FAIL: List regex matching failed to retain string. Got {val}") + failed = True + else: + print("PASS: List regex retained.") + + # Test 2: Dict matching retains 're:' elements if they fail + print("\n=== Test 2: Dict regex matching ===") + my_predbat.args = {"my_dict": {"foo": "sensor.foo", "bar": "re:.*bar"}} + my_predbat.unmatched_args = {} + + my_predbat.auto_config(final=False) + + val = my_predbat.args.get("my_dict") + if val != {"foo": "sensor.foo", "bar": "re:.*bar"}: + print(f"FAIL: Dict regex matching failed to retain string. Got {val}") + failed = True + else: + print("PASS: Dict regex retained.") + + # Test 3: Standard unmatched arg is moved to unmatched_args after 10 mins (final=True) + print("\n=== Test 3: Final=True moves unmatched args ===") + my_predbat.args = {"inverter_limit": "re:.*missing_limit.*"} + my_predbat.unmatched_args = {} + + my_predbat.auto_config(final=True) + + if "inverter_limit" in my_predbat.args: + print(f"FAIL: inverter_limit still in args: {my_predbat.args['inverter_limit']}") + failed = True + elif "inverter_limit" not in my_predbat.unmatched_args: + print(f"FAIL: inverter_limit not in unmatched_args") + failed = True + else: + print("PASS: Final=True moved unmatched arg correctly.") + + finally: + my_predbat.args = original_args + my_predbat.unmatched_args = original_unmatched + + print("============================================================") + if failed: + print("FAIL: SOME TESTS FAILED") + else: + print("PASS: ALL TESTS PASSED") + print("============================================================") + + return failed diff --git a/apps/predbat/tests/test_axle.py b/apps/predbat/tests/test_axle.py index c12ffe3c2..0b20df418 100644 --- a/apps/predbat/tests/test_axle.py +++ b/apps/predbat/tests/test_axle.py @@ -165,13 +165,13 @@ def test_axle(my_predbat=None): try: result = test_func(my_predbat) if result: - print(f"✗ FAILED: {key}") + print(f"FAIL: FAILED: {key}") failed += 1 else: - print(f"✓ PASSED: {key}") + print(f"PASS: PASSED: {key}") passed += 1 except Exception as e: - print(f"✗ EXCEPTION in {key}: {e}") + print(f"FAIL: EXCEPTION in {key}: {e}") import traceback traceback.print_exc() @@ -201,7 +201,7 @@ def _test_axle_initialization(my_predbat=None): assert axle.event_history == [], "Event history should be empty on init" assert axle.history_loaded is False, "History should not be loaded on init" - print(" ✓ Initialisation successful") + print(" PASS: Initialisation successful") return False @@ -221,7 +221,7 @@ def _test_axle_list_api_key_validation(my_predbat=None): assert axle2.api_key is None, "Empty list should result in None api_key" - print(" ✓ List API key validation works correctly") + print(" PASS: List API key validation works correctly") return False @@ -270,7 +270,7 @@ def _test_axle_fetch_with_active_event(my_predbat=None): assert axle.failures_total == 0, "No failures should be recorded" - print(" ✓ Active event fetched and published correctly") + print(" PASS: Active event fetched and published correctly") return False @@ -313,7 +313,7 @@ def _test_axle_duplicate_event_detection(my_predbat=None): # Check that NO alert was sent for duplicate event alert_messages = [msg for msg in axle.log_messages if msg.startswith("Alert:")] assert len(alert_messages) == 0, "Should NOT send alert for duplicate event" - print(" ✓ No alert sent for duplicate event") + print(" PASS: No alert sent for duplicate event") # Reset log messages axle.log_messages = [] @@ -332,7 +332,7 @@ def _test_axle_duplicate_event_detection(my_predbat=None): assert len(alert_messages) == 1, "Should send alert for new/different event" assert "18:00" in alert_messages[0], "Alert should contain new event time" assert "20:00" in alert_messages[0], "Alert should contain new event end time" - print(" ✓ Alert sent for new/different event") + print(" PASS: Alert sent for new/different event") # Test 3: Fetch event with different end time - should trigger alert print(" Test 3: Same start but different end time") @@ -348,7 +348,7 @@ def _test_axle_duplicate_event_detection(my_predbat=None): alert_messages = [msg for msg in axle.log_messages if msg.startswith("Alert:")] assert len(alert_messages) == 1, "Should send alert for event with different end time" assert "21:00" in alert_messages[0], "Alert should contain updated end time" - print(" ✓ Alert sent for event with different end time") + print(" PASS: Alert sent for event with different end time") # Test 4: No current_event in sensor (empty state) - should trigger alert for new event print(" Test 4: Empty sensor state (first event)") @@ -372,9 +372,9 @@ def _test_axle_duplicate_event_detection(my_predbat=None): alert_messages = [msg for msg in axle.log_messages if msg.startswith("Alert:")] assert len(alert_messages) == 1, "Should send alert for first event when sensor is empty" assert "22:00" in alert_messages[0], "Alert should contain first event time" - print(" ✓ Alert sent for first event when sensor empty") + print(" PASS: Alert sent for first event when sensor empty") - print(" ✓ All duplicate event detection tests passed") + print(" PASS: All duplicate event detection tests passed") return False @@ -406,7 +406,7 @@ def _test_axle_fetch_with_notify_config(my_predbat=None): alert_messages = [msg for msg in axle.log_messages if msg.startswith("Alert:")] assert len(alert_messages) == 1, "Should send notification when set_event_notify=True" assert "18:00" in alert_messages[0], "Notification should contain event time" - print(" ✓ Notification sent when enabled") + print(" PASS: Notification sent when enabled") # Test 2: set_event_notify disabled - should NOT send notification print(" Test 2: Notifications disabled (set_event_notify=False)") @@ -427,16 +427,16 @@ def _test_axle_fetch_with_notify_config(my_predbat=None): # Verify NO notification was sent alert_messages2 = [msg for msg in axle2.log_messages if msg.startswith("Alert:")] assert len(alert_messages2) == 0, "Should NOT send notification when set_event_notify=False" - print(" ✓ Notification blocked when disabled") + print(" PASS: Notification blocked when disabled") # Verify event data was still processed correctly assert axle2.current_event["start_time"] == "2025-12-20T18:00:00+0000" assert axle2.current_event["import_export"] == "import" sensor2 = axle2.dashboard_items["binary_sensor.predbat_axle_event"] assert sensor2["state"] == "on", "Sensor should be ON even without notification" - print(" ✓ Event data processed correctly without notification") + print(" PASS: Event data processed correctly without notification") - print(" ✓ Notification config tests passed") + print(" PASS: Notification config tests passed") return False @@ -472,7 +472,7 @@ def _test_axle_fetch_with_future_event(my_predbat=None): assert len(sensor["attributes"]["event_current"]) == 1, "Should have current event in list" assert len(sensor["attributes"]["event_history"]) == 0, "Future event not in history" - print(" ✓ Future event shows OFF state correctly") + print(" PASS: Future event shows OFF state correctly") return False @@ -505,7 +505,7 @@ def _test_axle_fetch_with_past_event(my_predbat=None): assert len(sensor["attributes"]["event_current"]) == 1, "Should have current event" assert len(sensor["attributes"]["event_history"]) == 1, "Past event in history" - print(" ✓ Past event added to history correctly") + print(" PASS: Past event added to history correctly") return False @@ -536,7 +536,7 @@ def _test_axle_fetch_no_event(my_predbat=None): assert axle.failures_total == 0, "No failures for empty response" - print(" ✓ No event handled correctly") + print(" PASS: No event handled correctly") return False @@ -561,7 +561,7 @@ def _test_axle_http_error(my_predbat=None): assert axle.failures_total == 1, "Failure should be recorded for HTTP error" assert any("status code 401" in msg for msg in axle.log_messages), "Error should be logged" - print(" ✓ HTTP error handled correctly (no retry)") + print(" PASS: HTTP error handled correctly (no retry)") return False @@ -604,7 +604,7 @@ async def count_calls(self): assert any("Request failed after 3 attempts" in msg for msg in axle.log_messages), "Exception should be logged with retry count" assert any("Retrying in" in msg for msg in axle.log_messages), "Retry messages should be logged" - print(" ✓ Request exception handled correctly with 3 retries and exponential backoff") + print(" PASS: Request exception handled correctly with 3 retries and exponential backoff") return False @@ -656,7 +656,7 @@ def get_session(*args, **kwargs): assert any("Successfully fetched event data" in msg for msg in axle.log_messages), "Success should be logged" assert any("Retrying in" in msg for msg in axle.log_messages), "Retry attempts should be logged" - print(" ✓ Retry mechanism succeeds after initial failures") + print(" PASS: Retry mechanism succeeds after initial failures") return False @@ -685,7 +685,7 @@ def _test_axle_datetime_parsing_variations(my_predbat=None): assert isinstance(axle.current_event["start_time"], str), "Should be stored as string" assert isinstance(axle.current_event["end_time"], str), "Should be stored as string" - print(" ✓ Different datetime formats parsed correctly") + print(" PASS: Different datetime formats parsed correctly") return False @@ -710,7 +710,7 @@ def _test_axle_json_parse_error(my_predbat=None): assert axle.failures_total == 1, "Failure should be recorded for JSON parse error" assert any("Failed to parse JSON response" in msg for msg in axle.log_messages), "Parse error should be logged" - print(" ✓ JSON parse error handled correctly (no retry)") + print(" PASS: JSON parse error handled correctly (no retry)") return False @@ -733,7 +733,7 @@ async def mock_fetch(): assert result is True, "Run should return True" assert axle.history_loaded is True, "Should load history on first run" assert len(fetch_called) == 1, "Should fetch when updated_at is None" - print(" ✓ Fetches when no previous updated_at") + print(" PASS: Fetches when no previous updated_at") # Test 2: recent updated_at (< 10 min ago) - should NOT fetch print(" Test 2: Recent updated_at (5 min ago) - should skip") @@ -745,7 +745,7 @@ async def mock_fetch(): fetch_called.clear() result = run_async(axle2.run(seconds=300, first=False)) assert len(fetch_called) == 0, "Should not fetch when updated_at is < 10 min old" - print(" ✓ Skips fetch when data is fresh") + print(" PASS: Skips fetch when data is fresh") # Test 3: stale updated_at (>= 10 min ago) - should fetch print(" Test 3: Stale updated_at (11 min ago) - should fetch") @@ -757,7 +757,7 @@ async def mock_fetch(): fetch_called.clear() result = run_async(axle3.run(seconds=660, first=False)) assert len(fetch_called) == 1, "Should fetch when updated_at is 10+ min old" - print(" ✓ Fetches when data is stale") + print(" PASS: Fetches when data is stale") # Test 4: restart with recent updated_at (restored from storage) - should NOT fetch print(" Test 4: Restart with recent updated_at (2 min ago) - should skip") @@ -770,7 +770,7 @@ async def mock_fetch(): result = run_async(axle4.run(seconds=0, first=True)) assert axle4.history_loaded is True, "Should still load history on first run" assert len(fetch_called) == 0, "Should not fetch on restart when updated_at is still fresh" - print(" ✓ Skips fetch on restart when updated_at is still fresh") + print(" PASS: Skips fetch on restart when updated_at is still fresh") # Test 5: exactly 10 minutes - boundary: should fetch print(" Test 5: Exactly 10 minutes old - boundary fetch") @@ -782,9 +782,9 @@ async def mock_fetch(): fetch_called.clear() run_async(axle5.run(seconds=600, first=False)) assert len(fetch_called) == 1, "Should fetch at exactly 10 min boundary" - print(" ✓ Fetches at exactly 10 min boundary") + print(" PASS: Fetches at exactly 10 min boundary") - print(" ✓ Run method time-based polling logic correct") + print(" PASS: Run method time-based polling logic correct") return False @@ -827,7 +827,7 @@ def _test_axle_history_loading(my_predbat=None): assert axle.event_history[0]["import_export"] == "import" assert axle.history_loaded is True - print(" ✓ History loading filters future events correctly") + print(" PASS: History loading filters future events correctly") return False @@ -862,7 +862,7 @@ def _test_axle_history_cleanup(my_predbat=None): assert len(axle.event_history) == 1, "Should remove events older than 7 days" assert axle.event_history[0]["import_export"] == "export" - print(" ✓ History cleanup removes old events correctly") + print(" PASS: History cleanup removes old events correctly") return False @@ -932,7 +932,7 @@ def _test_axle_fetch_sessions(my_predbat=None): assert sessions[2]["start_time"] == "2025-12-18T14:00:00+0000" assert sessions[2]["import_export"] == "export" - print(" ✓ Fetch sessions returns current + history correctly") + print(" PASS: Fetch sessions returns current + history correctly") return False @@ -1020,12 +1020,12 @@ def get_arg(self, name, indirect=True): assert base.load_scaling_dynamic.get(end_minutes) is None, "load_scaling_dynamic after event should be unchanged" # Verify the event was logged - print(" ✓ Export rates increased by 100p/kWh for 2-hour period (14:00-16:00)") - print(f" ✓ Rate at 14:00 changed from {original_rate_before} to {base.rate_export[start_minutes]}") - print(f" ✓ Rate at 13:59 unchanged: {base.rate_export[start_minutes - 1]}") - print(f" ✓ Rate at 16:00 unchanged: {base.rate_export[end_minutes]}") - print(f" ✓ {len(rate_replicate)} minutes marked as 'saving' events") - print(f" ✓ load_scaling_saving ({base.load_scaling_saving}) applied to {len(base.load_scaling_dynamic)} minutes in export event") + print(" PASS: Export rates increased by 100p/kWh for 2-hour period (14:00-16:00)") + print(f" PASS: Rate at 14:00 changed from {original_rate_before} to {base.rate_export[start_minutes]}") + print(f" PASS: Rate at 13:59 unchanged: {base.rate_export[start_minutes - 1]}") + print(f" PASS: Rate at 16:00 unchanged: {base.rate_export[end_minutes]}") + print(f" PASS: {len(rate_replicate)} minutes marked as 'saving' events") + print(f" PASS: load_scaling_saving ({base.load_scaling_saving}) applied to {len(base.load_scaling_dynamic)} minutes in export event") return False @@ -1102,12 +1102,12 @@ def get_arg(self, name, indirect=True): assert base.load_scaling_dynamic.get(start_minutes - 1) is None, "load_scaling_dynamic before event should be unchanged" assert base.load_scaling_dynamic.get(end_minutes) is None, "load_scaling_dynamic after event should be unchanged" - print(" ✓ Import rates decreased by 10p/kWh for 2-hour period (02:00-04:00)") - print(f" ✓ Rate at 02:00 changed from {original_rate} to {base.rate_import[start_minutes]}") - print(f" ✓ Rate at 01:59 unchanged: {base.rate_import[start_minutes - 1]}") - print(f" ✓ Rate at 04:00 unchanged: {base.rate_import[end_minutes]}") - print(f" ✓ {len(rate_replicate)} minutes marked as 'saving' events") - print(f" ✓ load_scaling_free ({base.load_scaling_free}) applied to {len(base.load_scaling_dynamic)} minutes in import event") + print(" PASS: Import rates decreased by 10p/kWh for 2-hour period (02:00-04:00)") + print(f" PASS: Rate at 02:00 changed from {original_rate} to {base.rate_import[start_minutes]}") + print(f" PASS: Rate at 01:59 unchanged: {base.rate_import[start_minutes - 1]}") + print(f" PASS: Rate at 04:00 unchanged: {base.rate_import[end_minutes]}") + print(f" PASS: {len(rate_replicate)} minutes marked as 'saving' events") + print(f" PASS: load_scaling_free ({base.load_scaling_free}) applied to {len(base.load_scaling_dynamic)} minutes in import event") return False @@ -1161,7 +1161,7 @@ def _test_axle_active_function(my_predbat=None): result = fetch_axle_active(test_interface_no_config) assert result is False, "fetch_axle_active should return False when axle_session is not configured" - print("✓ fetch_axle_active function test passed") + print("PASS: fetch_axle_active function test passed") return False @@ -1178,14 +1178,14 @@ def _test_axle_managed_initialization(my_predbat=None): assert axle.partner_password == "secret" assert axle.api_base_url == "https://api-sandbox.axle.energy" assert axle.api_key is None, "api_key should be None in managed mode" - print(" ✓ Valid managed mode initialisation") + print(" PASS: Valid managed mode initialisation") # Missing site_id should disable managed mode axle2 = MockAxleAPI() axle2.initialize(api_key=None, pence_per_kwh=100, automatic=False, managed_mode=True, site_id=None, partner_username="user@test.com", partner_password="secret") assert axle2.managed_mode is False, "managed_mode should be False when site_id missing" assert any("site_id" in msg for msg in axle2.log_messages), "Should log missing site_id" - print(" ✓ Missing site_id disables managed mode") + print(" PASS: Missing site_id disables managed mode") # Missing all managed params axle3 = MockAxleAPI() @@ -1193,7 +1193,7 @@ def _test_axle_managed_initialization(my_predbat=None): assert axle3.managed_mode is False, "managed_mode should be False when all params missing" assert any("partner_username" in msg for msg in axle3.log_messages) assert any("partner_password" in msg for msg in axle3.log_messages) - print(" ✓ Missing all params disables managed mode") + print(" PASS: Missing all params disables managed mode") return False @@ -1228,11 +1228,11 @@ def _test_axle_managed_price_curve_processing(my_predbat=None): assert len(export_events) == 2 assert len(import_events) == 2 - # First export: 50 GBP/MWh → 5.0 p/kWh + # First export: 50 GBP/MWh -> 5.0 p/kWh first_export = [e for e in export_events if "14:00:00" in e["start_time"]][0] assert first_export["pence_per_kwh"] == 5.0, f"Expected 5.0, got {first_export['pence_per_kwh']}" - # First import: negated → -5.0 p/kWh + # First import: negated -> -5.0 p/kWh first_import = [e for e in import_events if "14:00:00" in e["start_time"]][0] assert first_import["pence_per_kwh"] == -5.0, f"Expected -5.0, got {first_import['pence_per_kwh']}" @@ -1247,9 +1247,9 @@ def _test_axle_managed_price_curve_processing(my_predbat=None): null_events = [e for e in axle.event_history if "15:00:00" in e.get("start_time", "")] assert len(null_events) == 0, "Null price slots should be skipped" - print(" ✓ Price curve conversion correct (GBP/MWh → p/kWh)") - print(" ✓ Export and import sessions created per slot") - print(" ✓ Null prices skipped") + print(" PASS: Price curve conversion correct (GBP/MWh -> p/kWh)") + print(" PASS: Export and import sessions created per slot") + print(" PASS: Null prices skipped") return False @@ -1296,8 +1296,8 @@ def _test_axle_managed_event_dedup(my_predbat=None): export_event = [e for e in axle.event_history if e["import_export"] == "export"][0] assert export_event["pence_per_kwh"] == 6.0, "Deduped event should have updated price" - print(" ✓ Different directions at same time are kept separately") - print(" ✓ Same direction at same time is deduped with update") + print(" PASS: Different directions at same time are kept separately") + print(" PASS: Same direction at same time is deduped with update") return False @@ -1327,8 +1327,8 @@ def _test_axle_managed_future_events(my_predbat=None): axle.add_event_to_history(future_event, allow_future=True) assert len(axle.event_history) == 1, "Future event should be accepted with allow_future" - print(" ✓ Future events rejected without allow_future") - print(" ✓ Future events accepted with allow_future") + print(" PASS: Future events rejected without allow_future") + print(" PASS: Future events accepted with allow_future") return False @@ -1352,7 +1352,7 @@ def _test_axle_managed_publish(my_predbat=None): sensor = axle_managed.dashboard_items["binary_sensor.predbat_axle_event"] assert sensor["attributes"]["managed_mode"] is True, "Managed should have managed_mode=True" - print(" ✓ managed_mode attribute correctly set in sensor") + print(" PASS: managed_mode attribute correctly set in sensor") return False @@ -1381,8 +1381,8 @@ def _test_axle_managed_disabled_without_creds(my_predbat=None): axle2.initialize(api_key=None, pence_per_kwh=100, automatic=False, managed_mode=False) assert any("BYOK" in msg for msg in axle2.log_messages), "Should warn about BYOK needing api_key" - print(" ✓ Managed mode disabled when credentials missing") - print(" ✓ BYOK warning logged when managed_mode=False and no api_key") + print(" PASS: Managed mode disabled when credentials missing") + print(" PASS: BYOK warning logged when managed_mode=False and no api_key") return False @@ -1403,7 +1403,7 @@ def _test_axle_managed_get_partner_token(my_predbat=None): assert token == "tok_abc123", f"Expected tok_abc123, got {token}" assert axle.partner_token == "tok_abc123" assert axle.partner_token_expiry is not None - print(" ✓ Token fetched successfully") + print(" PASS: Token fetched successfully") # Test 2: Cached token returned without HTTP call call_count = [0] @@ -1420,7 +1420,7 @@ async def counting_aenter(*args): assert token2 == "tok_abc123", "Should return cached token" assert call_count[0] == 0, "Should not make HTTP call for cached token" - print(" ✓ Cached token returned without HTTP call") + print(" PASS: Cached token returned without HTTP call") # Test 3: Auth response missing access_token axle2 = MockAxleAPI() @@ -1435,7 +1435,7 @@ async def counting_aenter(*args): assert token3 is None, "Should return None when access_token missing from response" assert axle2.partner_token is None, "Should not cache empty token" assert any("missing access_token" in msg for msg in axle2.log_messages), "Should log missing token warning" - print(" ✓ Missing access_token handled correctly") + print(" PASS: Missing access_token handled correctly") # Test 4: Auth failure (401) axle3 = MockAxleAPI() @@ -1449,7 +1449,7 @@ async def counting_aenter(*args): assert token4 is None, "Should return None on auth failure" assert any("auth failed" in msg for msg in axle3.log_messages), "Should log auth failure" - print(" ✓ Auth failure returns None") + print(" PASS: Auth failure returns None") return False @@ -1515,9 +1515,9 @@ def session_factory(*args, **kwargs): assert axle.failures_total == 0 assert any("Price curve processed successfully" in msg for msg in axle.log_messages) - print(" ✓ Auth + price curve fetch works end-to-end") - print(" ✓ Sessions created with correct prices") - print(" ✓ Current event set to active export slot") + print(" PASS: Auth + price curve fetch works end-to-end") + print(" PASS: Sessions created with correct prices") + print(" PASS: Current event set to active export slot") return False @@ -1568,8 +1568,8 @@ def session_factory(*args, **kwargs): assert call_order[0] == 4, f"Expected 4 HTTP calls (auth, fail, re-auth, success), got {call_order[0]}" assert any("Price curve processed successfully" in msg for msg in axle.log_messages) - print(" ✓ Token invalidated after price curve failure") - print(" ✓ Re-auth and retry succeeds") + print(" PASS: Token invalidated after price curve failure") + print(" PASS: Re-auth and retry succeeds") # Test 2: Both attempts fail — should record failure axle2 = MockAxleAPI() @@ -1594,6 +1594,6 @@ def session_factory_all_fail(*args, **kwargs): assert axle2.failures_total == 1, f"Expected 1 failure, got {axle2.failures_total}" assert len(axle2.event_history) == 0, "No events should be created on complete failure" assert any("No price curve data after retry" in msg for msg in axle2.log_messages) - print(" ✓ Complete failure after retry records failure correctly") + print(" PASS: Complete failure after retry records failure correctly") return False diff --git a/apps/predbat/tests/test_balance_inverters.py b/apps/predbat/tests/test_balance_inverters.py index 05b9d1e93..f115a70d3 100644 --- a/apps/predbat/tests/test_balance_inverters.py +++ b/apps/predbat/tests/test_balance_inverters.py @@ -251,7 +251,7 @@ def test_balance_discharge_low_soc(my_predbat): print("ERROR: Expected inverter 0 discharge rate to be set to 0") return True - print("✓ Test passed: Low SoC inverter discharge rate set to 0") + print("PASS: Test passed: Low SoC inverter discharge rate set to 0") return False @@ -301,7 +301,7 @@ def test_balance_charge_high_soc(my_predbat): print("ERROR: Expected inverter 0 charge rate to be set to 0") return True - print("✓ Test passed: High SoC inverter charge rate set to 0") + print("PASS: Test passed: High SoC inverter charge rate set to 0") return False @@ -351,7 +351,7 @@ def test_balance_cross_charging1(my_predbat): print("ERROR: Expected inverter 2 charge rate to be set to 0 to stop cross-charging") return True - print("✓ Test passed: Cross-charging inverter charge rate set to 0") + print("PASS: Test passed: Cross-charging inverter charge rate set to 0") return False @@ -401,7 +401,7 @@ def test_balance_cross_charging2(my_predbat): print("ERROR: Expected inverter 1 discharge rate to be set to 0 to stop cross-charging") return True - print("✓ Test passed: Cross-charging inverter discharge rate set to 0") + print("PASS: Test passed: Cross-charging inverter discharge rate set to 0") return False @@ -451,7 +451,7 @@ def test_balance_cross_discharging(my_predbat): print("ERROR: Expected inverter 1 discharge rate to be set to 0 to stop cross-discharging") return True - print("✓ Test passed: Cross-discharging inverter discharge rate set to 0") + print("PASS: Test passed: Cross-discharging inverter discharge rate set to 0") return False @@ -498,7 +498,7 @@ def test_balance_already_balanced(my_predbat): print("ERROR: Expected no rate adjustments when inverters are balanced") return True - print("✓ Test passed: No adjustments made when already balanced") + print("PASS: Test passed: No adjustments made when already balanced") return False @@ -548,7 +548,7 @@ def test_balance_reset_balanced_charge(my_predbat): print("ERROR: Expected rate adjustments when inverters are balanced") return True - print("✓ Test passed: Rate adjustments made when already balanced") + print("PASS: Test passed: Rate adjustments made when already balanced") return False @@ -598,7 +598,7 @@ def test_balance_reset_balanced_discharge(my_predbat): print("ERROR: Expected rate adjustments when inverters are balanced") return True - print("✓ Test passed: Rate adjustments made when already balanced") + print("PASS: Test passed: Rate adjustments made when already balanced") return False @@ -644,7 +644,7 @@ def test_balance_below_threshold(my_predbat): print("ERROR: Expected no rate adjustments when difference below threshold") return True - print("✓ Test passed: No adjustments made when below threshold") + print("PASS: Test passed: No adjustments made when below threshold") return False @@ -692,7 +692,7 @@ def test_balance_at_reserve(my_predbat): print("ERROR: Expected inverter 0 at reserve to have discharge stopped") return True - print("✓ Test passed: Inverter at reserve has discharge stopped") + print("PASS: Test passed: Inverter at reserve has discharge stopped") return False @@ -738,7 +738,7 @@ def test_balance_disabled(my_predbat): print("ERROR: Expected no rate adjustments when balancing disabled") return True - print("✓ Test passed: No adjustments made when balancing disabled") + print("PASS: Test passed: No adjustments made when balancing disabled") return False @@ -791,7 +791,7 @@ def test_balance_calibration_mode(my_predbat): # Reset calibration mode my_predbat.inverters[0].in_calibration = False - print("✓ Test passed: No adjustments made when in calibration mode") + print("PASS: Test passed: No adjustments made when in calibration mode") return False @@ -840,5 +840,5 @@ def test_balance_insufficient_power(my_predbat): print("ERROR: Expected no rate adjustments when power below threshold") return True - print("✓ Test passed: No adjustments made when power insufficient") + print("PASS: Test passed: No adjustments made when power insufficient") return False diff --git a/apps/predbat/tests/test_battery_curve_keys.py b/apps/predbat/tests/test_battery_curve_keys.py index 512ce6b09..02f362e14 100644 --- a/apps/predbat/tests/test_battery_curve_keys.py +++ b/apps/predbat/tests/test_battery_curve_keys.py @@ -39,7 +39,7 @@ def test_get_curve_value_with_int_keys(my_predbat): assert get_curve_value(curve, 98) == 0.23 assert get_curve_value(curve, 97) == 0.3 assert get_curve_value(curve, 96) == 1.0 # default value - print("✓ Integer keys test passed") + print("PASS: Integer keys test passed") def test_get_curve_value_with_string_keys(my_predbat): @@ -52,7 +52,7 @@ def test_get_curve_value_with_string_keys(my_predbat): assert get_curve_value(curve, 98) == 0.23 assert get_curve_value(curve, 97) == 0.3 assert get_curve_value(curve, 96) == 1.0 # default value - print("✓ String keys test passed") + print("PASS: String keys test passed") def test_get_curve_value_with_mixed_keys(my_predbat): @@ -63,7 +63,7 @@ def test_get_curve_value_with_mixed_keys(my_predbat): assert get_curve_value(curve, 100) == 0.15 # int key found assert get_curve_value(curve, 99) == 0.20 # string key found - print("✓ Mixed keys test passed") + print("PASS: Mixed keys test passed") def test_get_curve_value_custom_default(my_predbat): @@ -74,7 +74,7 @@ def test_get_curve_value_custom_default(my_predbat): assert get_curve_value(curve, 100) == 0.15 assert get_curve_value(curve, 99, default=0.5) == 0.5 assert get_curve_value(curve, 98, default=0.0) == 0.0 - print("✓ Custom default test passed") + print("PASS: Custom default test passed") def test_validate_config_auto_curve(my_predbat): @@ -91,7 +91,7 @@ def test_validate_config_auto_curve(my_predbat): my_predbat.args["battery_discharge_power_curve"] = "auto" errors_with_auto = my_predbat.validate_config() assert errors_with_auto == baseline_errors, f"Setting curves to 'auto' introduced extra validation errors: baseline={baseline_errors}, with_auto={errors_with_auto}" - print("✓ Auto curve validation test passed") + print("PASS: Auto curve validation test passed") finally: my_predbat.args = original_args diff --git a/apps/predbat/tests/test_calculate_yesterday.py b/apps/predbat/tests/test_calculate_yesterday.py index ac74a0da5..179910aa2 100644 --- a/apps/predbat/tests/test_calculate_yesterday.py +++ b/apps/predbat/tests/test_calculate_yesterday.py @@ -345,7 +345,7 @@ def _test_car_slot_subtraction(my_predbat, failed): in_car_slot returns 1.2 kW = 0.1 kWh per 5-min step). The step_data_history mock returns FLAT_LOAD_KWH (0.1 kWh) for every step. - After the fix (kW → kWh/step conversion): + After the fix (kW -> kWh/step conversion): - Inside-slot steps: max(0.1 - 1.2 * 5/60, 0) = max(0.0, 0) = 0.0 - Outside-slot steps: unchanged at FLAT_LOAD_KWH @@ -360,7 +360,7 @@ def _test_car_slot_subtraction(my_predbat, failed): # calculate_yesterday will NOT re-run load_octopus_slots; it will use # whatever we put in car_charging_slots directly. my_predbat.num_cars = 1 - # Slot: minute 60..180, 2.4 kWh total over 2 hours → 1.2 kW = 0.1 kWh/step + # Slot: minute 60..180, 2.4 kWh total over 2 hours -> 1.2 kW = 0.1 kWh/step car_slot = {"start": 60, "end": 180, "kwh": 2.4, "average": 20} my_predbat.car_charging_slots[0] = [car_slot] car_charging_slots_before = copy.deepcopy(my_predbat.car_charging_slots) @@ -371,7 +371,7 @@ def _test_car_slot_subtraction(my_predbat, failed): my_predbat.car_charging_limit = [80.0, 100.0, 100.0, 100.0] car_charging_soc_before = list(my_predbat.car_charging_soc) - # octopus_intelligent_slot not configured → entity_id_list is empty + # octopus_intelligent_slot not configured -> entity_id_list is empty # so the re-load loop in calculate_yesterday is skipped my_predbat.args["octopus_intelligent_slot"] = None @@ -445,7 +445,7 @@ def _test_car_slot_from_energy_sensor(my_predbat, failed): synthesise a slot for that window and subtract the corresponding kWh from the load step data before handing it to the Prediction. - Slot: start=60, end=90, kwh=1.5 → load rate = 1.5/0.5h = 3.0 kW + Slot: start=60, end=90, kwh=1.5 -> load rate = 1.5/0.5h = 3.0 kW Subtraction per 5-min step: 3.0 * 5/60 = 0.25 kWh > FLAT_LOAD_KWH (0.1) Expected inside-slot value: max(0.1 - 0.25, 0) = 0.0 @@ -466,7 +466,7 @@ def _test_car_slot_from_energy_sensor(my_predbat, failed): my_predbat.car_charging_limit = [80.0, 100.0, 100.0, 100.0] car_charging_soc_before = list(my_predbat.car_charging_soc) - # No octopus slot configured → entity_id_list is empty, octopus path skipped. + # No octopus slot configured -> entity_id_list is empty, octopus path skipped. my_predbat.args["octopus_intelligent_slot"] = None my_predbat.octopus_slots = [[], [], [], []] my_predbat.octopus_intelligent_consider_full = False @@ -621,7 +621,7 @@ def _test_reconstruct_car_slots(my_predbat, failed): failed = True # With car_energy_reported_load=True, the slot kwh is capped to load_reported # * car_charging_loss. load_reported = plan_iv/PREDICT_STEP * FLAT_LOAD_KWH - # = 6 * 0.1 = 0.6 kWh; car_charging_loss=1.0 → expected kwh = 0.6. + # = 6 * 0.1 = 0.6 kWh; car_charging_loss=1.0 -> expected kwh = 0.6. load_steps_in_slot = plan_iv // PREDICT_STEP expected_slot_kwh = load_steps_in_slot * FLAT_LOAD_KWH * my_predbat.car_charging_loss if abs(slot.get("kwh", -1) - expected_slot_kwh) > 1e-9: @@ -632,7 +632,7 @@ def _test_reconstruct_car_slots(my_predbat, failed): failed = True # Steps inside the slot [60, 90) should be zeroed out. - # slot kwh adjusted to 0.6 → rate = 0.6/0.5h = 1.2 kW; 1.2*5/60 = 0.1 kWh/step = FLAT_LOAD_KWH → max(0.1-0.1,0)=0.0 + # slot kwh adjusted to 0.6 -> rate = 0.6/0.5h = 1.2 kW; 1.2*5/60 = 0.1 kWh/step = FLAT_LOAD_KWH -> max(0.1-0.1,0)=0.0 slot_end = 60 + my_predbat.plan_interval_minutes for inside_min in range(60, slot_end, PREDICT_STEP): val = yesterday_load_step.get(inside_min, -1) @@ -739,9 +739,9 @@ def _mock_load_octopus_slots(car_n, raw_slots, consider_full): my_predbat.car_charging_energy = {} plan_iv = my_predbat.plan_interval_minutes # 30 - # A slot claiming 3.0 kWh → kwh_drain = 3.0 kWh; needs load ≥ 0.3 kWh to survive. - # Provide only 0.01 kWh per step → load_reported = 6 * 0.01 = 0.06 kWh. - # 0.06 * 10 = 0.6 < 3.0 → slot should be cancelled (kwh set to 0). + # A slot claiming 3.0 kWh -> kwh_drain = 3.0 kWh; needs load ≥ 0.3 kWh to survive. + # Provide only 0.01 kWh per step -> load_reported = 6 * 0.01 = 0.06 kWh. + # 0.06 * 10 = 0.6 < 3.0 -> slot should be cancelled (kwh set to 0). TINY_LOAD = 0.01 cancelled_slot = {"start": 60, "end": 60 + plan_iv, "kwh": 3.0, "octopus": True} my_predbat.car_charging_slots = [[cancelled_slot], [], [], []] @@ -754,7 +754,7 @@ def _mock_load_octopus_slots(car_n, raw_slots, consider_full): print("ERROR 5d: slot kwh should be 0 after cancellation, got {}".format(cancelled_slot.get("kwh"))) failed = True - # Because kwh=0, subtract_amount=0 → load values should be completely unchanged. + # Because kwh=0, subtract_amount=0 -> load values should be completely unchanged. for m in range(60, 60 + plan_iv, PREDICT_STEP): val = yesterday_load_step_5d.get(m, -1) if abs(val - TINY_LOAD) > 1e-9: @@ -776,8 +776,8 @@ def _mock_load_octopus_slots(car_n, raw_slots, consider_full): my_predbat.car_charging_energy = {} plan_iv = my_predbat.plan_interval_minutes # 30 - # Slot claims 2.4 kWh → kwh_drain = 2.4; load = 6 * 0.2 = 1.2 kWh. - # 1.2 * 10 = 12.0 ≥ 2.4 → adjusted: slot["kwh"] = 1.2 * 1.0 = 1.2 kWh. + # Slot claims 2.4 kWh -> kwh_drain = 2.4; load = 6 * 0.2 = 1.2 kWh. + # 1.2 * 10 = 12.0 ≥ 2.4 -> adjusted: slot["kwh"] = 1.2 * 1.0 = 1.2 kWh. # Subtraction: 1.2 kWh / 0.5 h = 2.4 kW; 2.4 * 5/60 = 0.2 kWh/step. # After subtraction: max(0.2 - 0.2, 0) = 0.0 inside slot. MEDIUM_LOAD = 0.2 @@ -820,13 +820,13 @@ def _mock_load_octopus_slots(car_n, raw_slots, consider_full): my_predbat.args["octopus_intelligent_slot"] = None my_predbat.car_charging_energy = {} - plan_iv = my_predbat.plan_interval_minutes # 30 minutes → 6 steps + plan_iv = my_predbat.plan_interval_minutes # 30 minutes -> 6 steps # Load: 0.2 kWh/step × 6 steps = 1.2 kWh total in the window. - # Car 0: kwh=0.6, kwh_drain=0.6 → load 1.2 ≥ 0.6 → no scaling. - # subtract: 0.6/0.5h × 5/60 = 0.1 kWh/step → residual = 0.1 kWh/step. - # Car 1: kwh=0.9, kwh_drain=0.9 → load 6×0.1=0.6 < 0.9. - # 0.6×10=6.0 ≥ 0.9 → adjusted to 0.6 kWh. - # subtract: 0.6/0.5h × 5/60 = 0.1 kWh/step → residual = 0.0. + # Car 0: kwh=0.6, kwh_drain=0.6 -> load 1.2 ≥ 0.6 -> no scaling. + # subtract: 0.6/0.5h × 5/60 = 0.1 kWh/step -> residual = 0.1 kWh/step. + # Car 1: kwh=0.9, kwh_drain=0.9 -> load 6×0.1=0.6 < 0.9. + # 0.6×10=6.0 ≥ 0.9 -> adjusted to 0.6 kWh. + # subtract: 0.6/0.5h × 5/60 = 0.1 kWh/step -> residual = 0.0. TWO_CAR_LOAD = 0.2 slot_car0 = {"start": 60, "end": 60 + plan_iv, "kwh": 0.6, "octopus": True} slot_car1 = {"start": 60, "end": 60 + plan_iv, "kwh": 0.9, "octopus": True} @@ -846,7 +846,7 @@ def _mock_load_octopus_slots(car_n, raw_slots, consider_full): print("ERROR 5f: car 1 slot kwh should be {} after scaling, got {}".format(expected_car1_kwh, slot_car1.get("kwh"))) failed = True - # Both cars have subtracted their full amounts → inside-slot load should be 0. + # Both cars have subtracted their full amounts -> inside-slot load should be 0. for m in range(60, 60 + plan_iv, PREDICT_STEP): val = yesterday_load_step_5f.get(m, -1) if abs(val) > 1e-9: @@ -886,7 +886,7 @@ def _test_soc_not_mutated_and_override_passed(my_predbat, failed): # soc_yesterday is read from the HA entity prefix+".savings_total_soc". # No entity is registered in the test dummy store, so get_state_wrapper - # returns the default 0.0 → soc_yesterday == 0.0. + # returns the default 0.0 -> soc_yesterday == 0.0. expected_soc_yesterday = 0.0 # Capture (base_soc_kw, prediction_soc_kw, prediction_soc_max) for each diff --git a/apps/predbat/tests/test_carbon.py b/apps/predbat/tests/test_carbon.py index b93610011..c383fb126 100644 --- a/apps/predbat/tests/test_carbon.py +++ b/apps/predbat/tests/test_carbon.py @@ -123,13 +123,13 @@ def test_carbon(my_predbat=None): try: test_result = test_func(my_predbat) if test_result: - print(f"✗ FAILED: {test_name}") + print(f"FAIL: FAILED: {test_name}") failed += 1 else: - print(f"✓ PASSED: {test_name}") + print(f"PASS: PASSED: {test_name}") passed += 1 except Exception as e: - print(f"✗ EXCEPTION in {test_name}: {e}") + print(f"FAIL: EXCEPTION in {test_name}: {e}") import traceback traceback.print_exc() @@ -156,23 +156,23 @@ def _test_carbon_initialization(my_predbat=None): api.automatic = True if api.postcode != "SW1A 1AA": - print(" ✗ ERROR: Postcode not set correctly") + print(" FAIL: ERROR: Postcode not set correctly") failed = 1 if api.automatic != True: - print(" ✗ ERROR: Automatic flag not set correctly") + print(" FAIL: ERROR: Automatic flag not set correctly") failed = 1 if api.failures_total != 0: - print(" ✗ ERROR: failures_total should be 0") + print(" FAIL: ERROR: failures_total should be 0") failed = 1 if api.carbon_data_points != []: - print(" ✗ ERROR: carbon_data_points should be empty list") + print(" FAIL: ERROR: carbon_data_points should be empty list") failed = 1 if not failed: - print(" ✓ CarbonAPI initialised correctly") + print(" PASS: CarbonAPI initialised correctly") return failed @@ -211,7 +211,7 @@ def _test_fetch_carbon_data_success(my_predbat=None): print(f"ERROR: Expected intensity 265, got {first_point['intensity']}") return 1 - print(" ✓ Carbon data fetched successfully") + print(" PASS: Carbon data fetched successfully") return failed @@ -240,7 +240,7 @@ def _test_fetch_carbon_data_http_error(my_predbat=None): print(f"ERROR: Should not have collected data on HTTP error") return 1 - print(" ✓ HTTP error handled correctly") + print(" PASS: HTTP error handled correctly") return 0 @@ -267,7 +267,7 @@ def _test_fetch_carbon_data_timeout(my_predbat=None): print(f"ERROR: Should not have collected data on timeout") return 1 - print(" ✓ Timeout handled correctly") + print(" PASS: Timeout handled correctly") return 0 @@ -296,7 +296,7 @@ def _test_fetch_carbon_data_json_error(my_predbat=None): print(f"ERROR: Should not have collected data on JSON error") return 1 - print(" ✓ JSON error handled correctly") + print(" PASS: JSON error handled correctly") return 0 @@ -326,7 +326,7 @@ def _test_fetch_carbon_data_empty(my_predbat=None): print("ERROR: Should log warning about empty data") return 1 - print(" ✓ Empty data handled correctly") + print(" PASS: Empty data handled correctly") return 0 @@ -348,7 +348,7 @@ def _test_fetch_carbon_data_cache_skip(my_predbat=None): print(f"ERROR: Expected 0 API calls (cache), got {mock_session_class.call_count}") return 1 - print(" ✓ Cache skip working correctly") + print(" PASS: Cache skip working correctly") return 0 @@ -379,7 +379,7 @@ def _test_fetch_carbon_data_cache_refresh(my_predbat=None): print("ERROR: Should have collected data after cache refresh") return 1 - print(" ✓ Cache refresh working correctly") + print(" PASS: Cache refresh working correctly") return 0 @@ -434,7 +434,7 @@ def _test_publish_carbon_data_current(my_predbat=None): print("ERROR: Forecast attribute doesn't match data points") return 1 - print(" ✓ Current intensity published correctly") + print(" PASS: Current intensity published correctly") return 0 @@ -484,7 +484,7 @@ def _test_publish_carbon_data_forecast(my_predbat=None): print(f"ERROR: Expected intensity=265, got {first_item['intensity']}") return 1 - print(" ✓ Forecast attribute published correctly") + print(" PASS: Forecast attribute published correctly") return 0 @@ -514,7 +514,7 @@ def _test_publish_carbon_data_unknown(my_predbat=None): print(f"ERROR: Expected state 'unknown', got '{item['state']}'") return 1 - print(" ✓ Unknown state handled correctly") + print(" PASS: Unknown state handled correctly") return 0 @@ -548,7 +548,7 @@ def _test_postcode_stripping(my_predbat=None): print("ERROR: Stripped postcode 'BS16' not found in URL") return 1 - print(" ✓ Postcode stripping working correctly") + print(" PASS: Postcode stripping working correctly") return 0 @@ -591,7 +591,7 @@ def _test_multiple_date_fetches(my_predbat=None): print(f"ERROR: Second call should contain date 2025-12-22") return 1 - print(" ✓ Multiple date fetches working correctly") + print(" PASS: Multiple date fetches working correctly") return 0 @@ -626,7 +626,7 @@ def _test_time_format_conversion(my_predbat=None): print(f"ERROR: Expected to to start with '2025-12-20T14:30:00', got '{point['to']}'") return 1 - print(" ✓ Time format conversion working correctly") + print(" PASS: Time format conversion working correctly") return 0 @@ -668,7 +668,7 @@ def _test_timezone_handling(my_predbat=None): print(f"ERROR: Failed to parse stored time: {e}") return 1 - print(" ✓ Timezone handling working correctly") + print(" PASS: Timezone handling working correctly") return 0 @@ -733,7 +733,7 @@ async def session_aexit(*args): print(f"ERROR: Missing expected intensities. Got: {intensities}") return 1 - print(" ✓ Data collection from multiple dates working correctly") + print(" PASS: Data collection from multiple dates working correctly") return 0 @@ -769,7 +769,7 @@ def _test_failure_counter(my_predbat=None): print(f"ERROR: Expected failures_total=4 after timeout, got {failures_after_timeout}") return 1 - print(" ✓ Failure counter working correctly") + print(" PASS: Failure counter working correctly") return 0 @@ -802,7 +802,7 @@ def _test_run_first_call(my_predbat=None): print(f"ERROR: Expected 2 API calls on first run, got {mock_session.get.call_count}") return 1 - print(" ✓ First run calling fetch correctly") + print(" PASS: First run calling fetch correctly") return 0 @@ -834,7 +834,7 @@ def _test_run_15min_interval(my_predbat=None): print(f"ERROR: Expected 0 API calls at non-15-min interval, got {mock_session.get.call_count}") return 1 - print(" ✓ 15-minute interval working correctly") + print(" PASS: 15-minute interval working correctly") return 0 @@ -878,5 +878,5 @@ def _test_automatic_config_flow(my_predbat=None): print("ERROR: carbon_intensity config should not be set when automatic=False") return 1 - print(" ✓ Automatic config flow working correctly") + print(" PASS: Automatic config flow working correctly") return 0 diff --git a/apps/predbat/tests/test_compare.py b/apps/predbat/tests/test_compare.py index 574fa588a..4b075c0c2 100644 --- a/apps/predbat/tests/test_compare.py +++ b/apps/predbat/tests/test_compare.py @@ -296,7 +296,7 @@ def _mock_fetch_config_options(): # Do NOT restore – replicate the old buggy code # Now run a second tariff with no config – value should still be 2.0 (bleed) - cmp.fetch_config({}) # empty config → no changes + cmp.fetch_config({}) # empty config -> no changes if pb.config_index["best_soc_min"]["value"] == 0.5: print("ERROR T10: expected to detect config bleed when restore is skipped") diff --git a/apps/predbat/tests/test_component_base.py b/apps/predbat/tests/test_component_base.py index db825eb45..8caf4754c 100644 --- a/apps/predbat/tests/test_component_base.py +++ b/apps/predbat/tests/test_component_base.py @@ -92,8 +92,8 @@ async def run_test(): # Start component in background task = asyncio.create_task(component.start()) - # Wait briefly for it to start (1.0 → 0.01s real via fast_sleep; must be - # shorter than the component's 5s loop sleep → 0.05s real) + # Wait briefly for it to start (1.0 -> 0.01s real via fast_sleep; must be + # shorter than the component's 5s loop sleep -> 0.05s real) await asyncio.sleep(1.0) # Check it started successfully @@ -128,7 +128,7 @@ async def run_test(): assert not component.api_started, "Component should not have started yet (failed first attempt)" # Wait slightly longer - should still be 1 run (waiting for backoff) - # Backoff = 60s real → 0.6s real via fast_sleep; we wait 0.5 → 0.005s real + # Backoff = 60s real -> 0.6s real via fast_sleep; we wait 0.5 -> 0.005s real await asyncio.sleep(0.5) assert component.run_count == 1, f"Should still be 1 run (waiting for backoff), got {component.run_count}" @@ -189,7 +189,7 @@ async def run_test(): initial_run_count = component.run_count # Wait a bit more - should not run again immediately (only every 60 seconds) - # Component loop sleep = 5s → 0.05s real; we wait 0.5 → 0.005s real + # Component loop sleep = 5s -> 0.05s real; we wait 0.5 -> 0.005s real await asyncio.sleep(0.5) assert component.run_count == initial_run_count, f"Should not run again immediately, expected {initial_run_count}, got {component.run_count}" @@ -333,7 +333,7 @@ async def run_test(): task = asyncio.create_task(component.start()) - # Wait long enough (sped up 100x by fast_sleep → ~2s real) for the component + # Wait long enough (sped up 100x by fast_sleep -> ~2s real) for the component # loop to advance past simulated seconds=60 so a steady-state run can occur. await asyncio.sleep(200) diff --git a/apps/predbat/tests/test_db_manager.py b/apps/predbat/tests/test_db_manager.py index fe56ae7c6..a858991c9 100644 --- a/apps/predbat/tests/test_db_manager.py +++ b/apps/predbat/tests/test_db_manager.py @@ -104,13 +104,13 @@ def test_db_manager(my_predbat=None): try: result = test_func(my_predbat) if result: - print(f"✗ FAILED: {key}") + print(f"FAIL: FAILED: {key}") failed += 1 else: - print(f"✓ PASSED: {key}") + print(f"PASS: PASSED: {key}") passed += 1 except Exception as e: - print(f"✗ EXCEPTION in {key}: {e}") + print(f"FAIL: EXCEPTION in {key}: {e}") import traceback traceback.print_exc() @@ -149,7 +149,7 @@ async def run_test(): timeout += 1 assert db_mgr.api_started, "DatabaseManager failed to start" - print("✓ DatabaseManager started successfully") + print("PASS: DatabaseManager started successfully") # Test 1: Set state with attributes entity_id = "sensor.test_battery" @@ -164,7 +164,7 @@ async def run_test(): assert "last_changed" in result, "set_state_db should return last_changed" assert result["state"] == state, "Returned state doesn't match" assert result["attributes"] == attributes, "Returned attributes don't match" - print(f"✓ set_state_db returned correct structure: {result}") + print(f"PASS: set_state_db returned correct structure: {result}") # Test 2: Get state back and verify retrieved = await loop.run_in_executor(None, db_mgr.get_state_db, entity_id) @@ -174,7 +174,7 @@ async def run_test(): assert retrieved["attributes"]["unit_of_measurement"] == "kWh", "Attribute unit_of_measurement mismatch" assert retrieved["attributes"]["friendly_name"] == "Test Battery", "Attribute friendly_name mismatch" assert retrieved["attributes"]["device_class"] == "energy", "Attribute device_class mismatch" - print(f"✓ get_state_db retrieved correct state and attributes: {retrieved}") + print(f"PASS: get_state_db retrieved correct state and attributes: {retrieved}") # Test 3: Set state with custom timestamp (test timezone conversion) # GMT+2 10:00 should be stored as GMT+0 08:00 in the database @@ -198,14 +198,14 @@ async def run_test(): expected_gmt0_time = datetime(2025, 12, 25, 8, 0, 0) # GMT+2 10:00 -> GMT+0 08:00 assert parsed_ts.hour == expected_gmt0_time.hour, f"Timezone conversion failed: expected hour {expected_gmt0_time.hour} (GMT+0), got {parsed_ts.hour}" assert parsed_ts.day == expected_gmt0_time.day, f"Timezone conversion failed: expected day {expected_gmt0_time.day}, got {parsed_ts.day}" - print(f"✓ Timezone conversion verified: GMT+2 10:00 -> GMT+0 {parsed_ts.strftime('%H:%M:%S')}") + print(f"PASS: Timezone conversion verified: GMT+2 10:00 -> GMT+0 {parsed_ts.strftime('%H:%M:%S')}") # Test 4: Update existing entity with new state state3 = "50.0" await loop.run_in_executor(None, db_mgr.set_state_db, entity_id, state3, attributes) retrieved3 = await loop.run_in_executor(None, db_mgr.get_state_db, entity_id) assert retrieved3["state"] == state3, f"Expected updated state {state3}, got {retrieved3['state']}" - print(f"✓ State update works correctly: {retrieved3['state']}") + print(f"PASS: State update works correctly: {retrieved3['state']}") # Stop the component and verify thread exits await db_mgr.stop() @@ -217,15 +217,15 @@ async def run_test(): timeout += 1 assert not db_mgr.api_started, "DatabaseManager thread did not exit after stop command" - print("✓ DatabaseManager thread exited after stop command") + print("PASS: DatabaseManager thread exited after stop command") await task - print("✓ DatabaseManager stopped successfully") + print("PASS: DatabaseManager stopped successfully") finally: # Cleanup temp directory shutil.rmtree(mock_base.config_root) - print(f"✓ Cleaned up temp directory: {mock_base.config_root}") + print(f"PASS: Cleaned up temp directory: {mock_base.config_root}") run_async(run_test()) print("=== test_db_manager_set_get_state PASSED ===\n") @@ -256,7 +256,7 @@ async def run_test(): timeout += 1 assert db_mgr.api_started, "DatabaseManager failed to start" - print("✓ DatabaseManager started successfully") + print("PASS: DatabaseManager started successfully") # Test 1: Create multiple entities entities_to_create = [ @@ -279,7 +279,7 @@ async def run_test(): for entity_id, _, _ in entities_to_create: assert entity_id in all_entities, f"Entity {entity_id} not in returned list" - print(f"✓ get_all_entities_db returned correct entities: {all_entities}") + print(f"PASS: get_all_entities_db returned correct entities: {all_entities}") # Test 3: Create historical data for one entity history_entity = "sensor.battery_soc" @@ -308,7 +308,7 @@ async def run_test(): else: history_data = history - print(f"✓ get_history_db returned {len(history_data)} history entries") + print(f"PASS: get_history_db returned {len(history_data)} history entries") # Verify history entries have correct structure for entry in history_data[:3]: # Check first 3 entries @@ -320,7 +320,7 @@ async def run_test(): history_states = [entry.get("state") for entry in history_data] found_count = sum(1 for hs in historical_states if hs["state"] in history_states) assert found_count > 0, f"None of the historical states found in returned history" - print(f"✓ Found {found_count} of {len(historical_states)} expected historical states") + print(f"PASS: Found {found_count} of {len(historical_states)} expected historical states") # Test 5: Verify timestamp format for entry in history_data[:3]: @@ -331,7 +331,7 @@ async def run_test(): # Remove 'Z' suffix if present ts = timestamp_str.rstrip("Z") parsed = datetime.strptime(ts, TIME_FORMAT_DB) - print(f"✓ Timestamp format correct: {timestamp_str}") + print(f"PASS: Timestamp format correct: {timestamp_str}") break except ValueError as e: print(f"Warning: Timestamp format issue: {timestamp_str} - {e}") @@ -346,16 +346,16 @@ async def run_test(): timeout += 1 assert not db_mgr.api_started, "DatabaseManager thread did not exit after stop command" - print("✓ DatabaseManager thread exited after stop command") + print("PASS: DatabaseManager thread exited after stop command") await task - print("✓ DatabaseManager stopped successfully") + print("PASS: DatabaseManager stopped successfully") finally: # Cleanup shutil.rmtree(mock_base.config_root) - print(f"✓ Cleaned up temp directory: {mock_base.config_root}") + print(f"PASS: Cleaned up temp directory: {mock_base.config_root}") run_async(run_test()) print("=== test_db_manager_entities_and_history PASSED ===\n") @@ -386,14 +386,14 @@ async def run_test(): timeout += 1 assert db_mgr.api_started, "DatabaseManager failed to start" - print("✓ DatabaseManager started successfully") + print("PASS: DatabaseManager started successfully") # Test 1: Get state for non-existent entity loop = asyncio.get_event_loop() non_existent = "sensor.does_not_exist" result = await loop.run_in_executor(None, db_mgr.get_state_db, non_existent) assert result is None, f"Expected None for non-existent entity, got {result}" - print(f"✓ get_state_db correctly returned None for non-existent entity") + print(f"PASS: get_state_db correctly returned None for non-existent entity") # Test 2: Get history for non-existent entity (run in executor) now = datetime(2025, 12, 25, 12, 0, 0, tzinfo=mock_base.local_tz) @@ -401,7 +401,7 @@ async def run_test(): # History should return empty list or [[]] if history: assert len(history) == 0 or (len(history) == 1 and len(history[0]) == 0), f"Expected empty history for non-existent entity, got {history}" - print(f"✓ get_history_db correctly returned empty for non-existent entity") + print(f"PASS: get_history_db correctly returned empty for non-existent entity") # Test 3: Verify IPC queue is still working after errors # Set a valid entity to confirm queue isn't broken @@ -413,13 +413,13 @@ async def run_test(): retrieved = await loop.run_in_executor(None, db_mgr.get_state_db, entity_id) assert retrieved is not None, "IPC queue broken after error handling" assert retrieved["state"] == state, "State mismatch after error handling" - print(f"✓ IPC queue still functional after error handling") + print(f"PASS: IPC queue still functional after error handling") # Test 4: Get all entities should work even if empty initially all_entities = await loop.run_in_executor(None, db_mgr.get_all_entities_db) assert isinstance(all_entities, list), "get_all_entities_db should always return a list" assert entity_id in all_entities, "Entity should be in entities list" - print(f"✓ get_all_entities_db returned valid list: {len(all_entities)} entities") + print(f"PASS: get_all_entities_db returned valid list: {len(all_entities)} entities") # Stop the component and verify thread exits # Send stop command via IPC to cover the queue processing path @@ -433,16 +433,16 @@ async def run_test(): timeout += 1 assert not db_mgr.api_started, "DatabaseManager thread did not exit after stop command" - print("✓ DatabaseManager thread exited after stop command") + print("PASS: DatabaseManager thread exited after stop command") await task - print("✓ DatabaseManager stopped successfully") + print("PASS: DatabaseManager stopped successfully") finally: # Cleanup shutil.rmtree(mock_base.config_root) - print(f"✓ Cleaned up temp directory: {mock_base.config_root}") + print(f"PASS: Cleaned up temp directory: {mock_base.config_root}") run_async(run_test()) print("=== test_db_manager_error_handling PASSED ===\n") @@ -473,7 +473,7 @@ async def run_test(): timeout += 1 assert db_mgr1.api_started, "DatabaseManager failed to start" - print("✓ DatabaseManager started successfully (first instance)") + print("PASS: DatabaseManager started successfully (first instance)") # Store test data loop = asyncio.get_event_loop() @@ -489,7 +489,7 @@ async def run_test(): # Verify data is stored all_entities = await loop.run_in_executor(None, db_mgr1.get_all_entities_db) assert len(all_entities) >= 3, f"Expected at least 3 entities before restart, got {len(all_entities)}" - print(f"✓ Stored {len(all_entities)} entities: {all_entities}") + print(f"PASS: Stored {len(all_entities)} entities: {all_entities}") # Stop the first instance and verify thread exits await db_mgr1.stop() @@ -503,7 +503,7 @@ async def run_test(): assert not db_mgr1.api_started, "DatabaseManager thread did not exit after stop command" await task1 - print("✓ DatabaseManager stopped and thread exited (first instance)") + print("PASS: DatabaseManager stopped and thread exited (first instance)") # Wait a bit to ensure everything is closed await asyncio.sleep(0.1) @@ -525,7 +525,7 @@ async def run_test(): timeout += 1 assert db_mgr2.api_started, "DatabaseManager failed to start (second instance)" - print("✓ DatabaseManager restarted successfully (second instance)") + print("PASS: DatabaseManager restarted successfully (second instance)") # Verify all entities are still present all_entities_after = await loop.run_in_executor(None, db_mgr2.get_all_entities_db) @@ -534,7 +534,7 @@ async def run_test(): for entity_id, _, _ in test_data: assert entity_id in all_entities_after, f"Entity {entity_id} not found after restart" - print(f"✓ All {len(all_entities_after)} entities persisted after restart") + print(f"PASS: All {len(all_entities_after)} entities persisted after restart") # Verify state and attributes are preserved for entity_id, expected_state, expected_attributes in test_data: @@ -547,7 +547,7 @@ async def run_test(): assert attr_key in retrieved["attributes"], f"Attribute {attr_key} missing for {entity_id} after restart" assert retrieved["attributes"][attr_key] == attr_value, f"Attribute {attr_key} mismatch for {entity_id}: expected {attr_value}, got {retrieved['attributes'][attr_key]}" - print(f"✓ Entity {entity_id}: state={retrieved['state']}, attributes preserved") + print(f"PASS: Entity {entity_id}: state={retrieved['state']}, attributes preserved") # Stop the second instance and verify thread exits await db_mgr2.stop() @@ -560,12 +560,12 @@ async def run_test(): assert not db_mgr2.api_started, "DatabaseManager thread did not exit after stop command" await task2 - print("✓ DatabaseManager stopped and thread exited (second instance)") + print("PASS: DatabaseManager stopped and thread exited (second instance)") finally: # Cleanup shutil.rmtree(mock_base.config_root) - print(f"✓ Cleaned up temp directory: {mock_base.config_root}") + print(f"PASS: Cleaned up temp directory: {mock_base.config_root}") run_async(run_test()) print("=== test_db_manager_persistence PASSED ===\n") @@ -596,7 +596,7 @@ async def run_test(): timeout += 1 assert db_mgr.api_started, "DatabaseManager failed to start" - print("✓ DatabaseManager started successfully") + print("PASS: DatabaseManager started successfully") loop = asyncio.get_event_loop() @@ -609,7 +609,7 @@ async def run_test(): first_commit_time = db_mgr.last_commit_time assert first_commit_time is not None, "First commit should have happened (last_commit_time should be set)" - print(f"✓ First commit happened at {first_commit_time}") + print(f"PASS: First commit happened at {first_commit_time}") # Test 2: Rapid writes within 5 seconds should NOT trigger additional commits await asyncio.sleep(0.1) @@ -622,7 +622,7 @@ async def run_test(): second_check_time = db_mgr.last_commit_time assert second_check_time == first_commit_time, "No commit should happen within 5 seconds of last commit" - print(f"✓ Rapid writes within 5 seconds did not trigger new commits (last_commit_time unchanged)") + print(f"PASS: Rapid writes within 5 seconds did not trigger new commits (last_commit_time unchanged)") # Test 3: Push back commit time to 6 seconds ago, then write - should trigger a new commit print(" Pushing last_commit_time back to 6 seconds ago to test throttle expiry...") @@ -638,7 +638,7 @@ async def run_test(): assert third_commit_time > first_commit_time, "New commit time should be later than first commit" time_diff = (third_commit_time - pushed_back_time).total_seconds() assert time_diff >= 5.0, f"Time difference should show commit happened after 5+ second gap, got {time_diff}" - print(f"✓ After pushing time back 6s, new commit triggered at {third_commit_time}") + print(f"PASS: After pushing time back 6s, new commit triggered at {third_commit_time}") # Test 4: Verify all data was persisted despite throttling retrieved1 = await loop.run_in_executor(None, db_mgr.get_state_db, "sensor.test1") @@ -652,7 +652,7 @@ async def run_test(): assert retrieved3 is not None and retrieved3["state"] == "30", "sensor.test3 data should be persisted" assert retrieved4 is not None and retrieved4["state"] == "40", "sensor.test4 data should be persisted" assert retrieved5 is not None and retrieved5["state"] == "50", "sensor.test5 data should be persisted" - print("✓ All data persisted correctly despite commit throttling") + print("PASS: All data persisted correctly despite commit throttling") # Stop and cleanup await db_mgr.stop() @@ -662,12 +662,12 @@ async def run_test(): timeout += 1 await task - print("✓ DatabaseManager stopped successfully") + print("PASS: DatabaseManager stopped successfully") finally: # Cleanup temp directory shutil.rmtree(mock_base.config_root) - print(f"✓ Cleaned up temp directory: {mock_base.config_root}") + print(f"PASS: Cleaned up temp directory: {mock_base.config_root}") run_async(run_test()) print("=== test_db_manager_commit_throttling PASSED ===\n") diff --git a/apps/predbat/tests/test_download.py b/apps/predbat/tests/test_download.py index fe05a2730..01b97c912 100644 --- a/apps/predbat/tests/test_download.py +++ b/apps/predbat/tests/test_download.py @@ -95,13 +95,13 @@ def test_download(my_predbat): try: test_result = test_func(my_predbat) if test_result: - print(f"✗ FAILED: {test_name}") + print(f"FAIL: FAILED: {test_name}") failed += 1 else: - print(f"✓ PASSED: {test_name}") + print(f"PASS: PASSED: {test_name}") passed += 1 except Exception as e: - print(f"✗ EXCEPTION in {test_name}: {e}") + print(f"FAIL: EXCEPTION in {test_name}: {e}") import traceback traceback.print_exc() @@ -258,8 +258,8 @@ def _test_compute_file_sha1(my_predbat): Test Git blob SHA1 hash computation (matches GitHub's SHA) """ # Create a temporary file with known content - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write("test content\n") + with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: + f.write(b"test content\n") temp_path = f.name try: diff --git a/apps/predbat/tests/test_fetch_config_options.py b/apps/predbat/tests/test_fetch_config_options.py index 52eb59e9f..096514c63 100644 --- a/apps/predbat/tests/test_fetch_config_options.py +++ b/apps/predbat/tests/test_fetch_config_options.py @@ -153,7 +153,7 @@ def mock_expose_config(key, value): assert my_predbat.iboost_max_power == 0.05, "iboost_max_power should be 0.05 (3000 / 60000)" assert abs(my_predbat.iboost_min_power - 0.00833) < 0.001, "iboost_min_power should be ~0.00833 (500 / 60000)" - print("✓ Basic configuration test passed") + print("PASS: Basic configuration test passed") # Test 2: Axle control enabled with active event print("\n*** Test 2: Axle control enabled with active event ***") @@ -170,7 +170,7 @@ def mock_expose_config(key, value): assert my_predbat.set_read_only == True, "set_read_only should be True when Axle event is active" assert my_predbat.set_read_only_axle == True, "set_read_only_axle should be True when Axle event is active" - print("✓ Axle control with active event test passed") + print("PASS: Axle control with active event test passed") # Test 3: Axle control enabled but no active event print("\n*** Test 3: Axle control enabled but no active event ***") @@ -184,7 +184,7 @@ def mock_expose_config(key, value): assert my_predbat.set_read_only == False, "set_read_only should be False when no Axle event is active" assert my_predbat.set_read_only_axle == False, "set_read_only_axle should be False when no Axle event is active" - print("✓ Axle control with no active event test passed") + print("PASS: Axle control with no active event test passed") # Test 4: Manual read-only mode (overrides Axle) print("\n*** Test 4: Manual read-only mode (overrides Axle) ***") @@ -200,7 +200,7 @@ def mock_expose_config(key, value): assert my_predbat.set_read_only == True, "set_read_only should be True from manual setting" assert my_predbat.set_read_only_axle == False, "set_read_only_axle should be False when manual read-only is set" - print("✓ Manual read-only mode test passed") + print("PASS: Manual read-only mode test passed") # Test 5: Monitor mode configuration print("\n*** Test 5: Monitor mode configuration ***") @@ -220,7 +220,7 @@ def mock_expose_config(key, value): assert my_predbat.set_export_window == False, "set_export_window should be False in Monitor mode" assert my_predbat.set_soc_enable == False, "set_soc_enable should be False in Monitor mode" - print("✓ Monitor mode configuration test passed") + print("PASS: Monitor mode configuration test passed") # Test 6: Control SOC only mode print("\n*** Test 6: Control SOC only mode ***") @@ -236,7 +236,7 @@ def mock_expose_config(key, value): assert my_predbat.set_export_window == False, "set_export_window should be False" assert my_predbat.set_soc_enable == True, "set_soc_enable should be True" - print("✓ Control SOC only mode test passed") + print("PASS: Control SOC only mode test passed") # Test 7: Control charge mode print("\n*** Test 7: Control charge mode ***") @@ -252,7 +252,7 @@ def mock_expose_config(key, value): assert my_predbat.set_export_window == False, "set_export_window should be False" assert my_predbat.set_soc_enable == True, "set_soc_enable should be True" - print("✓ Control charge mode test passed") + print("PASS: Control charge mode test passed") # Test 8: Control charge & discharge mode print("\n*** Test 8: Control charge & discharge mode ***") @@ -268,7 +268,7 @@ def mock_expose_config(key, value): assert my_predbat.set_export_window == True, "set_export_window should be True" assert my_predbat.set_soc_enable == True, "set_soc_enable should be True" - print("✓ Control charge & discharge mode test passed") + print("PASS: Control charge & discharge mode test passed") # Test 9: Invalid mode defaults to Monitor print("\n*** Test 9: Invalid mode defaults to Monitor ***") @@ -281,7 +281,7 @@ def mock_expose_config(key, value): assert my_predbat.calculate_best_charge == False, "calculate_best_charge should be False for invalid mode" assert my_predbat.set_soc_enable == False, "set_soc_enable should be False for invalid mode" - print("✓ Invalid mode defaults to Monitor test passed") + print("PASS: Invalid mode defaults to Monitor test passed") # Test 10: Holiday mode override print("\n*** Test 10: Holiday mode configuration ***") @@ -297,7 +297,7 @@ def mock_expose_config(key, value): assert my_predbat.days_previous == [1], "days_previous should be [1] in holiday mode" assert my_predbat.max_days_previous == 2, "max_days_previous should be 2 (1 + 1) in holiday mode" - print("✓ Holiday mode configuration test passed") + print("PASS: Holiday mode configuration test passed") # Test 11: Forecast calculations print("\n*** Test 11: Forecast calculations ***") @@ -312,7 +312,7 @@ def mock_expose_config(key, value): assert my_predbat.forecast_days == 2, "forecast_days should be 2 for 25 hours" assert my_predbat.forecast_minutes == 25 * 60, "forecast_minutes should be 1500" - print("✓ Forecast calculations test passed") + print("PASS: Forecast calculations test passed") # Test 12: LoadML should not modify the in-day adjustment config setting print("\n*** Test 12: LoadML does not change in-day adjustment config setting ***") @@ -327,7 +327,7 @@ def mock_expose_config(key, value): assert my_predbat.calculate_inday_adjustment is True, "calculate_inday_adjustment config should remain unchanged" assert ("calculate_inday_adjustment", False) not in exposed_config_calls, "calculate_inday_adjustment should not be force-updated in config" - print("✓ LoadML config-preservation test passed") + print("PASS: LoadML config-preservation test passed") # Restore original methods my_predbat.get_arg = original_get_arg diff --git a/apps/predbat/tests/test_fetch_pv_forecast.py b/apps/predbat/tests/test_fetch_pv_forecast.py index a257150df..f190116ab 100644 --- a/apps/predbat/tests/test_fetch_pv_forecast.py +++ b/apps/predbat/tests/test_fetch_pv_forecast.py @@ -111,11 +111,11 @@ def test_fetch_pv_forecast_with_relative_time(): # With the corrected formula (target = stored_minute - offset), a +120 min offset # (relative_time is 2 hours before midnight_utc) shifts data BACK by 120 minutes: - # forecast minute 0 (= relative_time + 0 = midnight - 2h) → output minute -120 - # forecast minute 60 (= midnight - 1h) → output minute -60 - # forecast minute 120 (= midnight exactly) → output minute 0 - # forecast minute 180 (= 1h after midnight) → output minute 60 - # forecast minute 240 (= 2h after midnight) → output minute 120 + # forecast minute 0 (= relative_time + 0 = midnight - 2h) -> output minute -120 + # forecast minute 60 (= midnight - 1h) -> output minute -60 + # forecast minute 120 (= midnight exactly) -> output minute 0 + # forecast minute 180 (= 1h after midnight) -> output minute 60 + # forecast minute 240 (= 2h after midnight) -> output minute 120 # Verify the offset is correctly applied assert pv_forecast_minute[0] == 1.0, f"Expected minute 0 to be 1.0, got {pv_forecast_minute.get(0)}" @@ -127,9 +127,9 @@ def test_fetch_pv_forecast_with_relative_time(): assert pv_forecast_minute10[60] == 1.2, f"Expected forecast10 minute 60 to be 1.2, got {pv_forecast_minute10.get(60)}" assert pv_forecast_minute10[120] == 1.6, f"Expected forecast10 minute 120 to be 1.6, got {pv_forecast_minute10.get(120)}" - print(f"✓ Forecast data correctly shifted back by 120 minutes (stored_minute - offset)") - print(f"✓ pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}, [120]={pv_forecast_minute[120]}") - print(f"✓ pv_forecast_minute10[0]={pv_forecast_minute10[0]}, [60]={pv_forecast_minute10[60]}, [120]={pv_forecast_minute10[120]}") + print(f"PASS: Forecast data correctly shifted back by 120 minutes (stored_minute - offset)") + print(f"PASS: pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}, [120]={pv_forecast_minute[120]}") + print(f"PASS: pv_forecast_minute10[0]={pv_forecast_minute10[0]}, [60]={pv_forecast_minute10[60]}, [120]={pv_forecast_minute10[120]}") print("Test 1 PASSED") @@ -185,8 +185,8 @@ def test_fetch_pv_forecast_no_relative_time(): assert pv_forecast_minute10[120] == 0.8, f"Expected forecast10 minute 120 to be 0.8, got {pv_forecast_minute10[120]}" assert pv_forecast_minute10[180] == 1.2, f"Expected forecast10 minute 180 to be 1.2, got {pv_forecast_minute10[180]}" - print(f"✓ Forecast data used with no offset (relative_time missing)") - print(f"✓ pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}, [120]={pv_forecast_minute[120]}") + print(f"PASS: Forecast data used with no offset (relative_time missing)") + print(f"PASS: pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}, [120]={pv_forecast_minute[120]}") print("Test 2 PASSED") @@ -234,8 +234,8 @@ def test_fetch_pv_forecast_invalid_relative_time(): assert pv_forecast_minute[60] == 0.5, f"Expected minute 60 to be 0.5, got {pv_forecast_minute[60]}" assert pv_forecast_minute[120] == 1.0, f"Expected minute 120 to be 1.0, got {pv_forecast_minute[120]}" - print(f"✓ Forecast data used with no offset (invalid relative_time)") - print(f"✓ pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}, [120]={pv_forecast_minute[120]}") + print(f"PASS: Forecast data used with no offset (invalid relative_time)") + print(f"PASS: pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}, [120]={pv_forecast_minute[120]}") print("Test 3 PASSED") @@ -287,8 +287,8 @@ def test_fetch_pv_forecast_relative_time_same_as_midnight(): assert pv_forecast_minute[120] == 1.0, f"Expected minute 120 to be 1.0, got {pv_forecast_minute[120]}" assert pv_forecast_minute[180] == 1.5, f"Expected minute 180 to be 1.5, got {pv_forecast_minute[180]}" - print(f"✓ Forecast data maps directly when relative_time = midnight_utc") - print(f"✓ pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}, [120]={pv_forecast_minute[120]}") + print(f"PASS: Forecast data maps directly when relative_time = midnight_utc") + print(f"PASS: pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}, [120]={pv_forecast_minute[120]}") print("Test 4 PASSED") @@ -343,12 +343,12 @@ def test_fetch_pv_forecast_previous_day(): # With the corrected formula (target = stored_minute - offset), a +1440 min offset # maps yesterday's stored minutes to today-relative minutes: - # forecast minute 0 (yesterday midnight) → output minute -1440 (past, before today midnight) - # forecast minute 360 (yesterday 6am) → output minute -1080 (past) - # forecast minute 720 (yesterday noon) → output minute -720 (past) - # forecast minute 1080 (yesterday 6pm) → output minute -360 (past) - # forecast minute 1440 (today midnight) → output minute 0 ← first visible today slot - # forecast minute 1500 (today 1am) → output minute 60 + # forecast minute 0 (yesterday midnight) -> output minute -1440 (past, before today midnight) + # forecast minute 360 (yesterday 6am) -> output minute -1080 (past) + # forecast minute 720 (yesterday noon) -> output minute -720 (past) + # forecast minute 1080 (yesterday 6pm) -> output minute -360 (past) + # forecast minute 1440 (today midnight) -> output minute 0 <- first visible today slot + # forecast minute 1500 (today 1am) -> output minute 60 # Verify the offset is correctly applied # Today midnight (forecast minute 1440) should appear at output minute 0 @@ -360,8 +360,8 @@ def test_fetch_pv_forecast_previous_day(): assert pv_forecast_minute10[0] == 1.6, f"Expected forecast10 minute 0 to be 1.6, got {pv_forecast_minute10.get(0)}" assert pv_forecast_minute10[60] == 2.0, f"Expected forecast10 minute 60 to be 2.0, got {pv_forecast_minute10.get(60)}" - print(f"✓ Forecast data correctly shifted back by 1440 minutes (stored_minute - offset)") - print(f"✓ pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}") + print(f"PASS: Forecast data correctly shifted back by 1440 minutes (stored_minute - offset)") + print(f"PASS: pv_forecast_minute[0]={pv_forecast_minute[0]}, [60]={pv_forecast_minute[60]}") print("Test 5 PASSED") @@ -412,10 +412,10 @@ def test_fetch_pv_forecast_negative_offset(): # With the corrected formula (target = stored_minute - offset), a -60 min offset # (relative_time is 1 hour AFTER midnight_utc) shifts data FORWARD by 60 minutes: - # forecast minute 60 (= relative_time + 1h = 2h after midnight) → output minute 120 - # forecast minute 120 (= 3h after midnight) → output minute 180 - # forecast minute 180 (= 4h after midnight) → output minute 240 - # forecast minute 240 (= 5h after midnight) → output minute 300 + # forecast minute 60 (= relative_time + 1h = 2h after midnight) -> output minute 120 + # forecast minute 120 (= 3h after midnight) -> output minute 180 + # forecast minute 180 (= 4h after midnight) -> output minute 240 + # forecast minute 240 (= 5h after midnight) -> output minute 300 # Check that data is mapped correctly with negative offset assert pv_forecast_minute[120] == 0.0, f"Expected minute 120 to be 0.0, got {pv_forecast_minute.get(120)}" @@ -429,8 +429,8 @@ def test_fetch_pv_forecast_negative_offset(): assert pv_forecast_minute10[240] == 0.8, f"Expected forecast10 minute 240 to be 0.8, got {pv_forecast_minute10.get(240)}" assert pv_forecast_minute10[300] == 1.2, f"Expected forecast10 minute 300 to be 1.2, got {pv_forecast_minute10.get(300)}" - print(f"✓ Forecast data correctly shifted forward by 60 minutes (stored_minute - (-60))") - print(f"✓ pv_forecast_minute[120]={pv_forecast_minute[120]}, [180]={pv_forecast_minute[180]}, [240]={pv_forecast_minute[240]}") + print(f"PASS: Forecast data correctly shifted forward by 60 minutes (stored_minute - (-60))") + print(f"PASS: pv_forecast_minute[120]={pv_forecast_minute[120]}, [180]={pv_forecast_minute[180]}, [240]={pv_forecast_minute[240]}") print("Test 6 PASSED") diff --git a/apps/predbat/tests/test_fill_load_from_power.py b/apps/predbat/tests/test_fill_load_from_power.py index 46c5701df..1ff89f22a 100644 --- a/apps/predbat/tests/test_fill_load_from_power.py +++ b/apps/predbat/tests/test_fill_load_from_power.py @@ -82,7 +82,7 @@ def test_fill_load_from_power_basic(): total_consumption = result[0] - result[59] assert abs(total_consumption - 3.0) < 0.2, f"Total consumption should be near 3.0 kWh, got {dp4(total_consumption)} kWh" - print(f"✓ Two 30-minute periods (0-59): {dp4(result[0])} -> {dp4(result[59])}, consumption: {dp4(total_consumption)} kWh") + print(f"PASS: Two 30-minute periods (0-59): {dp4(result[0])} -> {dp4(result[59])}, consumption: {dp4(total_consumption)} kWh") print("Test 1 PASSED") @@ -119,7 +119,7 @@ def test_fill_load_from_power_no_power_data(): # Check warning was logged assert any("No power data" in msg for msg in fetch.log_messages), "Should log warning about no power data" - print(f"✓ Original data returned: {dp4(result[0])}, {dp4(result[4])}") + print(f"PASS: Original data returned: {dp4(result[0])}, {dp4(result[4])}") print("Test 2 PASSED") @@ -162,7 +162,7 @@ def test_fill_load_from_power_partial_power_data(): total_consumption = result[0] - result[59] assert abs(total_consumption - 4.0) < 0.2, f"Total consumption should be near 4.0 kWh, got {dp4(total_consumption)} kWh" - print(f"✓ Two 30-minute periods: Power in first period, distributed in second") + print(f"PASS: Two 30-minute periods: Power in first period, distributed in second") print(f" {dp4(result[0])} -> {dp4(result[29])} -> {dp4(result[59])}") print("Test 3 PASSED") @@ -197,7 +197,7 @@ def test_fill_load_from_power_single_minute_period(): # Last value should be around 4.0 assert abs(result[9] - 4.0) < 0.1, f"Minute 9: expected ~4.0, got {result[9]}" - print(f"✓ Short span handled: {dp4(result[0])} -> {dp4(result[9])}") + print(f"PASS: Short span handled: {dp4(result[0])} -> {dp4(result[9])}") print("Test 4 PASSED") @@ -225,7 +225,7 @@ def test_fill_load_from_power_zero_load(): for minute in range(0, 30): assert result[minute] == 0.0, f"Minute {minute} should be 0.0, got {result[minute]}" - print("✓ Zero load values preserved") + print("PASS: Zero load values preserved") print("Test 5 PASSED") @@ -301,9 +301,9 @@ def test_fill_load_from_power_backwards_time(): first_hour_consumption = result[0] - result[59] assert abs(first_hour_consumption - 1.5) < 0.5, f"First hour should have ~1.5 kWh consumption, got {dp4(first_hour_consumption)}" - print(f"✓ Backwards time: minute 0 (now) = {dp4(result[0])} kWh") - print(f"✓ Backwards time: minute 60 (1h ago) = {dp4(result[60])} kWh") - print(f"✓ Backwards time: minute 120 (2h ago) = {dp4(result[120])} kWh") + print(f"PASS: Backwards time: minute 0 (now) = {dp4(result[0])} kWh") + print(f"PASS: Backwards time: minute 60 (1h ago) = {dp4(result[60])} kWh") + print(f"PASS: Backwards time: minute 120 (2h ago) = {dp4(result[120])} kWh") print("Test 6 PASSED") @@ -346,7 +346,7 @@ def test_fill_load_from_power_data_extends_beyond_load(): # They may not exist in the result dict at all, which is the correct behaviour. assert 1440 not in result or result[1440] == 0, "Minute 1440 should not be populated from power-only range" - print("✓ No spurious zero-load warning when power data extends past load data range") + print("PASS: No spurious zero-load warning when power data extends past load data range") print("Test 7 PASSED") @@ -378,7 +378,7 @@ def test_fill_load_from_power_negative_power_corrupts_load_total(): for minute in range(0, 31): load_minutes[minute] = 10.0 for minute in range(31, 61): - load_minutes[minute] = 10.0 - (minute - 30) * (2.0 / 30) # 9.933 → 8.0 + load_minutes[minute] = 10.0 - (minute - 30) * (2.0 / 30) # 9.933 -> 8.0 expected_total = load_minutes[0] - load_minutes[60] # 10.0 - 8.0 = 2.0 kWh @@ -425,17 +425,17 @@ def run_all_tests(my_predbat=None): test_fill_load_from_power_negative_power_corrupts_load_total() print("\n" + "=" * 60) - print("✅ ALL TESTS PASSED") + print("PASS: ALL TESTS PASSED") print("=" * 60) return 0 # Return 0 for success except AssertionError as e: print("\n" + "=" * 60) - print(f"❌ TEST FAILED: {e}") + print(f"ERROR: TEST FAILED: {e}") print("=" * 60) return 1 # Return 1 for failure except Exception as e: print("\n" + "=" * 60) - print(f"❌ ERROR: {e}") + print(f"ERROR: ERROR: {e}") import traceback traceback.print_exc() diff --git a/apps/predbat/tests/test_find_battery_size.py b/apps/predbat/tests/test_find_battery_size.py index 19ea11177..5e5b1af55 100644 --- a/apps/predbat/tests/test_find_battery_size.py +++ b/apps/predbat/tests/test_find_battery_size.py @@ -547,7 +547,7 @@ def test_battery_scaling_auto_basic(my_predbat): day0 = str(today - td(days=3)) day1 = str(today - td(days=2)) day2 = str(today - td(days=1)) - # Values: 9.0, 9.5, 10.0; today adds 9.4 → trimmed mean of [9.0,9.4,9.5,10.0] drops extremes → (9.4+9.5)/2 = 9.45 + # Values: 9.0, 9.5, 10.0; today adds 9.4 -> trimmed mean of [9.0,9.4,9.5,10.0] drops extremes -> (9.4+9.5)/2 = 9.45 existing_history = {day0: 9.0, day1: 9.5, day2: 10.0} my_predbat.ha_interface.dummy_items[sensor_name] = {"state": "9.5", "history": existing_history} @@ -627,7 +627,7 @@ def test_battery_scaling_auto_clamping(my_predbat): my_predbat.battery_scaling_auto = True sensor_name = "sensor.{}_soc_max_calculated".format(my_predbat.prefix) - # Test lower clamp: find_battery_size returns 7.0, ratio 0.7 → clamped to 0.8 → soc_max=8.0 + # Test lower clamp: find_battery_size returns 7.0, ratio 0.7 -> clamped to 0.8 -> soc_max=8.0 inv = _make_inv_for_scaling(my_predbat, nominal) my_predbat.ha_interface.dummy_items.pop(sensor_name, None) inv.find_battery_size = lambda _nc=0: 7.0 @@ -642,7 +642,7 @@ def test_battery_scaling_auto_clamping(my_predbat): else: print("SUCCESS: lower clamp to 0.8 correct") - # Test upper clamp: find_battery_size returns 11.0, ratio 1.1 → clamped to 1.0 → soc_max=10.0 + # Test upper clamp: find_battery_size returns 11.0, ratio 1.1 -> clamped to 1.0 -> soc_max=10.0 inv2 = _make_inv_for_scaling(my_predbat, nominal) my_predbat.ha_interface.dummy_items.pop(sensor_name, None) inv2.find_battery_size = lambda _nc=0: 11.0 @@ -742,7 +742,7 @@ def mock_find_should_not_be_called(): if calls[0] > 0: print("ERROR: find_battery_size was called {} time(s) but should have been skipped".format(calls[0])) failed = True - # stored mean=9.0, nominal=10.0 → scaling=0.9 → soc_max=9.0 + # stored mean=9.0, nominal=10.0 -> scaling=0.9 -> soc_max=9.0 expected_scaling = _clamped_auto_scaling(9.0, nominal) expected_soc_max = round(nominal * expected_scaling, 3) if abs(inv.soc_max - expected_soc_max) > 0.001: @@ -906,7 +906,7 @@ def test_battery_size_tracking_no_nominal(my_predbat): try: inv.battery_size_tracking() - # nominal_in_sensor=0 \u2192 use trimmed_mean directly without clamping + # nominal_in_sensor=0 -> use trimmed_mean directly without clamping expected_soc_max = round(9.2, 3) if abs(inv.soc_max - expected_soc_max) > 0.001: print("ERROR: soc_max {} does not match expected {:.3f} (no-nominal path)".format(inv.soc_max, expected_soc_max)) @@ -1045,7 +1045,7 @@ def test_update_soc_max_calculated_sensor_all_none(my_predbat): # (so the HA entity shows a sensible value rather than 'unknown') sensor_state = my_predbat.ha_interface.dummy_items.get(sensor_name, {}) state_value = sensor_state.get("state") if isinstance(sensor_state, dict) else sensor_state - expected_state = nominal # nominal_capacity > 0 → state = nominal_capacity + expected_state = nominal # nominal_capacity > 0 -> state = nominal_capacity if state_value != expected_state: print("ERROR: expected sensor state {}, got '{}'".format(expected_state, state_value)) failed = True @@ -1092,8 +1092,8 @@ def test_update_soc_max_calculated_sensor_mixed_none(my_predbat): from datetime import timedelta as td - # 2 valid days + 1 None day; calling with a new valid value → 3 real values total - # values: 9.0, 9.5, 9.2 → sorted [9.0, 9.2, 9.5] → trimmed mean = 9.2 + # 2 valid days + 1 None day; calling with a new valid value -> 3 real values total + # values: 9.0, 9.5, 9.2 -> sorted [9.0, 9.2, 9.5] -> trimmed mean = 9.2 today = my_predbat.now_utc.date() existing_history = { str(today - td(days=3)): 9.0, @@ -1108,7 +1108,7 @@ def test_update_soc_max_calculated_sensor_mixed_none(my_predbat): print("ERROR: expected a valid mean when real values exist alongside None entries") failed = True else: - # Sorted real values: [9.0, 9.2, 9.5] → trimmed drops 9.0 and 9.5 → mean=9.2 + # Sorted real values: [9.0, 9.2, 9.5] -> trimmed drops 9.0 and 9.5 -> mean=9.2 expected_mean = 9.2 if abs(result - expected_mean) > 0.05: print("ERROR: expected trimmed mean ~{:.2f}, got {:.3f}".format(expected_mean, result)) diff --git a/apps/predbat/tests/test_find_charge_window.py b/apps/predbat/tests/test_find_charge_window.py index bc8814dcc..fe0a87b2b 100644 --- a/apps/predbat/tests/test_find_charge_window.py +++ b/apps/predbat/tests/test_find_charge_window.py @@ -28,16 +28,16 @@ def test_find_charge_window(my_predbat): """ Comprehensive tests for find_charge_window covering all code paths: - Path A - mixed rate exceeds combine_rate_threshold → window closed early - Path B - combine_export_slots=False hits export_slot_split → split - Path C - combine_charge_slots=False hits charge_slot_split → split + Path A - mixed rate exceeds combine_rate_threshold -> window closed early + Path B - combine_export_slots=False hits export_slot_split -> split + Path C - combine_charge_slots=False hits charge_slot_split -> split Path D - manual_all_times slot split at plan_interval_minutes Path E - alt_rates alternate_rate_boundary terminates export window; also 24-hour export cap Path F - window start (rate_low_start set for first time) Path G - window continuation and correct average calculation - Path H - rate outside threshold while window in progress → closes window - Path I - gap in rates while window in progress → closes window (regression + Path H - rate outside threshold while window in progress -> closes window + Path I - gap in rates while window in progress -> closes window (regression for previous break-too-early bug) Misc - no qualifying rates, find_high=False, find_high=True, zero rate excluded for find_high, scan from non-zero minute, scan stops at @@ -70,7 +70,7 @@ def test_find_charge_window(my_predbat): scan_end = my_predbat.forecast_minutes + my_predbat.minutes_now + 12 * 60 # ----------------------------------------------------------------------- - # Test: no rates → no window found + # Test: no rates -> no window found # ----------------------------------------------------------------------- print("Test find_charge_window: empty rates dict returns no window") s, e, avg = my_predbat.find_charge_window({}, 0, thresh_lo, find_high=False) @@ -80,7 +80,7 @@ def test_find_charge_window(my_predbat): # ----------------------------------------------------------------------- # Test: simple charge window (find_high=False) — Path F + G - # Rates start at 0, low for 120 min, high after → window [0, 120] + # Rates start at 0, low for 120 min, high after -> window [0, 120] # ----------------------------------------------------------------------- print("Test find_charge_window: simple charge window (find_high=False)") rates = {} @@ -94,7 +94,7 @@ def test_find_charge_window(my_predbat): # ----------------------------------------------------------------------- # Test: simple export window (find_high=True) — Path F + G - # Rates start at 0, high for 120 min, low after → window [0, 120] + # Rates start at 0, high for 120 min, low after -> window [0, 120] # ----------------------------------------------------------------------- print("Test find_charge_window: simple export window (find_high=True)") rates2 = {} @@ -118,7 +118,7 @@ def test_find_charge_window(my_predbat): # ----------------------------------------------------------------------- # Test: rate goes ABOVE threshold while window in progress — Path H - # Low rates 0..59, high rate at 60.. → window closes at 60 + # Low rates 0..59, high rate at 60.. -> window closes at 60 # ----------------------------------------------------------------------- print("Test find_charge_window: rate above threshold closes window (Path H)") rates_h = {} @@ -133,7 +133,7 @@ def test_find_charge_window(my_predbat): # ----------------------------------------------------------------------- # Test: Path A — combine_rate_threshold: second rate too different # Low rate 5 for 0..55, then rate 8 at 60 (diff = 3 > threshold 1.0) - # → window closes at 60 due to combine_rate_threshold + # -> window closes at 60 due to combine_rate_threshold # ----------------------------------------------------------------------- print("Test find_charge_window: combine_rate_threshold closes mixed window (Path A)") my_predbat.combine_rate_threshold = 2.0 # allow up to 2p difference @@ -149,7 +149,7 @@ def test_find_charge_window(my_predbat): # ----------------------------------------------------------------------- # Test: Path C — combine_charge_slots=False splits charge at charge_slot_split - # Low rates from 0 to 120, charge_slot_split=30 → window [0, 30] + # Low rates from 0 to 120, charge_slot_split=30 -> window [0, 30] # ----------------------------------------------------------------------- print("Test find_charge_window: combine_charge_slots=False splits slot (Path C)") my_predbat.combine_charge_slots = False @@ -162,7 +162,7 @@ def test_find_charge_window(my_predbat): # ----------------------------------------------------------------------- # Test: Path B — combine_export_slots=False splits export at export_slot_split - # High rates from 0 to 120, export_slot_split=30 → window [0, 30] + # High rates from 0 to 120, export_slot_split=30 -> window [0, 30] # ----------------------------------------------------------------------- print("Test find_charge_window: combine_export_slots=False splits slot (Path B)") my_predbat.combine_export_slots = False @@ -176,7 +176,7 @@ def test_find_charge_window(my_predbat): # ----------------------------------------------------------------------- # Test: Path D — manual_all_times slot split at plan_interval_minutes # rate_low_start=0 is in manual_all_times; low rates from 0 to 120; - # plan_interval_minutes=30 → window closes at minute 30 + # plan_interval_minutes=30 -> window closes at minute 30 # ----------------------------------------------------------------------- print("Test find_charge_window: manual_all_times split at plan_interval_minutes (Path D)") my_predbat.plan_interval_minutes = 30 @@ -190,14 +190,14 @@ def test_find_charge_window(my_predbat): # ----------------------------------------------------------------------- # Test: Path E — alternate_rate_boundary splits export window - # find_high=True; alt_rates changes significantly between minutes 25→30; + # find_high=True; alt_rates changes significantly between minutes 25->30; # high export rates throughout; plan_interval_minutes=30; - # → export window should end at 30 (boundary reached after plan_interval) + # -> export window should end at 30 (boundary reached after plan_interval) # ----------------------------------------------------------------------- print("Test find_charge_window: alternate_rate_boundary splits export window (Path E)") my_predbat.plan_interval_minutes = 30 my_predbat.export_slot_split = 30 - # alt_rates min=0, max=20 → alt_rate_threshold=2.0; jump of 10 qualifies + # alt_rates min=0, max=20 -> alt_rate_threshold=2.0; jump of 10 qualifies alt_rates_e = {} for m in range(0, 30, 5): alt_rates_e[m] = 0.0 # low alt rate @@ -207,7 +207,7 @@ def test_find_charge_window(my_predbat): rates_e = {m: high_rate for m in range(0, scan_end, 5)} # all qualify # Window starts at 0; alternate_rate_boundary is set at minute 30; - # at minute 30, (30 - 0) = 30 >= plan_interval_minutes=30 → break + # at minute 30, (30 - 0) = 30 >= plan_interval_minutes=30 -> break s, e, _ = my_predbat.find_charge_window(rates_e, 0, thresh_hi, find_high=True, alt_rates=alt_rates_e) failed |= _assert_window("path E alt rate boundary", s, e, _, 0, 30) @@ -224,7 +224,7 @@ def test_find_charge_window(my_predbat): # ----------------------------------------------------------------------- # Test: correct average over varying rates (Path G) - # Three rate bands: 4, 6, 8 each for 1 slot (5 min) → average = (4+6+8)/3 = 6 + # Three rate bands: 4, 6, 8 each for 1 slot (5 min) -> average = (4+6+8)/3 = 6 # Raise combine_rate_threshold so all three rates are combined into one window. # ----------------------------------------------------------------------- print("Test find_charge_window: correct average calculation (Path G)") diff --git a/apps/predbat/tests/test_futurerate_auto.py b/apps/predbat/tests/test_futurerate_auto.py index 5621d35eb..9a667edd5 100644 --- a/apps/predbat/tests/test_futurerate_auto.py +++ b/apps/predbat/tests/test_futurerate_auto.py @@ -323,7 +323,7 @@ def _test_futurerate_auto_disabled_proceeds(my_predbat): def _test_futurerate_auto_no_agile_returns_empty(my_predbat): - """futurerate_adjust_auto=True + no Agile detected → return ({}, {}) without hitting the API.""" + """futurerate_adjust_auto=True + no Agile detected -> return ({}, {}) without hitting the API.""" base = MockFutureRateBase() base.args["futurerate_adjust_auto"] = True future = _make_future_rate(base) @@ -341,7 +341,7 @@ def _test_futurerate_auto_no_agile_returns_empty(my_predbat): def _test_futurerate_auto_import_agile_proceeds(my_predbat): - """futurerate_adjust_auto=True + import is Agile → calls futurerate_analysis_new.""" + """futurerate_adjust_auto=True + import is Agile -> calls futurerate_analysis_new.""" base = MockFutureRateBase() base.args["futurerate_adjust_auto"] = True base.args["metric_octopus_import"] = IMPORT_ENTITY @@ -361,7 +361,7 @@ def _test_futurerate_auto_import_agile_proceeds(my_predbat): def _test_futurerate_auto_export_agile_proceeds(my_predbat): - """futurerate_adjust_auto=True + only export is Agile → calls futurerate_analysis_new.""" + """futurerate_adjust_auto=True + only export is Agile -> calls futurerate_analysis_new.""" base = MockFutureRateBase() base.args["futurerate_adjust_auto"] = True base.args["metric_octopus_export"] = EXPORT_ENTITY @@ -386,7 +386,7 @@ def _test_futurerate_auto_export_agile_proceeds(my_predbat): def _test_futurerate_adjust_auto_import_agile_calibrates_import(my_predbat): - """futurerate_adjust_auto=True + import Agile → calibrate import with real rates.""" + """futurerate_adjust_auto=True + import Agile -> calibrate import with real rates.""" base = MockFutureRateBase() base.args["futurerate_adjust_auto"] = True base.args["futurerate_adjust_import"] = False @@ -432,7 +432,7 @@ def mock_calibrate(real_mdata, mdata, is_import, peak_start_minutes, peak_end_mi def _test_futurerate_adjust_auto_export_agile_calibrates_export(my_predbat): - """futurerate_adjust_auto=True + export Agile → calibrate export with real rates.""" + """futurerate_adjust_auto=True + export Agile -> calibrate export with real rates.""" base = MockFutureRateBase() base.args["futurerate_adjust_auto"] = True base.args["futurerate_adjust_import"] = False @@ -476,7 +476,7 @@ def mock_calibrate(real_mdata, mdata, is_import, peak_start_minutes, peak_end_mi def _test_futurerate_adjust_auto_disabled_uses_manual_flags(my_predbat): - """futurerate_adjust_auto=False → manual futurerate_adjust_import/export flags respected.""" + """futurerate_adjust_auto=False -> manual futurerate_adjust_import/export flags respected.""" base = MockFutureRateBase() base.args["futurerate_adjust_auto"] = False base.args["futurerate_adjust_import"] = True diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index aea4e45fd..48f614f11 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -22,7 +22,7 @@ def approx_equal(actual, expected, abs_tol=0.01): class TestProtobufDecode: - """Test protobuf telemetry → entity mapping.""" + """Test protobuf telemetry -> entity mapping.""" def _make_status(self, soc=50, battery_power=1000, pv_power=2000, grid_power=-500, load_power=1500, mode=0): status = pb.GatewayStatus() @@ -300,7 +300,7 @@ def _make_gateway(self): gw._last_status = None gw.args = {} gw.local_tz = pytz.timezone("Europe/London") - gw._dashboard_calls = {} # entity_id → (state, attributes) + gw._dashboard_calls = {} # entity_id -> (state, attributes) def capture_dashboard(entity_id, state=None, attributes=None, app=None): gw._dashboard_calls[entity_id] = (state, attributes) @@ -386,7 +386,7 @@ def test_inverter_time_sensor(self): status = self._make_status() gw._inject_entities(status) - # Serial "CE123456789" (len > 6) → last 6 chars lowercase = "456789" + # Serial "CE123456789" (len > 6) -> last 6 chars lowercase = "456789" entity = "sensor.predbat_gateway_456789_inverter_time" assert entity in gw._dashboard_calls state, attrs = gw._dashboard_calls[entity] @@ -414,7 +414,7 @@ def test_non_primary_inverter_skipped(self): assert "sensor.predbat_gateway_000001_soc" in gw._dashboard_calls def test_battery_power_negated(self): - """Battery power sign is inverted: firmware +ve=charging → PredBat +ve=discharging.""" + """Battery power sign is inverted: firmware +ve=charging -> PredBat +ve=discharging.""" gw = self._make_gateway() gw._inject_entities(self._make_status(battery_power=1000)) @@ -449,20 +449,20 @@ def test_schedule_select_entities(self): gw._inject_entities(self._make_status()) suffix = "456789" - # charge_start = 130 → 01:30:00 + # charge_start = 130 -> 01:30:00 state, attrs = gw._dashboard_calls[f"select.predbat_gateway_{suffix}_charge_slot1_start"] assert state == "01:30:00" assert attrs == GATEWAY_ATTRIBUTE_TABLE.get("charge_slot1_start", {}) - # charge_end = 430 → 04:30:00 + # charge_end = 430 -> 04:30:00 state, _ = gw._dashboard_calls[f"select.predbat_gateway_{suffix}_charge_slot1_end"] assert state == "04:30:00" - # discharge_start = 1600 → 16:00:00 + # discharge_start = 1600 -> 16:00:00 state, _ = gw._dashboard_calls[f"select.predbat_gateway_{suffix}_discharge_slot1_start"] assert state == "16:00:00" - # discharge_end = 1900 → 19:00:00 + # discharge_end = 1900 -> 19:00:00 state, _ = gw._dashboard_calls[f"select.predbat_gateway_{suffix}_discharge_slot1_end"] assert state == "19:00:00" @@ -1256,7 +1256,7 @@ def test_republish_refreshes_timestamp(self): second_plan.ParseFromString(second_payload) assert second_plan.timestamp >= first_plan.timestamp # rebuilt with current clock - assert second_plan.plan_version == first_plan.plan_version # content unchanged → same version + assert second_plan.plan_version == first_plan.plan_version # content unchanged -> same version # The cached bytes are refreshed so subsequent reads reflect the new timestamp. assert gw._last_plan_data == second_payload @@ -1898,7 +1898,7 @@ def test_discharge_slot_end(self): # ------------------------------------------------------------------ def test_midnight_time_converts_correctly(self): - """00:00:00 → HHMM 0 (midnight).""" + """00:00:00 -> HHMM 0 (midnight).""" import json gw = self._make_gateway() @@ -1949,7 +1949,7 @@ def test_no_command_when_suffix_not_in_map(self): class TestNumberEvent: - """Tests for GatewayMQTT.number_event() — numeric entity → command routing.""" + """Tests for GatewayMQTT.number_event() — numeric entity -> command routing.""" def _make_gateway(self): from gateway import GatewayMQTT @@ -1980,25 +1980,25 @@ def _run(self, coro): # ------------------------------------------------------------------ def test_charge_rate_routes_correctly(self): - """charge_rate entity → set_charge_rate with power_w.""" + """charge_rate entity -> set_charge_rate with power_w.""" gw = self._make_gateway() self._run(gw.number_event("number.predbat_gateway_456789_charge_rate", "3000")) assert gw._published == [("set_charge_rate", {"power_w": 3000, "serial": "CE123456789"})] def test_discharge_rate_routes_correctly(self): - """discharge_rate entity → set_discharge_rate with power_w (not charge_rate).""" + """discharge_rate entity -> set_discharge_rate with power_w (not charge_rate).""" gw = self._make_gateway() self._run(gw.number_event("number.predbat_gateway_456789_discharge_rate", "2500")) assert gw._published == [("set_discharge_rate", {"power_w": 2500, "serial": "CE123456789"})] def test_reserve_soc_routes_correctly(self): - """reserve_soc entity → set_reserve with target_soc.""" + """reserve_soc entity -> set_reserve with target_soc.""" gw = self._make_gateway() self._run(gw.number_event("number.predbat_gateway_456789_reserve_soc", "10")) assert gw._published == [("set_reserve", {"target_soc": 10, "serial": "CE123456789"})] def test_target_soc_routes_correctly(self): - """target_soc entity → set_target_soc with target_soc.""" + """target_soc entity -> set_target_soc with target_soc.""" gw = self._make_gateway() self._run(gw.number_event("number.predbat_gateway_456789_target_soc", "100")) assert gw._published == [("set_target_soc", {"target_soc": 100, "serial": "CE123456789"})] @@ -2079,7 +2079,7 @@ def test_no_command_when_suffix_not_in_map(self): class TestSwitchEvent: - """Tests for GatewayMQTT.switch_event() — charge/discharge enable → mode commands.""" + """Tests for GatewayMQTT.switch_event() — charge/discharge enable -> mode commands.""" def _make_gateway(self): from gateway import GatewayMQTT @@ -2130,7 +2130,7 @@ def test_charge_enabled_turn_off(self): def test_charge_enabled_toggle(self): """Toggling charge_enabled flips based on current state from get_state_wrapper.""" gw = self._make_gateway() - # currently on → toggle → off + # currently on -> toggle -> off gw._state["switch.predbat_gateway_456789_charge_enabled"] = True self._run(gw.switch_event("switch.predbat_gateway_456789_charge_enabled", "toggle")) assert gw._published == [("set_charge_enable", {"enable": False, "serial": "CE123456789"})] @@ -2154,7 +2154,7 @@ def test_discharge_enabled_turn_off(self): def test_discharge_enabled_toggle(self): """Toggling discharge_enabled flips based on current state from get_state_wrapper.""" gw = self._make_gateway() - # currently off → toggle → on (get_state_wrapper returns False by default) + # currently off -> toggle -> on (get_state_wrapper returns False by default) self._run(gw.switch_event("switch.predbat_gateway_456789_discharge_enabled", "toggle")) assert gw._published == [("set_discharge_enable", {"enable": True, "serial": "CE123456789"})] @@ -2381,49 +2381,49 @@ def _get_payload_with_plan(self, rows, extra_states=None): return json.loads(raw_bytes.decode()) def test_timeline_charging_maps_to_1(self): - """Charging state → timeline code 1.""" + """Charging state -> timeline code 1.""" rows = self._make_rows([{"state": "Chrg", "soc_percent": 60}]) payload = self._get_payload_with_plan(rows) assert payload["timeline"][0] == 1 def test_timeline_freeze_charging_maps_to_1(self): - """Freeze charging state → timeline code 1.""" + """Freeze charging state -> timeline code 1.""" rows = self._make_rows([{"state": "FrzChrg", "soc_percent": 60}]) payload = self._get_payload_with_plan(rows) assert payload["timeline"][0] == 1 def test_timeline_hold_charging_maps_to_1(self): - """Hold charging state → timeline code 1.""" + """Hold charging state -> timeline code 1.""" rows = self._make_rows([{"state": "HoldChrg", "soc_percent": 60}]) payload = self._get_payload_with_plan(rows) assert payload["timeline"][0] == 1 def test_timeline_discharging_maps_to_2(self): - """Discharging state → timeline code 2.""" + """Discharging state -> timeline code 2.""" rows = self._make_rows([{"state": "Exp", "soc_percent": 40}]) payload = self._get_payload_with_plan(rows) assert payload["timeline"][0] == 2 def test_timeline_freeze_discharging_maps_to_2(self): - """Freeze discharging state → timeline code 2.""" + """Freeze discharging state -> timeline code 2.""" rows = self._make_rows([{"state": "FrzExp", "soc_percent": 40}]) payload = self._get_payload_with_plan(rows) assert payload["timeline"][0] == 2 def test_timeline_hold_discharging_maps_to_2(self): - """Hold export/discharge state → timeline code 2.""" + """Hold export/discharge state -> timeline code 2.""" rows = self._make_rows([{"state": "HoldExp", "soc_percent": 40}]) payload = self._get_payload_with_plan(rows) assert payload["timeline"][0] == 2 def test_timeline_solar_maps_to_3(self): - """Unknown state with pv_forecast > 0.1 → timeline code 3 (solar).""" + """Unknown state with pv_forecast > 0.1 -> timeline code 3 (solar).""" rows = self._make_rows([{"state": "Idle", "soc_percent": 50, "pv_forecast": 0.5}]) payload = self._get_payload_with_plan(rows) assert payload["timeline"][0] == 3 def test_timeline_grid_import_maps_to_4(self): - """Unknown state with pv_forecast <= 0.1 → timeline code 4 (grid import).""" + """Unknown state with pv_forecast <= 0.1 -> timeline code 4 (grid import).""" rows = self._make_rows([{"state": "Idle", "soc_percent": 50, "pv_forecast": 0.0}]) payload = self._get_payload_with_plan(rows) assert payload["timeline"][0] == 4 @@ -2740,13 +2740,13 @@ def test_southern_hemisphere_standard_offset(self): tz = _pytz.timezone("Australia/Sydney") # Manually compute what the fallback would produce using just January (wrong) off_jan = tz.utcoffset(datetime.datetime(2024, 1, 15, 12, 0, 0)).total_seconds() / 60 - # January is DST in Sydney: UTC+11 → posix_total = -660 → hours = -11 (wrong) + # January is DST in Sydney: UTC+11 -> posix_total = -660 -> hours = -11 (wrong) # The correct standard offset is UTC+10 (July) off_jul = tz.utcoffset(datetime.datetime(2024, 7, 15, 12, 0, 0)).total_seconds() / 60 assert off_jan > off_jul, "Precondition: Sydney has higher offset in Jan (DST) than Jul (standard)" # The actual conversion should use the smaller offset (July = standard time) result = self._convert("Australia/Sydney") - # Standard AEST is UTC+10; POSIX sign flips → "-10" must appear # cspell:disable-line + # Standard AEST is UTC+10; POSIX sign flips -> "-10" must appear # cspell:disable-line assert "-10" in result, f"Expected standard offset -10 in POSIX string, got {result!r}" # cspell:disable-line def test_asia_kolkata_half_hour_offset(self): @@ -2788,7 +2788,7 @@ def test_negative_fractional_offset(self): assert "3:30" in result, f"Expected '3:30' in POSIX string, got {result!r}" # cspell:disable-line def test_fallback_northern_hemisphere(self): - """Fallback path (TZif file bypassed): Europe/London standard time is GMT (UTC+0) → 'GMT0'.""" + """Fallback path (TZif file bypassed): Europe/London standard time is GMT (UTC+0) -> 'GMT0'.""" from unittest.mock import patch from gateway import GatewayMQTT @@ -2808,7 +2808,7 @@ def test_fallback_southern_hemisphere(self): assert "-11" not in result, f"DST offset -11 must not appear in fallback result, got {result!r}" # cspell:disable-line def test_fallback_negative_fractional_offset(self): - """Fallback path divmod fix: America/St_Johns standard time is NST (UTC-3:30) → '3:30'.""" + """Fallback path divmod fix: America/St_Johns standard time is NST (UTC-3:30) -> '3:30'.""" from unittest.mock import patch from gateway import GatewayMQTT @@ -3179,7 +3179,7 @@ def _make_gateway(self, read_only=False, alive=True, auto_configured=True): gw._suffix_to_serial = {} gw._inverter_reset_done = set() gw._mqtt_connected = alive - gw._gateway_online = False # broker-connected but LWT-offline → is_alive() True when _mqtt_connected + gw._gateway_online = False # broker-connected but LWT-offline -> is_alive() True when _mqtt_connected gw._last_telemetry_time = 0 gw._auto_configured = auto_configured gw._published = [] @@ -3651,7 +3651,7 @@ def test_set_charge_slot_start_raw_payload(self): import json gw = self._make_gateway() - # 02:00:00 → HHMM 200, matching the expected {"start":200} in the hub spec + # 02:00:00 -> HHMM 200, matching the expected {"start":200} in the hub spec self._run(gw.select_event("select.predbat_gateway_30g499_charge_slot1_start", "02:00:00")) assert gw._raw_published, "No payload was published — serial lookup may have failed" @@ -3673,7 +3673,7 @@ def test_set_charge_slot_start_raw_payload(self): class TestEvTelemetry: - """Tests for GatewayMQTT._inject_ev_entities() — EvCharger telemetry → entities.""" + """Tests for GatewayMQTT._inject_ev_entities() — EvCharger telemetry -> entities.""" def _make_gateway(self, battery_size=100): from gateway import GatewayMQTT @@ -3683,7 +3683,7 @@ def _make_gateway(self, battery_size=100): gw.base = MagicMock() gw.log = MagicMock() gw.prefix = "predbat" - gw._dashboard_calls = {} # entity_id → (state, attributes) + gw._dashboard_calls = {} # entity_id -> (state, attributes) def capture_dashboard(entity_id, state=None, attributes=None, app=None): gw._dashboard_calls[entity_id] = (state, attributes) @@ -3717,11 +3717,11 @@ def test_ev_entities_published(self): base = "predbat_gateway_ev" assert gw._dashboard_calls[f"binary_sensor.{base}_online"][0] is True - assert gw._dashboard_calls[f"binary_sensor.{base}_connected"][0] is True # status="Charging" → car connected + assert gw._dashboard_calls[f"binary_sensor.{base}_connected"][0] is True # status="Charging" -> car connected assert gw._dashboard_calls[f"binary_sensor.{base}_session_active"][0] is True assert gw._dashboard_calls[f"sensor.{base}_status"][0] == "Charging" assert gw._dashboard_calls[f"sensor.{base}_power"][0] == 7200 - # Wh → kWh + # Wh -> kWh assert approx_equal(gw._dashboard_calls[f"sensor.{base}_session_energy"][0], 12.4) assert gw._dashboard_calls[f"sensor.{base}_current_limit"][0] == 16 assert gw._dashboard_calls[f"sensor.{base}_soc"][0] == 55 @@ -3743,7 +3743,7 @@ def test_ev_entity_attributes_from_table(self): def test_not_reported_fields_skipped(self): """Zero/empty 'not reported' fields are not published, except soc which falls back to session energy.""" gw = self._make_gateway(battery_size=100) - # session_energy_wh=12400 → 12.4 kWh; battery_size=100 → fallback soc = 12.4% + # session_energy_wh=12400 -> 12.4 kWh; battery_size=100 -> fallback soc = 12.4% status = self._status_with_ev(soc_percent=0, voltage_v=0, max_current_a=0, current_limit_a=0, eco_mode="", status="") gw._inject_ev_entities(status) @@ -3757,7 +3757,7 @@ def test_not_reported_fields_skipped(self): assert f"sensor.{base}_status" not in gw._dashboard_calls # Always-published fields remain assert f"binary_sensor.{base}_online" in gw._dashboard_calls - assert gw._dashboard_calls[f"binary_sensor.{base}_connected"][0] is False # status="" → no car + assert gw._dashboard_calls[f"binary_sensor.{base}_connected"][0] is False # status="" -> no car assert f"sensor.{base}_power" in gw._dashboard_calls # Charge rate falls back to 7.4 kW when capability is not reported assert approx_equal(gw._dashboard_calls[f"sensor.{base}_charge_rate"][0], 7.4) @@ -3765,7 +3765,7 @@ def test_not_reported_fields_skipped(self): def test_soc_fallback_uses_battery_size(self): """When soc is not reported, the fallback is session_energy / battery_size * 100.""" gw = self._make_gateway(battery_size=50) - # session_energy_wh=12400 → 12.4 kWh; battery_size=50 → 12.4/50*100 = 24.8% + # session_energy_wh=12400 -> 12.4 kWh; battery_size=50 -> 12.4/50*100 = 24.8% gw._inject_ev_entities(self._status_with_ev(soc_percent=0)) assert approx_equal(gw._dashboard_calls["sensor.predbat_gateway_ev_soc"][0], 24.8) diff --git a/apps/predbat/tests/test_ge_cloud.py b/apps/predbat/tests/test_ge_cloud.py index 47a7acfe9..e56b0536d 100644 --- a/apps/predbat/tests/test_ge_cloud.py +++ b/apps/predbat/tests/test_ge_cloud.py @@ -264,13 +264,13 @@ def test_ge_cloud(my_predbat=None): try: result = test_func(my_predbat) if result: - print(f"✗ FAILED: {key}") + print(f"FAIL: FAILED: {key}") failed += 1 else: - print(f"✓ PASSED: {key}") + print(f"PASS: PASSED: {key}") passed += 1 except Exception as e: - print(f"✗ EXCEPTION in {key}: {e}") + print(f"FAIL: EXCEPTION in {key}: {e}") import traceback traceback.print_exc() diff --git a/apps/predbat/tests/test_hahistory.py b/apps/predbat/tests/test_hahistory.py index 1b80aaa7f..58e7e2847 100644 --- a/apps/predbat/tests/test_hahistory.py +++ b/apps/predbat/tests/test_hahistory.py @@ -117,7 +117,7 @@ def test_hahistory_initialize(my_predbat=None): print("ERROR: history_entities should be empty after initialisation") failed += 1 else: - print("✓ history_entities initialised correctly") + print("PASS: history_entities initialised correctly") if not isinstance(ha_history.history_data, dict): print("ERROR: history_data should be a dict") @@ -126,7 +126,7 @@ def test_hahistory_initialize(my_predbat=None): print("ERROR: history_data should be empty after initialisation") failed += 1 else: - print("✓ history_data initialised correctly") + print("PASS: history_data initialised correctly") return failed @@ -146,7 +146,7 @@ def test_hahistory_add_entity(my_predbat=None): print("ERROR: Failed to add new entity with 30 days") failed += 1 else: - print("✓ Added new entity with 30 days") + print("PASS: Added new entity with 30 days") # Test updating entity with fewer days (should not update) ha_history.add_entity("sensor.battery", 7) @@ -154,7 +154,7 @@ def test_hahistory_add_entity(my_predbat=None): print("ERROR: Should not update entity to fewer days") failed += 1 else: - print("✓ Correctly kept maximum days (30)") + print("PASS: Correctly kept maximum days (30)") # Test updating entity with more days (should update) ha_history.add_entity("sensor.battery", 60) @@ -162,7 +162,7 @@ def test_hahistory_add_entity(my_predbat=None): print("ERROR: Failed to update entity to more days") failed += 1 else: - print("✓ Updated entity to more days (60)") + print("PASS: Updated entity to more days (60)") # Test adding multiple entities ha_history.add_entity("sensor.solar", 14) @@ -171,7 +171,7 @@ def test_hahistory_add_entity(my_predbat=None): print("ERROR: Should have 3 entities tracked") failed += 1 else: - print("✓ Multiple entities tracked correctly") + print("PASS: Multiple entities tracked correctly") return failed @@ -192,7 +192,7 @@ def test_hahistory_get_history_no_interface(my_predbat=None): print("ERROR: Should return None when no HAInterface") failed += 1 else: - print("✓ Returned None when no HAInterface") + print("PASS: Returned None when no HAInterface") # Check for error log error_found = any("No HAInterface available" in msg for msg in mock_base.log_messages) @@ -200,7 +200,7 @@ def test_hahistory_get_history_no_interface(my_predbat=None): print("ERROR: Should log error when no HAInterface") failed += 1 else: - print("✓ Logged error when no HAInterface") + print("PASS: Logged error when no HAInterface") return failed @@ -239,7 +239,7 @@ def test_hahistory_get_history_fetch_and_cache(my_predbat=None): print(f"ERROR: Expected {len(mock_history)} entries, got {len(result[0])}") failed += 1 else: - print(f"✓ Fetched history with {len(result[0])} entries") + print(f"PASS: Fetched history with {len(result[0])} entries") # Verify entity was tracked if entity_id not in ha_history.history_entities: @@ -249,14 +249,14 @@ def test_hahistory_get_history_fetch_and_cache(my_predbat=None): print("ERROR: Entity should be tracked with 30 days") failed += 1 else: - print("✓ Entity tracked correctly") + print("PASS: Entity tracked correctly") # Verify data was cached if entity_id not in ha_history.history_data: print("ERROR: History should be cached") failed += 1 else: - print("✓ History cached correctly") + print("PASS: History cached correctly") # Test 2: Get from cache (should not call HAInterface again) initial_call_count = len(mock_ha.get_history_calls) @@ -266,7 +266,7 @@ def test_hahistory_get_history_fetch_and_cache(my_predbat=None): print("ERROR: Should use cache, not call HAInterface again") failed += 1 else: - print("✓ Used cache instead of fetching again") + print("PASS: Used cache instead of fetching again") # Test 3: Request more days (should fetch again) result3 = ha_history.get_history(entity_id, days=60, tracked=True) @@ -274,14 +274,14 @@ def test_hahistory_get_history_fetch_and_cache(my_predbat=None): print("ERROR: Should fetch when requesting more days") failed += 1 else: - print("✓ Fetched when requesting more days") + print("PASS: Fetched when requesting more days") # Verify entity tracking was updated if ha_history.history_entities[entity_id] != 60: print("ERROR: Entity tracking should be updated to 60 days") failed += 1 else: - print("✓ Entity tracking updated to 60 days") + print("PASS: Entity tracking updated to 60 days") # Test 4: Request more days again - cache should have them populated result4 = ha_history.get_history(entity_id, days=60, tracked=True) @@ -289,14 +289,14 @@ def test_hahistory_get_history_fetch_and_cache(my_predbat=None): print("ERROR: Cache did not correctly return 60 days") failed += 1 else: - print("✓ Cache correctly populated with longer history") + print("PASS: Cache correctly populated with longer history") sorted_result4 = sorted(result4[0], key=lambda x: x["last_updated"]) if sorted_result4 != result4[0]: print("ERROR: Cache population did not correctly order entries when adding longer history") failed += 1 else: - print("✓ Cache order correct after adding longer history") + print("PASS: Cache order correct after adding longer history") return failed @@ -325,21 +325,21 @@ def test_hahistory_get_history_untracked(my_predbat=None): print("ERROR: Should return history data") failed += 1 else: - print("✓ Fetched untracked history") + print("PASS: Fetched untracked history") # Verify entity was NOT tracked if entity_id in ha_history.history_entities: print("ERROR: Entity should not be tracked when tracked=False") failed += 1 else: - print("✓ Entity not tracked correctly") + print("PASS: Entity not tracked correctly") # Verify data was NOT cached if entity_id in ha_history.history_data: print("ERROR: History should not be cached when tracked=False") failed += 1 else: - print("✓ History not cached correctly") + print("PASS: History not cached correctly") return failed @@ -376,14 +376,14 @@ def test_hahistory_expand_from_7_to_30_days(my_predbat=None): print(f"ERROR: Expected {length_7_days} entries for 7 days, got {len(result_7[0])}") failed += 1 else: - print(f"✓ Fetched and cached 7 days ({len(result_7[0])} entries)") + print(f"PASS: Fetched and cached 7 days ({len(result_7[0])} entries)") # Verify entity is tracked with 7 days if ha_history.history_entities.get(entity_id) != 7: print(f"ERROR: Entity should be tracked with 7 days, but has {ha_history.history_entities.get(entity_id)}") failed += 1 else: - print("✓ Entity tracked with 7 days") + print("PASS: Entity tracked with 7 days") # Step 2: Now request 30 days (more than cached) print("\n Step 2: Requesting 30 days (more than cached 7 days)...") @@ -395,7 +395,7 @@ def test_hahistory_expand_from_7_to_30_days(my_predbat=None): print("ERROR: Should fetch from HAInterface when requesting more days than cached") failed += 1 else: - print("✓ Correctly triggered new fetch from HAInterface") + print("PASS: Correctly triggered new fetch from HAInterface") # Verify we got the FULL 30 days, not just 7 if result_30 is None: @@ -406,14 +406,14 @@ def test_hahistory_expand_from_7_to_30_days(my_predbat=None): print(f" This suggests only {len(result_30[0]) / FIVE_MINUTE_ENTRIES_PER_DAY:.1f} days were returned") failed += 1 else: - print(f"✓ Correctly returned FULL 30 days of history ({len(result_30[0])} entries)") + print(f"PASS: Correctly returned FULL 30 days of history ({len(result_30[0])} entries)") # Verify entity tracking was updated to 30 days if ha_history.history_entities.get(entity_id) != 30: print(f"ERROR: Entity should be tracked with 30 days, but has {ha_history.history_entities.get(entity_id)}") failed += 1 else: - print("✓ Entity tracking updated to 30 days") + print("PASS: Entity tracking updated to 30 days") # Verify the last fetch call requested 30 days last_call = mock_ha.get_history_calls[-1] @@ -421,7 +421,7 @@ def test_hahistory_expand_from_7_to_30_days(my_predbat=None): print(f"ERROR: Last fetch should have requested 30 days, but requested {last_call['days']}") failed += 1 else: - print("✓ Fetch correctly requested 30 days from backend") + print("PASS: Fetch correctly requested 30 days from backend") # Step 3: Request 30 days again - should use cache now print("\n Step 3: Requesting 30 days again (should use cache)...") @@ -432,13 +432,13 @@ def test_hahistory_expand_from_7_to_30_days(my_predbat=None): print("ERROR: Should use cache when requesting same or fewer days") failed += 1 else: - print("✓ Correctly used cache for subsequent request") + print("PASS: Correctly used cache for subsequent request") if len(result_30_again[0]) != length_30_days: print(f"ERROR: Cached result should still have {length_30_days} entries, got {len(result_30_again[0])}") failed += 1 else: - print("✓ Cache correctly returns full 30 days") + print("PASS: Cache correctly returns full 30 days") return failed @@ -516,7 +516,7 @@ def test_hahistory_update_entity_filter_attributes(my_predbat=None): failed += 1 if failed == 0: - print("✓ Attributes filtered correctly") + print("PASS: Attributes filtered correctly") return failed @@ -547,7 +547,7 @@ def test_hahistory_update_entity_merge_new(my_predbat=None): print(f"ERROR: Should have 3 initial entries, got {initial_count}") failed += 1 else: - print("✓ Initial history loaded correctly") + print("PASS: Initial history loaded correctly") # New history with overlapping and new entries new_history = [ @@ -563,7 +563,7 @@ def test_hahistory_update_entity_merge_new(my_predbat=None): print(f"ERROR: Should have 5 entries after merge (3 old + 2 new), got {final_count}") failed += 1 else: - print("✓ New entries merged correctly") + print("PASS: New entries merged correctly") # Verify ordering (oldest to newest) - numeric states come back as floats from the columnar store states = [entry["state"] for entry in ha_history.history_data[entity_id]] @@ -572,7 +572,7 @@ def test_hahistory_update_entity_merge_new(my_predbat=None): print(f"ERROR: Expected states {expected_states}, got {states}") failed += 1 else: - print("✓ History maintained correct order") + print("PASS: History maintained correct order") return failed @@ -613,14 +613,14 @@ def test_hahistory_prune_history(my_predbat=None): break if failed == 0: - print("✓ All entries within 30-day window") + print("PASS: All entries within 30-day window") # Verify some entries were removed if final_count >= initial_count: print("ERROR: Expected entries to be removed") failed += 1 else: - print(f"✓ Pruned {initial_count - final_count} old entries") + print(f"PASS: Pruned {initial_count - final_count} old entries") return failed @@ -643,7 +643,7 @@ def test_hahistory_prune_empty_history(my_predbat=None): # Should not crash with empty history try: ha_history.prune_history(now) - print("✓ Handled empty history without error") + print("PASS: Handled empty history without error") except Exception as e: print(f"ERROR: Exception with empty history: {e}") failed += 1 @@ -684,21 +684,21 @@ async def run_test(): print("ERROR: Should log startup message") return 1 else: - print("✓ Logged startup message") + print("PASS: Logged startup message") # Verify history was fetched for tracked entity if len(mock_ha.get_history_calls) == 0: print("ERROR: Should fetch history on first run") return 1 else: - print(f"✓ Fetched history for {len(mock_ha.get_history_calls)} entity(ies)") + print(f"PASS: Fetched history for {len(mock_ha.get_history_calls)} entity(ies)") # Verify history was cached if entity_id not in ha_history.history_data: print("ERROR: History should be cached") return 1 else: - print("✓ History cached correctly") + print("PASS: History cached correctly") return 0 @@ -729,7 +729,7 @@ async def run_test(): print("ERROR: Should log error when no HAInterface") return 1 else: - print("✓ Returned False and logged error") + print("PASS: Returned False and logged error") return 0 @@ -776,7 +776,7 @@ async def run_test(): print("ERROR: Should fetch history at 2-minute interval") return 1 else: - print("✓ Fetched history at 2-minute interval") + print("PASS: Fetched history at 2-minute interval") # Verify incremental fetch (with from_time) last_call = mock_ha.get_history_calls[-1] @@ -784,7 +784,7 @@ async def run_test(): print("ERROR: Should use from_time for incremental fetch") return 1 else: - print("✓ Used incremental fetch with from_time") + print("PASS: Used incremental fetch with from_time") return 0 @@ -829,7 +829,7 @@ async def run_test(): print("ERROR: Should log pruning message") return 1 else: - print("✓ Logged pruning message") + print("PASS: Logged pruning message") # Verify entries were pruned final_count = len(ha_history.history_data[entity_id]) @@ -837,7 +837,7 @@ async def run_test(): print("ERROR: Expected entries to be pruned") return 1 else: - print(f"✓ Pruned {initial_count - final_count} entries") + print(f"PASS: Pruned {initial_count - final_count} entries") return 0 @@ -875,7 +875,7 @@ async def run_test(): print("ERROR: Should not fetch history at 60 seconds") return 1 else: - print("✓ Correctly skipped update at non-trigger time") + print("PASS: Correctly skipped update at non-trigger time") return 0 @@ -904,7 +904,7 @@ def test_entity_history_store(my_predbat=None): print(f"ERROR: Expected 5 records in store, got {len(store)}") failed += 1 else: - print("✓ Store holds all records") + print("PASS: Store holds all records") # Test 2: round-trip states - numeric become floats, non-numeric preserved exactly output = store.to_records() @@ -914,7 +914,7 @@ def test_entity_history_store(my_predbat=None): print(f"ERROR: Expected states {expected}, got {states}") failed += 1 else: - print("✓ States round-trip correctly (numeric as float, non-numeric preserved)") + print("PASS: States round-trip correctly (numeric as float, non-numeric preserved)") # Test 3: round-trip timestamps parse back to the same instant times_in = [str2time(entry["last_updated"]) for entry in records] @@ -923,7 +923,7 @@ def test_entity_history_store(my_predbat=None): print(f"ERROR: Timestamps did not round-trip, in {times_in} out {times_out}") failed += 1 else: - print("✓ Timestamps round-trip to the same instant") + print("PASS: Timestamps round-trip to the same instant") # Test 4: attribute exceptions are preserved, shared attributes for the rest if output[2]["attributes"] != {"special": True}: @@ -933,7 +933,7 @@ def test_entity_history_store(my_predbat=None): print(f"ERROR: Expected empty shared attributes, got {output[0]['attributes']} and {output[4]['attributes']}") failed += 1 else: - print("✓ Attribute exceptions preserved, shared attributes returned for the rest") + print("PASS: Attribute exceptions preserved, shared attributes returned for the rest") # Test 5: mutation of materialised records does not corrupt the store output[0]["state"] = "corrupted" @@ -943,14 +943,14 @@ def test_entity_history_store(my_predbat=None): print(f"ERROR: Store was corrupted by mutating materialised records: {fresh[0]}") failed += 1 else: - print("✓ Materialised records are safe to mutate") + print("PASS: Materialised records are safe to mutate") # Test 6: indexing (including negative) and iteration match to_records if store[0]["state"] != 10.5 or store[-1]["state"] != 30.0 or [entry["state"] for entry in store] != expected: print(f"ERROR: Indexing/iteration mismatch: first {store[0]} last {store[-1]}") failed += 1 else: - print("✓ Indexing and iteration materialise records correctly") + print("PASS: Indexing and iteration materialise records correctly") # Test 7: prune drops older records and their exceptions store.prune((base_time + timedelta(minutes=12)).timestamp()) @@ -965,7 +965,7 @@ def test_entity_history_store(my_predbat=None): print(f"ERROR: Prune left stale attribute exceptions: {store.attribute_exceptions}") failed += 1 else: - print("✓ Prune drops old records and stale exceptions") + print("PASS: Prune drops old records and stale exceptions") # Test 8: records without a valid last_updated are not stored bad_store = EntityHistory.from_records([{"state": "1"}, {"state": "2", "last_updated": "junk"}, {"state": "3", "last_updated": base_time.isoformat()}]) @@ -973,7 +973,7 @@ def test_entity_history_store(my_predbat=None): print(f"ERROR: Expected 1 valid record stored, got {len(bad_store)}") failed += 1 else: - print("✓ Records without a valid last_updated are dropped") + print("PASS: Records without a valid last_updated are dropped") return failed @@ -1012,9 +1012,9 @@ def run_hahistory_tests(my_predbat): print("\n" + "=" * 80) if failed == 0: - print("✅ All HAHistory tests passed!") + print("PASS: All HAHistory tests passed!") else: - print(f"❌ {failed} HAHistory test(s) failed") + print(f"ERROR: {failed} HAHistory test(s) failed") print("=" * 80 + "\n") return failed diff --git a/apps/predbat/tests/test_hainterface_api.py b/apps/predbat/tests/test_hainterface_api.py index 65cf73496..e50aa711d 100644 --- a/apps/predbat/tests/test_hainterface_api.py +++ b/apps/predbat/tests/test_hainterface_api.py @@ -42,14 +42,14 @@ def test_hainterface_api_call_get(my_predbat=None): print("ERROR: Authorization header missing") failed += 1 else: - print("✓ GET request made correctly") + print("PASS: GET request made correctly") # Verify result if result != {"result": "success"}: print(f"ERROR: Wrong result: {result}") failed += 1 else: - print("✓ Result returned correctly") + print("PASS: Result returned correctly") return failed @@ -80,14 +80,14 @@ def test_hainterface_api_call_post(my_predbat=None): print("ERROR: Wrong JSON data") failed += 1 else: - print("✓ POST request made correctly") + print("PASS: POST request made correctly") # Verify result if result != {"status": "ok"}: print(f"ERROR: Wrong result: {result}") failed += 1 else: - print("✓ Result returned correctly") + print("PASS: Result returned correctly") return failed @@ -106,7 +106,7 @@ def test_hainterface_api_call_no_key(my_predbat=None): print(f"ERROR: Should return None, got {result}") failed += 1 else: - print("✓ Returned None when no API key") + print("PASS: Returned None when no API key") return failed @@ -139,7 +139,7 @@ def test_hainterface_api_call_supervisor(my_predbat=None): print("ERROR: Supervisor token not used") failed += 1 else: - print("✓ Supervisor endpoint called correctly") + print("PASS: Supervisor endpoint called correctly") return failed @@ -166,14 +166,14 @@ def test_hainterface_api_call_json_decode_error(my_predbat=None): print(f"ERROR: Should return None on JSON error, got {result}") failed += 1 else: - print("✓ Returned None on JSON decode error") + print("PASS: Returned None on JSON decode error") # Verify error count incremented if ha_interface.api_errors != 1: print(f"ERROR: api_errors should be 1, got {ha_interface.api_errors}") failed += 1 else: - print("✓ api_errors incremented") + print("PASS: api_errors incremented") return failed @@ -197,14 +197,14 @@ def test_hainterface_api_call_timeout(my_predbat=None): print(f"ERROR: Should return None on timeout, got {result}") failed += 1 else: - print("✓ Returned None on timeout") + print("PASS: Returned None on timeout") # Verify error count incremented if ha_interface.api_errors != 1: print(f"ERROR: api_errors should be 1, got {ha_interface.api_errors}") failed += 1 else: - print("✓ api_errors incremented") + print("PASS: api_errors incremented") return failed @@ -228,14 +228,14 @@ def test_hainterface_api_call_read_timeout(my_predbat=None): print(f"ERROR: Should return None on read timeout, got {result}") failed += 1 else: - print("✓ Returned None on ReadTimeout") + print("PASS: Returned None on ReadTimeout") # Verify error count incremented if ha_interface.api_errors != 1: print(f"ERROR: api_errors should be 1, got {ha_interface.api_errors}") failed += 1 else: - print("✓ api_errors incremented") + print("PASS: api_errors incremented") return failed @@ -273,7 +273,7 @@ def tracked_log(msg): print("ERROR: Warning should be suppressed in silent mode") failed += 1 else: - print("✓ Warning suppressed in silent mode") + print("PASS: Warning suppressed in silent mode") return failed @@ -303,7 +303,7 @@ def mock_fatal_error(): print("ERROR: fatal_error_occurred should be called at 10 errors") failed += 1 else: - print("✓ fatal_error_occurred called at error limit") + print("PASS: fatal_error_occurred called at error limit") return failed @@ -327,7 +327,7 @@ def test_hainterface_api_call_error_reset(my_predbat=None): print(f"ERROR: api_errors should be reset to 0, got {ha_interface.api_errors}") failed += 1 else: - print("✓ api_errors reset on success") + print("PASS: api_errors reset on success") return failed @@ -367,7 +367,7 @@ def test_hainterface_initialize_addon_check(my_predbat=None): print(f"ERROR: Slug should be 'predbat_addon', got {ha_interface.slug}") failed += 1 else: - print("✓ Addon slug detected correctly") + print("PASS: Addon slug detected correctly") return failed @@ -408,7 +408,7 @@ def test_hainterface_initialize_no_addon(my_predbat=None): print(f"ERROR: Slug should be None, got {ha_interface.slug}") failed += 1 else: - print("✓ Missing addon handled gracefully") + print("PASS: Missing addon handled gracefully") return failed @@ -444,7 +444,7 @@ def test_hainterface_get_history_basic(my_predbat=None): print("ERROR: API should be called") failed += 1 else: - print("✓ API called") + print("PASS: API called") # Verify result structure - get_history returns the list directly if not isinstance(result, list): @@ -457,7 +457,7 @@ def test_hainterface_get_history_basic(my_predbat=None): print(f"ERROR: History array should have 10 items, got {len(result[0])}") failed += 1 else: - print("✓ History data returned correctly") + print("PASS: History data returned correctly") return failed @@ -482,7 +482,7 @@ def test_hainterface_get_history_no_key(my_predbat=None): print(f"ERROR: Should return empty list from DB, got {result}") failed += 1 else: - print("✓ Used database when no API key") + print("PASS: Used database when no API key") return failed @@ -505,7 +505,7 @@ def test_hainterface_get_history_api_error(my_predbat=None): print(f"ERROR: Should return None on API error, got {result}") failed += 1 else: - print("✓ Returned None on API error") + print("PASS: Returned None on API error") return failed @@ -539,7 +539,7 @@ def test_hainterface_get_history_from_time(my_predbat=None): print(f"ERROR: from_time {expected_time_str} not in URL: {url}") failed += 1 else: - print("✓ from_time parameter used correctly") + print("PASS: from_time parameter used correctly") return failed @@ -570,9 +570,9 @@ def run_hainterface_api_tests(my_predbat): print("\n" + "=" * 80) if failed == 0: - print("✅ All HAInterface API tests passed!") + print("PASS: All HAInterface API tests passed!") else: - print(f"❌ {failed} HAInterface API test(s) failed") + print(f"ERROR: {failed} HAInterface API test(s) failed") print("=" * 80) return failed diff --git a/apps/predbat/tests/test_hainterface_lifecycle.py b/apps/predbat/tests/test_hainterface_lifecycle.py index cc406d574..bd3e0769f 100644 --- a/apps/predbat/tests/test_hainterface_lifecycle.py +++ b/apps/predbat/tests/test_hainterface_lifecycle.py @@ -46,7 +46,7 @@ def test_hainterface_initialize_ha_only(my_predbat=None): print("ERROR: websocket_active should be False initially") failed += 1 else: - print("✓ Initialized with HA only correctly") + print("PASS: Initialized with HA only correctly") return failed @@ -76,13 +76,13 @@ def test_hainterface_initialize_db_primary(my_predbat=None): print("ERROR: db_primary should be True") failed += 1 else: - print("✓ Initialized with DB primary correctly") + print("PASS: Initialized with DB primary correctly") if not any("SQL Lite database as primary" in log for log in mock_base.log_messages): print("ERROR: Should log DB primary mode") failed += 1 else: - print("✓ DB primary mode logged") + print("PASS: DB primary mode logged") return failed @@ -105,7 +105,7 @@ def test_hainterface_initialize_no_key_no_db(my_predbat=None): print("ERROR: Should raise ValueError") failed += 1 except ValueError: - print("✓ ValueError raised correctly") + print("PASS: ValueError raised correctly") return failed @@ -132,13 +132,13 @@ def test_hainterface_initialize_api_check_failed(my_predbat=None): print("ERROR: Should raise ValueError") failed += 1 except ValueError: - print("✓ ValueError raised on API check failure") + print("PASS: ValueError raised on API check failure") if not any("Unable to connect" in log for log in mock_base.log_messages): print("ERROR: Should log connection failure") failed += 1 else: - print("✓ Connection failure logged") + print("PASS: Connection failure logged") return failed @@ -167,7 +167,7 @@ def test_hainterface_initialize_db_mirror(my_predbat=None): print("ERROR: db_mirror_ha should be True") failed += 1 else: - print("✓ DB mirroring enabled correctly") + print("PASS: DB mirroring enabled correctly") return failed @@ -185,7 +185,7 @@ def test_hainterface_is_alive_not_started(my_predbat=None): print("ERROR: Should return False when not started") failed += 1 else: - print("✓ Returns False when not started") + print("PASS: Returns False when not started") return failed @@ -204,7 +204,7 @@ def test_hainterface_is_alive_no_websocket(my_predbat=None): print("ERROR: Should return False with ha_key but no websocket") failed += 1 else: - print("✓ Returns False without websocket") + print("PASS: Returns False without websocket") return failed @@ -222,7 +222,7 @@ def test_hainterface_is_alive_websocket_active(my_predbat=None): print("ERROR: Should return True with websocket active") failed += 1 else: - print("✓ Returns True with websocket active") + print("PASS: Returns True with websocket active") return failed @@ -240,7 +240,7 @@ def test_hainterface_is_alive_db_only(my_predbat=None): print("ERROR: Should return True in DB-only mode") failed += 1 else: - print("✓ Returns True in DB-only mode") + print("PASS: Returns True in DB-only mode") return failed @@ -262,7 +262,7 @@ def test_hainterface_wait_api_started_success(my_predbat=None): print("ERROR: Should return True when already started") failed += 1 else: - print("✓ Returns True when API started") + print("PASS: Returns True when API started") return failed @@ -294,13 +294,13 @@ def mock_sleep(seconds): print("ERROR: Should return False on timeout") failed += 1 else: - print("✓ Returns False on timeout") + print("PASS: Returns False on timeout") if not any("Failed to start" in log for log in mock_base.log_messages): print("ERROR: Should log timeout warning") failed += 1 else: - print("✓ Timeout warning logged") + print("PASS: Timeout warning logged") return failed @@ -335,7 +335,7 @@ def mock_get_side_effect(url, *args, **kwargs): print(f"ERROR: Expected slug 'predbat_addon', got '{slug}'") failed += 1 else: - print("✓ Slug retrieved correctly") + print("PASS: Slug retrieved correctly") return failed @@ -363,31 +363,31 @@ async def mock_socketloop(): print("ERROR: socketLoop should be called") failed += 1 else: - print("✓ socketLoop called") + print("PASS: socketLoop called") if ha_interface.websocket_active != True: print("ERROR: websocket_active should be True") failed += 1 else: - print("✓ websocket_active set to True") + print("PASS: websocket_active set to True") if ha_interface.api_started != False: print("ERROR: api_started should be False after exit") failed += 1 else: - print("✓ api_started set to False on exit") + print("PASS: api_started set to False on exit") if not any("Starting HA interface" in log for log in mock_base.log_messages): print("ERROR: Should log 'Starting HA interface'") failed += 1 else: - print("✓ Startup message logged") + print("PASS: Startup message logged") if not any("HA interface stopped" in log for log in mock_base.log_messages): print("ERROR: Should log 'HA interface stopped'") failed += 1 else: - print("✓ Stop message logged") + print("PASS: Stop message logged") return failed @@ -415,25 +415,25 @@ async def mock_sleep(delay): print("ERROR: Should log 'Starting Dummy HA interface'") failed += 1 else: - print("✓ Dummy startup message logged") + print("PASS: Dummy startup message logged") if ha_interface.api_started != False: print("ERROR: api_started should be False after exit") failed += 1 else: - print("✓ api_started set to False on exit") + print("PASS: api_started set to False on exit") if sleep_count[0] < 3: print(f"ERROR: Expected at least 3 sleep calls, got {sleep_count[0]}") failed += 1 else: - print(f"✓ Sleep called {sleep_count[0]} times") + print(f"PASS: Sleep called {sleep_count[0]} times") if not any("HA interface stopped" in log for log in mock_base.log_messages): print("ERROR: Should log 'HA interface stopped'") failed += 1 else: - print("✓ Stop message logged") + print("PASS: Stop message logged") return failed @@ -462,9 +462,9 @@ def run_hainterface_lifecycle_tests(my_predbat): print("\n" + "=" * 80) if failed == 0: - print("✅ All HAInterface lifecycle tests passed!") + print("PASS: All HAInterface lifecycle tests passed!") else: - print(f"❌ {failed} HAInterface lifecycle test(s) failed") + print(f"ERROR: {failed} HAInterface lifecycle test(s) failed") print("=" * 80) return failed diff --git a/apps/predbat/tests/test_hainterface_service.py b/apps/predbat/tests/test_hainterface_service.py index dde247d7e..fa219877c 100644 --- a/apps/predbat/tests/test_hainterface_service.py +++ b/apps/predbat/tests/test_hainterface_service.py @@ -49,7 +49,7 @@ def mock_call_service_websocket_command(domain, service, data): print(f"ERROR: Expected entity_id 'switch.test', got '{data.get('entity_id')}'") failed += 1 else: - print("✓ call_service_websocket_command called correctly") + print("PASS: call_service_websocket_command called correctly") ha_interface.call_service_websocket_command = original_method return failed @@ -89,7 +89,7 @@ async def mock_trigger_callback(data): print(f"ERROR: Expected value 42, got {data.get('service_data', {}).get('value')}") failed += 1 else: - print("✓ Loopback trigger_callback called correctly") + print("PASS: Loopback trigger_callback called correctly") return failed @@ -119,7 +119,7 @@ def background_processor(): # Verify command details if domain == "switch" and service == "turn_on" and service_data.get("entity_id") == "switch.test": - print("✓ Command queued correctly") + print("PASS: Command queued correctly") # Simulate socketLoop processing result_holder["success"] = True @@ -140,7 +140,7 @@ def background_processor(): failed += test_failed if test_failed == 0: - print("✓ Command processed successfully") + print("PASS: Command processed successfully") return failed @@ -193,8 +193,8 @@ def background_processor(): processor_thread.join(timeout=2.0) if result_data.get("checks_passed"): - print("✓ return_response removed from service_data") - print("✓ return_response flag set correctly") + print("PASS: return_response removed from service_data") + print("PASS: return_response flag set correctly") return result, 0 @@ -202,7 +202,7 @@ def background_processor(): failed += test_failed if test_failed == 0 and result == "test_value": - print("✓ Response returned correctly") + print("PASS: Response returned correctly") elif test_failed == 0: print(f"ERROR: Expected result 'test_value', got {result}") failed += 1 @@ -256,13 +256,13 @@ def background_processor(): print("ERROR: Should log warning on failure") failed += 1 else: - print("✓ Warning logged on failure") + print("PASS: Warning logged on failure") if result is not None: print(f"ERROR: Expected None result on failure, got {result}") failed += 1 else: - print("✓ Returned None on failure") + print("PASS: Returned None on failure") return failed @@ -300,13 +300,13 @@ async def test_command(): print("ERROR: Should log warning on timeout") failed += 1 else: - print("✓ Warning logged on timeout") + print("PASS: Warning logged on timeout") if result is not None: print(f"ERROR: Expected None result on timeout, got {result}") failed += 1 else: - print("✓ Returned None on timeout") + print("PASS: Returned None on timeout") return failed @@ -357,13 +357,13 @@ def background_processor(): print("ERROR: Should log warning on error") failed += 1 else: - print("✓ Warning logged on error") + print("PASS: Warning logged on error") if result is not None: print(f"ERROR: Expected None result on error, got {result}") failed += 1 else: - print("✓ Returned None on error") + print("PASS: Returned None on error") return failed @@ -425,21 +425,21 @@ def simulate_connection_drop(): print(f"ERROR: Caller blocked for {elapsed:.2f}s — should have returned promptly on connection_lost") failed += 1 else: - print(f"\u2713 Caller returned promptly in {elapsed:.3f}s (not the 2-minute timeout)") + print(f"PASS: Caller returned promptly in {elapsed:.3f}s (not the 2-minute timeout)") # Must log the 'connection_lost' failure warning if not any("Service call" in log and "failed" in log for log in mock_base.log_messages): print("ERROR: Should log warning that service call failed with connection_lost") failed += 1 else: - print("\u2713 Warning logged for connection_lost") + print("PASS: Warning logged for connection_lost") # Must return None (failure) if result is not None: print(f"ERROR: Expected None on connection_lost, got {result}") failed += 1 else: - print("\u2713 Returned None on connection_lost") + print("PASS: Returned None on connection_lost") return failed @@ -479,7 +479,7 @@ async def mock_trigger_callback(data): print(f"ERROR: Expected entity_id 'switch.test_switch'") failed += 1 else: - print("✓ CONFIG_ITEMS switch handled correctly") + print("PASS: CONFIG_ITEMS switch handled correctly") return failed @@ -518,7 +518,7 @@ async def mock_trigger_callback(data): print(f"ERROR: Expected value 42") failed += 1 else: - print("✓ CONFIG_ITEMS input_number handled correctly") + print("PASS: CONFIG_ITEMS input_number handled correctly") return failed @@ -557,7 +557,7 @@ async def mock_trigger_callback(data): print(f"ERROR: Expected option 'option2'") failed += 1 else: - print("✓ CONFIG_ITEMS select handled correctly") + print("PASS: CONFIG_ITEMS select handled correctly") return failed @@ -586,7 +586,7 @@ async def mock_trigger_callback(data): print(f"ERROR: Expected service 'turn_on'") failed += 1 else: - print("✓ Domain-based switch handled correctly") + print("PASS: Domain-based switch handled correctly") return failed @@ -618,7 +618,7 @@ async def mock_trigger_callback(data): print(f"ERROR: Expected value 50") failed += 1 else: - print("✓ Domain-based number handled correctly") + print("PASS: Domain-based number handled correctly") return failed @@ -650,7 +650,7 @@ async def mock_trigger_callback(data): print(f"ERROR: Expected option 'option1'") failed += 1 else: - print("✓ Domain-based select handled correctly") + print("PASS: Domain-based select handled correctly") return failed @@ -684,7 +684,7 @@ async def mock_trigger_callback(data): print("ERROR: Expected time '12:30:00'") failed += 1 else: - print("✓ Domain-based input_datetime time handled correctly") + print("PASS: Domain-based input_datetime time handled correctly") return failed @@ -722,7 +722,7 @@ async def mock_trigger_callback(data): print("ERROR: Expected datetime payload") failed += 1 else: - print("✓ input_datetime datetime payload handled correctly") + print("PASS: input_datetime datetime payload handled correctly") return failed @@ -760,7 +760,7 @@ async def mock_trigger_callback(data): print("ERROR: Expected date payload") failed += 1 else: - print("✓ input_datetime date payload handled correctly") + print("PASS: input_datetime date payload handled correctly") return failed @@ -794,7 +794,7 @@ async def mock_trigger_callback(data): print("ERROR: Expected value 'abc'") failed += 1 else: - print("✓ Domain-based input_text handled correctly") + print("PASS: Domain-based input_text handled correctly") return failed @@ -831,7 +831,7 @@ def mock_set_state(entity_id, state, attributes={}): print(f"ERROR: Expected state 123, got {state}") failed += 1 else: - print("✓ Sensor set_state called correctly") + print("PASS: Sensor set_state called correctly") ha_interface.set_state = original_set_state return failed @@ -877,7 +877,7 @@ async def mock_trigger_watch_list(entity_id, attributes, old_state, new_state): print(f"ERROR: Expected new_state 200") failed += 1 else: - print("✓ watch_list triggered on value change") + print("PASS: watch_list triggered on value change") return failed @@ -910,7 +910,7 @@ async def mock_trigger_watch_list(entity_id, attributes, old_state, new_state): print("ERROR: watch_list should not be triggered when value unchanged") failed += 1 else: - print("✓ watch_list not triggered when value unchanged") + print("PASS: watch_list not triggered when value unchanged") return failed @@ -946,9 +946,9 @@ def run_hainterface_service_tests(my_predbat): print("\n" + "=" * 80) if failed == 0: - print("✅ All HAInterface service tests passed!") + print("PASS: All HAInterface service tests passed!") else: - print(f"❌ {failed} HAInterface service test(s) failed") + print(f"ERROR: {failed} HAInterface service test(s) failed") print("=" * 80) return failed diff --git a/apps/predbat/tests/test_hainterface_state.py b/apps/predbat/tests/test_hainterface_state.py index a9633f8ab..1a39e439b 100644 --- a/apps/predbat/tests/test_hainterface_state.py +++ b/apps/predbat/tests/test_hainterface_state.py @@ -36,7 +36,7 @@ def test_hainterface_get_state_no_entity(my_predbat=None): print("ERROR: Should return full state_data dict") failed += 1 else: - print("✓ Returned full state_data dict") + print("PASS: Returned full state_data dict") return failed @@ -56,7 +56,7 @@ def test_hainterface_get_state_cached(my_predbat=None): print(f"ERROR: Expected '50', got '{result}'") failed += 1 else: - print("✓ Retrieved cached state value") + print("PASS: Retrieved cached state value") # Test with attribute result = ha_interface.get_state("sensor.battery", attribute="unit") @@ -64,7 +64,7 @@ def test_hainterface_get_state_cached(my_predbat=None): print(f"ERROR: Expected 'kWh', got '{result}'") failed += 1 else: - print("✓ Retrieved cached attribute") + print("PASS: Retrieved cached attribute") # Test with default for missing attribute result = ha_interface.get_state("sensor.battery", attribute="missing", default="default_value") @@ -72,7 +72,7 @@ def test_hainterface_get_state_cached(my_predbat=None): print(f"ERROR: Expected 'default_value', got '{result}'") failed += 1 else: - print("✓ Returned default for missing attribute") + print("PASS: Returned default for missing attribute") # Test raw mode result = ha_interface.get_state("sensor.battery", raw=True) @@ -80,7 +80,7 @@ def test_hainterface_get_state_cached(my_predbat=None): print("ERROR: raw=True should return full state dict") failed += 1 else: - print("✓ Returned raw state dict") + print("PASS: Returned raw state dict") return failed @@ -99,7 +99,7 @@ def test_hainterface_get_state_missing_entity(my_predbat=None): print(f"ERROR: Expected 'default_value', got '{result}'") failed += 1 else: - print("✓ Returned default for missing entity") + print("PASS: Returned default for missing entity") return failed @@ -119,7 +119,7 @@ def test_hainterface_get_state_case_insensitive(my_predbat=None): print("ERROR: Should be case-insensitive") failed += 1 else: - print("✓ Case-insensitive lookup works") + print("PASS: Case-insensitive lookup works") return failed @@ -141,7 +141,7 @@ def test_hainterface_get_state_db_mirror_tracking(my_predbat=None): print("ERROR: Entity should be added to db_mirror_list") failed += 1 else: - print("✓ Entity tracked in db_mirror_list") + print("PASS: Entity tracked in db_mirror_list") return failed @@ -168,7 +168,7 @@ def test_hainterface_update_state_item_basic(my_predbat=None): print("ERROR: State value incorrect") failed += 1 else: - print("✓ State cached correctly") + print("PASS: State cached correctly") return failed @@ -199,14 +199,14 @@ def test_hainterface_update_state_item_db_mirror(my_predbat=None): print("ERROR: DatabaseManager should be called") failed += 1 else: - print("✓ DatabaseManager set_state_db called") + print("PASS: DatabaseManager set_state_db called") # Verify state still cached if "sensor.battery" not in ha_interface.state_data: print("ERROR: State should also be cached") failed += 1 else: - print("✓ State cached alongside DB mirror") + print("PASS: State cached alongside DB mirror") return failed @@ -233,7 +233,7 @@ def test_hainterface_update_state_with_api(my_predbat=None): print("ERROR: API should be called") failed += 1 else: - print("✓ API called") + print("PASS: API called") # Verify state cached if "sensor.battery" not in ha_interface.state_data: @@ -243,7 +243,7 @@ def test_hainterface_update_state_with_api(my_predbat=None): print("ERROR: State value incorrect") failed += 1 else: - print("✓ State cached from API") + print("PASS: State cached from API") return failed @@ -271,7 +271,7 @@ def test_hainterface_update_state_db_primary(my_predbat=None): print("ERROR: DatabaseManager should be called") failed += 1 else: - print("✓ DatabaseManager queried") + print("PASS: DatabaseManager queried") # Verify state cached if "sensor.battery" not in ha_interface.state_data: @@ -281,7 +281,7 @@ def test_hainterface_update_state_db_primary(my_predbat=None): print("ERROR: State value incorrect") failed += 1 else: - print("✓ State cached from DB") + print("PASS: State cached from DB") return failed @@ -312,7 +312,7 @@ def test_hainterface_update_states_bulk(my_predbat=None): print("ERROR: API should be called") failed += 1 else: - print("✓ API called") + print("PASS: API called") # Verify both entities cached if len(ha_interface.state_data) != 2: @@ -322,7 +322,7 @@ def test_hainterface_update_states_bulk(my_predbat=None): print("ERROR: Missing entities in cache") failed += 1 else: - print("✓ All entities cached") + print("PASS: All entities cached") return failed @@ -353,14 +353,14 @@ def test_hainterface_update_states_db_primary(my_predbat=None): print("ERROR: DatabaseManager should be called") failed += 1 else: - print("✓ DatabaseManager queried") + print("PASS: DatabaseManager queried") # Verify entities cached if len(ha_interface.state_data) != 2: print(f"ERROR: Expected 2 entities, got {len(ha_interface.state_data)}") failed += 1 else: - print("✓ All entities cached from DB") + print("PASS: All entities cached from DB") return failed @@ -388,14 +388,14 @@ def test_hainterface_set_state_basic(my_predbat=None): print("ERROR: POST API should be called") failed += 1 else: - print("✓ POST API called") + print("PASS: POST API called") # Verify GET called (for update_state) if not mock_get.called: print("ERROR: GET API should be called for refresh") failed += 1 else: - print("✓ GET API called to refresh state") + print("PASS: GET API called to refresh state") return failed @@ -432,14 +432,14 @@ def test_hainterface_set_state_db_mirror(my_predbat=None): print("ERROR: Wrong parameters to set_state_db") failed += 1 else: - print("✓ DatabaseManager set_state_db called correctly") + print("PASS: DatabaseManager set_state_db called correctly") # Verify state cached if "sensor.battery" not in ha_interface.state_data: print("ERROR: State should be cached") failed += 1 else: - print("✓ State cached") + print("PASS: State cached") return failed @@ -468,14 +468,14 @@ def test_hainterface_set_state_db_primary(my_predbat=None): print("ERROR: API should not be called in DB primary mode") failed += 1 else: - print("✓ No API call in DB primary mode") + print("PASS: No API call in DB primary mode") # Verify DB called if len(mock_db.set_state_calls) == 0: print("ERROR: DatabaseManager should be called") failed += 1 else: - print("✓ DatabaseManager called") + print("PASS: DatabaseManager called") return failed @@ -499,7 +499,7 @@ def test_hainterface_db_mirror_list_tracking(my_predbat=None): print("ERROR: get_state should add to db_mirror_list") failed += 1 else: - print("✓ get_state adds to db_mirror_list") + print("PASS: get_state adds to db_mirror_list") # Test update_state adds to list ha_interface.db_mirror_list = {} @@ -509,7 +509,7 @@ def test_hainterface_db_mirror_list_tracking(my_predbat=None): print("ERROR: update_state should add to db_mirror_list") failed += 1 else: - print("✓ update_state adds to db_mirror_list") + print("PASS: update_state adds to db_mirror_list") # Test set_state adds to list ha_interface.db_mirror_list = {} @@ -523,7 +523,7 @@ def test_hainterface_db_mirror_list_tracking(my_predbat=None): print("ERROR: set_state should add to db_mirror_list") failed += 1 else: - print("✓ set_state adds to db_mirror_list") + print("PASS: set_state adds to db_mirror_list") return failed @@ -565,9 +565,9 @@ def run_hainterface_state_tests(my_predbat): print("\n" + "=" * 80) if failed == 0: - print("✅ All HAInterface state tests passed!") + print("PASS: All HAInterface state tests passed!") else: - print(f"❌ {failed} HAInterface state test(s) failed") + print(f"ERROR: {failed} HAInterface state test(s) failed") print("=" * 80 + "\n") return failed diff --git a/apps/predbat/tests/test_hainterface_websocket.py b/apps/predbat/tests/test_hainterface_websocket.py index eec916d27..0eb6bee52 100644 --- a/apps/predbat/tests/test_hainterface_websocket.py +++ b/apps/predbat/tests/test_hainterface_websocket.py @@ -84,7 +84,7 @@ async def mock_sleep(delay): print(f"ERROR: Expected access_token 'test_key'") failed += 1 else: - print("✓ Auth message sent correctly") + print("PASS: Auth message sent correctly") # Check subscriptions if len(mock_ws.send_json.call_args_list) < 3: @@ -99,14 +99,14 @@ async def mock_sleep(delay): print(f"ERROR: Expected state_changed event_type") failed += 1 else: - print("✓ State subscription sent correctly") + print("PASS: State subscription sent correctly") subscribe_service = mock_ws.send_json.call_args_list[2][0][0] if subscribe_service.get("event_type") != "call_service": print(f"ERROR: Expected call_service event_type") failed += 1 else: - print("✓ Service subscription sent correctly") + print("PASS: Service subscription sent correctly") # Note: api_started is set after subscriptions, but since we exit early with api_stop, # it may not be set in test environment. In real operation, socketLoop continues running. @@ -161,13 +161,13 @@ async def mock_sleep(delay): print("ERROR: Should log auth failure") failed += 1 else: - print("✓ Auth failure logged") + print("PASS: Auth failure logged") if ha_interface.websocket_active: print("ERROR: websocket_active should be False after auth failure") failed += 1 else: - print("✓ websocket_active set to False") + print("PASS: websocket_active set to False") return failed @@ -239,7 +239,7 @@ async def mock_sleep(delay): print(f"ERROR: Expected state '200', got '{state.get('state')}'") failed += 1 else: - print("✓ State updated correctly") + print("PASS: State updated correctly") if not mock_base.trigger_watch_list_calls: print("ERROR: trigger_watch_list should be called") @@ -253,7 +253,7 @@ async def mock_sleep(delay): print(f"ERROR: Expected new_state '200'") failed += 1 else: - print("✓ trigger_watch_list called correctly") + print("PASS: trigger_watch_list called correctly") return failed @@ -329,7 +329,7 @@ async def mock_sleep(delay): print(f"ERROR: Expected service 'turn_on'") failed += 1 else: - print("✓ trigger_callback called correctly") + print("PASS: trigger_callback called correctly") return failed @@ -385,7 +385,7 @@ async def mock_sleep(delay): print("ERROR: Should log result failure") failed += 1 else: - print("✓ Result failure logged") + print("PASS: Result failure logged") return failed @@ -438,7 +438,7 @@ async def mock_sleep(delay): print("ERROR: Should log reconnect attempt") failed += 1 else: - print("✓ Reconnect attempt logged") + print("PASS: Reconnect attempt logged") return failed @@ -493,14 +493,14 @@ async def mock_sleep(delay): print("ERROR: Should log error messages") failed += 1 else: - print("✓ Error logging present") + print("PASS: Error logging present") # fatal_error_occurred is called when error_count reaches 10 if not mock_base.fatal_error_occurred_called: print("WARN: fatal_error_occurred not called (may exit before check)") # Don't fail on this - timing dependent else: - print("✓ fatal_error_occurred called") + print("PASS: fatal_error_occurred called") return failed @@ -547,21 +547,21 @@ async def mock_sleep(delay): print(f"Logs: {[log for log in mock_base.log_messages if 'fail' in log.lower()]}") failed += 1 else: - print("✓ 'failed 10 times' logged") + print("PASS: 'failed 10 times' logged") # Check that fatal_error_occurred was called if not mock_base.fatal_error_occurred_called: print("ERROR: fatal_error_occurred should be called") failed += 1 else: - print("✓ fatal_error_occurred called") + print("PASS: fatal_error_occurred called") # Verify it made around 10 connection attempts if connection_attempts[0] < 9: print(f"ERROR: Expected ~10 connection attempts, got {connection_attempts[0]}") failed += 1 else: - print(f"✓ Made {connection_attempts[0]} connection attempts") + print(f"PASS: Made {connection_attempts[0]} connection attempts") return failed @@ -614,7 +614,7 @@ async def mock_sleep(delay): print("ERROR: Should log exception") failed += 1 else: - print("✓ Exception logged") + print("PASS: Exception logged") return failed @@ -651,7 +651,7 @@ async def mock_sleep(delay): print("ERROR: Should log startup exception") failed += 1 else: - print("✓ Startup exception logged") + print("PASS: Startup exception logged") return failed @@ -700,7 +700,7 @@ async def mock_sleep(delay): print("ERROR: update_pending should be True after connection") failed += 1 else: - print("✓ update_pending set to True") + print("PASS: update_pending set to True") return failed @@ -767,28 +767,28 @@ async def mock_sleep(delay): print("ERROR: threading.Event for queued command was not set after connection drop") failed += 1 else: - print("✓ threading.Event set when connection dropped with command in queue") + print("PASS: threading.Event set when connection dropped with command in queue") # The error must be 'connection_lost' if pre_queued_result.get("error") != "connection_lost": print(f"ERROR: Expected error='connection_lost', got {pre_queued_result.get('error')!r}") failed += 1 else: - print("✓ result_holder error set to 'connection_lost'") + print("PASS: result_holder error set to 'connection_lost'") # success must be False if pre_queued_result.get("success") is not False: print(f"ERROR: Expected success=False, got {pre_queued_result.get('success')!r}") failed += 1 else: - print("✓ result_holder success set to False") + print("PASS: result_holder success set to False") # The queue must be empty after cleanup if ha_interface.ws_command_queue: print("ERROR: ws_command_queue should be empty after connection drop cleanup") failed += 1 else: - print("✓ ws_command_queue cleared after connection drop") + print("PASS: ws_command_queue cleared after connection drop") return failed @@ -848,7 +848,7 @@ async def mock_sleep(delay): print("ERROR: service_registered event should be fired") failed += 1 else: - print("✓ service_registered event fired") + print("PASS: service_registered event fired") return failed @@ -876,9 +876,9 @@ def run_hainterface_websocket_tests(my_predbat): print("\n" + "=" * 80) if failed == 0: - print("✅ All HAInterface websocket tests passed!") + print("PASS: All HAInterface websocket tests passed!") else: - print(f"❌ {failed} HAInterface websocket test(s) failed") + print(f"ERROR: {failed} HAInterface websocket test(s) failed") print("=" * 80) return failed diff --git a/apps/predbat/tests/test_integer_config.py b/apps/predbat/tests/test_integer_config.py index f64fa6bc9..8325de014 100644 --- a/apps/predbat/tests/test_integer_config.py +++ b/apps/predbat/tests/test_integer_config.py @@ -46,7 +46,7 @@ def test_integer_config_entities(my_predbat): assert isinstance(ha_value, int), f"Value 2.0 with step=1 should convert to int, got {type(ha_value)}" assert ha_value == 2, f"Value should be 2, got {ha_value}" - print(f"✓ Float 2.0 with step=1 converts to integer 2") + print(f"PASS: Float 2.0 with step=1 converts to integer 2") # Test 2: String integer like "3" should convert to int ha_value = "3" @@ -57,7 +57,7 @@ def test_integer_config_entities(my_predbat): assert isinstance(ha_value, int), f"Value '3' with step=1 should convert to int, got {type(ha_value)}" assert ha_value == 3, f"Value should be 3, got {ha_value}" - print(f"✓ String '3' with step=1 converts to integer 3") + print(f"PASS: String '3' with step=1 converts to integer 3") # Test 3: String float like "4.0" should convert to int for integer step ha_value = "4.0" @@ -68,7 +68,7 @@ def test_integer_config_entities(my_predbat): assert isinstance(ha_value, int), f"Value '4.0' with step=1 should convert to int, got {type(ha_value)}" assert ha_value == 4, f"Value should be 4, got {ha_value}" - print(f"✓ String '4.0' with step=1 converts to integer 4") + print(f"PASS: String '4.0' with step=1 converts to integer 4") # Test 4: Float with decimal part should stay as float even for integer step ha_value = 4.5 @@ -79,7 +79,7 @@ def test_integer_config_entities(my_predbat): assert isinstance(ha_value, float), f"Value 4.5 should remain float, got {type(ha_value)}" assert ha_value == 4.5, f"Value should be 4.5, got {ha_value}" - print(f"✓ Float 4.5 stays as float 4.5 (has fractional part)") + print(f"PASS: Float 4.5 stays as float 4.5 (has fractional part)") # Test 5: Test with decimal step - should always stay as float # Find an entity with decimal step @@ -101,7 +101,7 @@ def test_integer_config_entities(my_predbat): # With decimal step, conversion shouldn't happen assert isinstance(ha_value, float), f"Value 5.0 with step={decimal_step} should stay float, got {type(ha_value)}" - print(f"✓ Float 5.0 with step={decimal_step} stays as float") + print(f"PASS: Float 5.0 with step={decimal_step} stays as float") else: print("! No decimal step entity found to test") @@ -124,9 +124,9 @@ def test_integer_config_entities(my_predbat): assert isinstance(ha_value, int), f"set_reserve_min 27.0 should convert to int, got {type(ha_value)}" assert ha_value == 27, f"Value should be 27, got {ha_value}" - print(f"✓ set_reserve_min: Float 27.0 with step=1 converts to integer 27") + print(f"PASS: set_reserve_min: Float 27.0 with step=1 converts to integer 27") - print("✓ Test passed: Integer conversion logic works correctly") + print("PASS: Test passed: Integer conversion logic works correctly") return False @@ -149,7 +149,7 @@ def test_expose_config_preserves_integer(my_predbat): # State should be integer 5, not float 5.0 assert isinstance(state_value, int), f"Exposed state should be int, got {type(state_value)}: {state_value}" assert state_value == 5, f"Exposed state should be 5, got {state_value}" - print(f"✓ expose_config correctly writes integer 5") + print(f"PASS: expose_config correctly writes integer 5") # Test with set_reserve_min my_predbat.expose_config("set_reserve_min", 30, force_ha=True) @@ -162,7 +162,7 @@ def test_expose_config_preserves_integer(my_predbat): assert isinstance(state_value, int), f"Exposed set_reserve_min should be int, got {type(state_value)}: {state_value}" assert state_value == 30, f"Exposed set_reserve_min should be 30, got {state_value}" - print(f"✓ expose_config correctly writes integer 30 for set_reserve_min") + print(f"PASS: expose_config correctly writes integer 30 for set_reserve_min") # Test with a float value to ensure floats still work # Find an entity with decimal step @@ -186,7 +186,7 @@ def test_expose_config_preserves_integer(my_predbat): assert isinstance(state_value, float), f"Exposed {decimal_name} should be float, got {type(state_value)}: {state_value}" assert state_value == 12.75, f"Exposed {decimal_name} should be 12.75, got {state_value}" - print(f"✓ expose_config correctly writes float 12.75 for {decimal_name}") + print(f"PASS: expose_config correctly writes float 12.75 for {decimal_name}") else: print("! No decimal step entity found to test") @@ -194,5 +194,5 @@ def test_expose_config_preserves_integer(my_predbat): for item in my_predbat.CONFIG_ITEMS: my_predbat.expose_config(item.get("name"), item.get("default"), force_ha=True) - print("✓ Test passed: expose_config preserves type correctly") + print("PASS: Test passed: expose_config preserves type correctly") return False diff --git a/apps/predbat/tests/test_kraken.py b/apps/predbat/tests/test_kraken.py index 76db84110..75c5d9da3 100644 --- a/apps/predbat/tests/test_kraken.py +++ b/apps/predbat/tests/test_kraken.py @@ -394,8 +394,8 @@ def test_run_refetches_rates_when_stale(): api = make_kraken_api() api.current_tariff = {"tariff_code": "E-1R-VAR-01", "product_code": "VAR-01"} - api.tariff_fetched_at = datetime.now() # fresh tariff → no re-discovery - api.rates_fetched_at = datetime.now() - timedelta(minutes=KRAKEN_RATES_REFRESH_MINUTES + 5) # stale → re-fetch + api.tariff_fetched_at = datetime.now() # fresh tariff -> no re-discovery + api.rates_fetched_at = datetime.now() - timedelta(minutes=KRAKEN_RATES_REFRESH_MINUTES + 5) # stale -> re-fetch api.async_find_tariffs = AsyncMock(return_value=None) api.async_fetch_rates = AsyncMock(return_value=[{"value_inc_vat": 24.5}]) api.async_fetch_standing_charges = AsyncMock(return_value=53.0) @@ -546,7 +546,7 @@ def test_standing_charge_converts_pence_to_pounds(): with patch("aiohttp.ClientSession", return_value=mock_session): result = asyncio.run(api.async_fetch_standing_charges()) - # 53.0 pence → 0.53 pounds + # 53.0 pence -> 0.53 pounds assert result == 0.53 @@ -644,7 +644,7 @@ def test_fetch_standing_charges_graphql_returns_value(): result = asyncio.run(api.async_fetch_standing_charges_graphql("1900000000456")) - # 61.95 pence/day → 0.6195 pounds/day + # 61.95 pence/day -> 0.6195 pounds/day assert result is not None assert abs(result - 0.6195) < 1e-6 @@ -999,7 +999,7 @@ async def capture_query(query, context): expected_start = midnight_utc - timedelta(days=1) assert abs((start_dt - expected_start).total_seconds()) < 60, f"start_at {start_dt} not close to expected {expected_start}" - # forecast_hours=72 → forecast_days=3 → end_at must be midnight + 4 days + # forecast_hours=72 -> forecast_days=3 -> end_at must be midnight + 4 days expected_end = midnight_utc + timedelta(days=4) assert abs((end_dt - expected_end).total_seconds()) < 60, f"end_at {end_dt} not close to expected {expected_end}" @@ -1007,7 +1007,7 @@ async def capture_query(query, context): def test_fetch_rates_graphql_window_default_forecast_hours(): """async_fetch_rates_graphql() uses forecast_hours default of 48 when not configured. - forecast_hours=48 → forecast_days=2 → end_at = midnight + 3 days. + forecast_hours=48 -> forecast_days=2 -> end_at = midnight + 3 days. """ from datetime import datetime, timedelta, timezone import re @@ -1035,7 +1035,7 @@ async def capture_query(query, context): now = datetime.now(timezone.utc) midnight_utc = now.replace(hour=0, minute=0, second=0, microsecond=0) - # default forecast_hours=48 → forecast_days=2 → end_at must be midnight + 3 days + # default forecast_hours=48 -> forecast_days=2 -> end_at must be midnight + 3 days expected_end = midnight_utc + timedelta(days=3) assert abs((end_dt - expected_end).total_seconds()) < 60, f"end_at {end_dt} not close to expected {expected_end}" @@ -1296,7 +1296,7 @@ def test_build_rest_auth_uses_basic_auth_for_api_key(): """_build_rest_auth returns HTTP Basic auth (API key as username) in api_key mode.""" import aiohttp - api = make_kraken_api() # api_key mode → _api_key = "test-key" + api = make_kraken_api() # api_key mode -> _api_key = "test-key" auth, headers = asyncio.run(api._build_rest_auth()) assert isinstance(auth, aiohttp.BasicAuth) assert auth.login == "test-key" @@ -1322,7 +1322,7 @@ def test_fetch_rates_404_retries_authenticated_and_succeeds(): Reproduces the live EDF case: E-1R-EDF_EXPORT_SEG_12M_HH-B (a private product) 404s unauthenticated, so the authenticated retry is what actually recovers the export rates. """ - api = make_kraken_api() # api_key mode → authenticated retry uses HTTP Basic auth + api = make_kraken_api() # api_key mode -> authenticated retry uses HTTP Basic auth export_tariff = {"tariff_code": "E-1R-EDF_EXPORT_SEG_12M_HH-B", "product_code": "EDF_EXPORT_SEG_12M"} api.export_tariff = export_tariff api.export_mpan = "1170001829927" @@ -1404,7 +1404,7 @@ def test_connection_nodes_extracts_edges(): assert KrakenAPI._connection_nodes(conn) == [{"value": 1}, {"value": 2}] # Backward-compat: a plain list is returned unchanged. assert KrakenAPI._connection_nodes([{"value": 3}]) == [{"value": 3}] - # Empty / missing shapes → []. + # Empty / missing shapes -> []. assert KrakenAPI._connection_nodes(None) == [] assert KrakenAPI._connection_nodes({}) == [] assert KrakenAPI._connection_nodes({"edges": []}) == [] @@ -1551,7 +1551,7 @@ def test_run_first_restores_cache_and_skips_fetch(): result = asyncio.run(api.run(0, True)) assert result is True - # Fresh cache → no API calls at all. + # Fresh cache -> no API calls at all. api.async_find_tariffs.assert_not_called() api.async_fetch_rates.assert_not_called() api.async_fetch_standing_charges.assert_not_called() @@ -1603,7 +1603,7 @@ def test_normalize_dispatches_field_mapping(): planned = api._normalize_dispatches( [ {"start": "2026-07-08T00:00:00Z", "end": "2026-07-08T00:30:00Z", "type": "SMART", "energyAddedKwh": "2.5"}, - {"start": None, "end": "x"}, # missing start → dropped + {"start": None, "end": "x"}, # missing start -> dropped ], completed=False, ) @@ -1627,7 +1627,7 @@ def test_normalize_dispatches_trims_in_progress_planned(): planned = api._normalize_dispatches([{"start": start, "end": end, "type": "SMART", "energyAddedKwh": 10.0}], completed=False) assert len(planned) == 1 - # ~half the window remains → ~half the energy, and start advanced to ~now. + # ~half the window remains -> ~half the energy, and start advanced to ~now. assert 4.5 < planned[0]["charge_in_kwh"] < 5.5 trimmed_start = datetime.fromisoformat(planned[0]["start"]) assert abs((trimmed_start - now).total_seconds()) < 60 @@ -1715,8 +1715,8 @@ def test_run_fetches_and_wires_dispatches(): api = make_kraken_api() api.current_tariff = {"tariff_code": "E-1R-VAR-01", "product_code": "VAR-01"} api.import_rates = [{"value_inc_vat": 24.5}] - api.tariff_fetched_at = datetime.now() # fresh → no tariff work / device re-discovery - api.rates_fetched_at = datetime.now() # fresh → no rate work + api.tariff_fetched_at = datetime.now() # fresh -> no tariff work / device re-discovery + api.rates_fetched_at = datetime.now() # fresh -> no rate work api.dispatch_fetched_at = None # dispatch due api.intelligent_devices = {"dev-1": {"device_id": "dev-1", "planned_dispatches": [], "completed_dispatches": []}} api.async_find_tariffs = AsyncMock(return_value=None) @@ -1753,7 +1753,7 @@ def test_run_no_devices_does_not_save_cache_every_cycle(): api.import_rates = [{"value_inc_vat": 24.5}] api.tariff_fetched_at = datetime.now() # fresh api.rates_fetched_at = datetime.now() # fresh - api.dispatch_fetched_at = None # no devices → never set → dispatch_due would be True + api.dispatch_fetched_at = None # no devices -> never set -> dispatch_due would be True api.intelligent_devices = {} # common no-EV account api.async_find_tariffs = AsyncMock(return_value=None) api.save_kraken_cache = AsyncMock() diff --git a/apps/predbat/tests/test_load_ml.py b/apps/predbat/tests/test_load_ml.py index c8eea6dd7..e99165092 100644 --- a/apps/predbat/tests/test_load_ml.py +++ b/apps/predbat/tests/test_load_ml.py @@ -817,7 +817,7 @@ def _test_curriculum_training(): now_utc = datetime.now(timezone.utc) - # ── Happy-path: 28 days of data → 3 intermediate passes + 1 final ──────── + # ── Happy-path: 28 days of data -> 3 intermediate passes + 1 final ──────── np.random.seed(42) load_data_28 = _create_synthetic_load_data(n_days=28, now_utc=now_utc) @@ -881,7 +881,7 @@ def _test_curriculum_training(): now_utc, epochs=3, patience=3, - curriculum_window_days=14, # Requires 14 days; only 5 days available → fallback + curriculum_window_days=14, # Requires 14 days; only 5 days available -> fallback curriculum_step_days=7, ) # Fallback should complete without crashing and return a MAE or None (if still @@ -1078,7 +1078,7 @@ async def mock_save_database_history(): component.save_database_history = mock_save_database_history # ── Run 1 (first=True, seconds=0) ─────────────────────────────────────── - # Expect: data fetched and stored, but training deferred → no save, no training. + # Expect: data fetched and stored, but training deferred -> no save, no training. component._fetch_load_data = AsyncMock(return_value=(fetch_data_1, 28, 5.0, None, None, None, None)) result = await component.run(seconds=0, first=True) @@ -1093,13 +1093,13 @@ async def mock_save_database_history(): assert save_call_count[0] == 0, f"save_database_history should NOT be called on first run (deferred), called {save_call_count[0]} times" assert training_call_count[0] == 0, f"Training should NOT run on first run, ran {training_call_count[0]} times" - print(" \u2713 Run 1: data populated, training deferred, no save") + print(" PASS: Run 1: data populated, training deferred, no save") # ── Advance time by ELAPSED_MINUTES ───────────────────────────────────── mock_base.now_utc = mock_base.now_utc + timedelta(minutes=ELAPSED_MINUTES) # ── Run 2 (first=False, seconds=30) ───────────────────────────────────── - # last_train_time is None → retrain_age_seconds = RETRAIN_INTERVAL_SECONDS → should_train=True + # last_train_time is None -> retrain_age_seconds = RETRAIN_INTERVAL_SECONDS -> should_train=True # Expect: shift old keys, merge fresh data, run initial training, save once. component._fetch_load_data = AsyncMock(return_value=(fetch_data_2, 7, 3.0, None, None, None, None)) @@ -1111,9 +1111,9 @@ async def mock_save_database_history(): assert save_call_count[0] == 1, f"save_database_history should be called once after second run, called {save_call_count[0]} times" # ── Verify time-shift: old keys shifted forward by ELAPSED_MINUTES ── - assert component.load_data.get(SHIFT) == 0.1, f"Old key 0 → key {SHIFT} after shift, got {component.load_data.get(SHIFT)}" - assert component.load_data.get(500 + SHIFT) == 0.1, f"Old key 500 → key {500 + SHIFT}, got {component.load_data.get(500 + SHIFT)}" - assert component.load_data.get(2000 + SHIFT) == 0.05, f"Old key 2000 → key {2000 + SHIFT}, got {component.load_data.get(2000 + SHIFT)}" + assert component.load_data.get(SHIFT) == 0.1, f"Old key 0 -> key {SHIFT} after shift, got {component.load_data.get(SHIFT)}" + assert component.load_data.get(500 + SHIFT) == 0.1, f"Old key 500 -> key {500 + SHIFT}, got {component.load_data.get(500 + SHIFT)}" + assert component.load_data.get(2000 + SHIFT) == 0.05, f"Old key 2000 -> key {2000 + SHIFT}, got {component.load_data.get(2000 + SHIFT)}" assert component.load_data.get(2000) is None, f"Key 2000 should be gone after shift (moved to {2000 + SHIFT})" # ── Verify fresh data from fetch_data_2 is at the expected keys ── @@ -1121,15 +1121,15 @@ async def mock_save_database_history(): actual = component.load_data.get(minute) assert actual == expected_value, f"At minute {minute}: expected {expected_value} (fetch_data_2) but got {actual}" - print(" \u2713 Run 2: initial training fired, keys shifted, fresh data merged, save called") + print(" PASS: Run 2: initial training fired, keys shifted, fresh data merged, save called") # ── Advance time by another ELAPSED_MINUTES ────────────────────────────── mock_base.now_utc = mock_base.now_utc + timedelta(minutes=ELAPSED_MINUTES) # ── Run 3 (first=False, seconds=PREDICTION_INTERVAL_SECONDS) ───────────── # last_train_time = 30 min ago (set in mock_do_training to component.now_utc of Run 2). - # retrain_age_seconds = 30*60 = 1800 < RETRAIN_INTERVAL_SECONDS (7200) → should_train=False. - # seconds % PREDICTION_INTERVAL_SECONDS == 0 → should_fetch=True. + # retrain_age_seconds = 30*60 = 1800 < RETRAIN_INTERVAL_SECONDS (7200) -> should_train=False. + # seconds % PREDICTION_INTERVAL_SECONDS == 0 -> should_fetch=True. # Expect: fetch+predict+save only, no training. component._fetch_load_data = AsyncMock(return_value=(fetch_data_3, 7, 3.0, None, None, None, None)) @@ -1139,7 +1139,7 @@ async def mock_save_database_history(): assert training_call_count[0] == 1, f"Training should NOT run again on third run (model too fresh), ran {training_call_count[0]} times total" assert save_call_count[0] == 2, f"save_database_history should be called again on third run, called {save_call_count[0]} times" - print(" \u2713 Run 3: fetch-only cycle (model fresh), save called without retraining") + print(" PASS: Run 3: fetch-only cycle (model fresh), save called without retraining") run_async(run_test()) @@ -1913,7 +1913,7 @@ async def test_basic_fetch(): assert result_age == 28, f"Expected 28 days, got {result_age}" assert len(result_data) > 0, "Load data should not be empty" assert result_now >= 0, f"Current load should be non-negative, got {result_now}" - print(" ✓ Basic fetch successful") + print(" PASS: Basic fetch successful") # Test 2: Missing sensor (should return None) async def test_missing_sensor(): @@ -1949,7 +1949,7 @@ def get_arg(self, key, default=None, indirect=True, combine=False, attribute=Non assert result_data is None, "Should return None when sensor missing" assert result_age == 0, "Age should be 0 when sensor missing" assert result_now == 0, "Current load should be 0 when sensor missing" - print(" ✓ Missing sensor handled correctly") + print(" PASS: Missing sensor handled correctly") # Test 3: Car charging subtraction async def test_car_charging_subtraction(): @@ -2031,7 +2031,7 @@ def mock_get_arg_with_car(key, default=None, indirect=True, combine=False, attri expected = 0.7 # One step of (1.0 - 0.3) in per-step format assert abs(value_1435 - expected) < 0.01, f"At minute 1435, expected ~{expected} kWh, got {value_1435:.4f}" - print(" ✓ Car charging subtraction works") + print(" PASS: Car charging subtraction works") # Test 3b: Car charging threshold-based detection (without sensor) async def test_car_charging_threshold_detection(): @@ -2130,7 +2130,7 @@ def mock_get_arg_threshold(key, default=None, indirect=True, combine=False, attr expected = 0.375 # Car detected, subtract estimate assert abs(value_1420 - expected) < 0.01, f"At minute 1420 (high load again), expected ~{expected} kWh, got {value_1420:.4f}" - print(" ✓ Car charging threshold detection works") + print(" PASS: Car charging threshold detection works") # Test 4: Load power fill async def test_load_power_fill(): @@ -2171,7 +2171,7 @@ def mock_get_arg_with_power(key, default=None, indirect=True, combine=False, att assert result_data is not None, "Should return load data" assert mock_base_with_power.fill_load_from_power.called, "fill_load_from_power should be called" assert result_now >= 0, f"Current load should be non-negative, got {result_now}" - print(" ✓ Load power fill invoked") + print(" PASS: Load power fill invoked") # Test 5: Exception handling async def test_exception_handling(): @@ -2194,7 +2194,7 @@ async def test_exception_handling(): assert result_data is None, "Should return None on exception" assert result_age == 0, "Age should be 0 on exception" assert result_now == 0, "Current load should be 0 on exception" - print(" ✓ Exception handling works") + print(" PASS: Exception handling works") # Test 6: Empty load data async def test_empty_load_data(): @@ -2218,7 +2218,7 @@ async def test_empty_load_data(): assert result_data is None, "Should return None when load data is empty" assert result_age == 0, "Age should be 0 when load data is empty" assert result_now == 0, "Current load should be 0 when load data is empty" - print(" ✓ Empty load data handled correctly") + print(" PASS: Empty load data handled correctly") # Test 7: Temperature data fetch with future predictions only async def test_temperature_data_fetch(): @@ -2275,7 +2275,7 @@ def mock_get_state_wrapper_side_effect(entity_id, default=None, attribute=None, # Verify get_state_wrapper was called correctly assert mock_base_with_temp.get_state_wrapper.called, "get_state_wrapper should be called" - print(" ✓ Temperature data fetch (future predictions) works") + print(" PASS: Temperature data fetch (future predictions) works") # Test 8: Temperature data with no predictions (None return) async def test_temperature_no_data(): @@ -2305,7 +2305,7 @@ async def test_temperature_no_data(): assert isinstance(result_temp, dict), "Temperature data should be a dict" assert len(result_temp) == 0, "Temperature data should be empty when no predictions available" - print(" ✓ Temperature data with no predictions handled correctly") + print(" PASS: Temperature data with no predictions handled correctly") # Test 9: Step-size calculation correctness (bug #3384 regression test) async def test_step_size_calculation(): @@ -2400,7 +2400,7 @@ def mock_get_arg_no_car_hold(key, default=None, indirect=True, combine=False, at # Verify current load is reasonable (not near-zero) assert result_now > 0.05, f"Current load should be > 0.05 kWh (got {result_now:.4f}). Bug #3384 would cause near-zero." - print(" ✓ Step-size calculation correct (bug #3384 regression test passed)") + print(" PASS: Step-size calculation correct (bug #3384 regression test passed)") # Run all sub-tests print(" Running LoadMLComponent._fetch_load_data tests:") @@ -2589,7 +2589,7 @@ def mock_dashboard_item(entity_id, state, attributes, app): assert attrs["icon"] == "mdi:chart-line", "icon should be 'mdi:chart-line'" assert attrs2["icon"] == "mdi:chart-line", "icon should be 'mdi:chart-line'" - print(" ✓ Entity published with correct attributes") + print(" PASS: Entity published with correct attributes") # Test 2: Empty predictions mock_base.dashboard_calls = [] @@ -2602,7 +2602,7 @@ def mock_dashboard_item(entity_id, state, attributes, app): assert call2["state"] == 0, "State should be 0 with empty predictions" assert call["attributes"]["results"] == {}, "results should be empty dict" - print(" ✓ Empty predictions handled correctly") + print(" PASS: Empty predictions handled correctly") print(" All _publish_entity tests passed!") @@ -2920,7 +2920,7 @@ def get_arg(self, key, default=None, indirect=True, combine=False, attribute=Non now_utc = datetime.now(timezone.utc) load_data = _create_synthetic_load_data(n_days=7, now_utc=now_utc) - # --- Part 1: model with a known timestamp → last_train_time set to that timestamp --- + # --- Part 1: model with a known timestamp -> last_train_time set to that timestamp --- predictor = LoadPredictor(learning_rate=0.01) predictor.train(load_data, now_utc, is_initial=True, epochs=2, time_decay_days=7) known_timestamp = predictor.training_timestamp @@ -2935,7 +2935,7 @@ def get_arg(self, key, default=None, indirect=True, combine=False, attribute=Non assert component.model_valid is True, "Model should be marked valid" assert component.initial_training_done is True, "initial_training_done should be True" - # --- Part 2: model with no timestamp → last_train_time stays None (triggers retrain) --- + # --- Part 2: model with no timestamp -> last_train_time stays None (triggers retrain) --- predictor2 = LoadPredictor(learning_rate=0.01) predictor2.train(load_data, now_utc, is_initial=True, epochs=2, time_decay_days=7) predictor2.training_timestamp = None # Simulate a pre-timestamp model diff --git a/apps/predbat/tests/test_minute_array.py b/apps/predbat/tests/test_minute_array.py index 6ee51814b..0b1f7e7ec 100644 --- a/apps/predbat/tests/test_minute_array.py +++ b/apps/predbat/tests/test_minute_array.py @@ -128,7 +128,7 @@ def test_minute_array(my_predbat): # Test 11: pad=False sizing — max(dict.keys()) + 2 captures all accumulated keys print("Test 11: pad=False size derived from max(dict.keys()) + 2") # Simulate two-entity accumulation: entity A adds keys 0..4, entity B adds keys 0..2 - # Final dict has keys 0..4 but age_days would reflect entity B (2 days → 2*1440 keys) + # Final dict has keys 0..4 but age_days would reflect entity B (2 days -> 2*1440 keys) big_dict = {i: float(i) for i in range(5)} size = max(big_dict.keys()) + 2 # 4 + 2 = 6 ma11 = MinuteArray(big_dict, size) diff --git a/apps/predbat/tests/test_minute_data.py b/apps/predbat/tests/test_minute_data.py index 9272b9c51..28a4a8301 100644 --- a/apps/predbat/tests/test_minute_data.py +++ b/apps/predbat/tests/test_minute_data.py @@ -42,10 +42,10 @@ def test_minute_data(my_predbat): # older period (transition+1..minutes_to) is filled with last_state. # Gap-fill propagates newest state (30.0) back to minute 0. # minute 0: gap-filled with newest=30.0 - # minute 5: transition minute for item@12:00 → state=30.0 - # minute 34: fill from item@12:00 → last_state=20.0 - # minute 35: transition minute for item@11:30 → state=20.0 - # minute 65: transition minute for item@11:00 → state=10.0 + # minute 5: transition minute for item@12:00 -> state=30.0 + # minute 34: fill from item@12:00 -> last_state=20.0 + # minute 35: transition minute for item@11:30 -> state=20.0 + # minute 65: transition minute for item@11:00 -> state=10.0 expected_points = [30.0, 30.0, 20.0, 20.0, 10.0] # Check that we have data and it's reasonable @@ -428,7 +428,7 @@ def test_minute_data(my_predbat): # Item@11:00 (2.5kW=2500W): first item, transition at minute 60 only (minutes==minutes_to) # Gap-fill propagates newest (3500W) back to minute 0. - # Minute 15: transition minute for item@11:45 → state=3500W + # Minute 15: transition minute for item@11:45 -> state=3500W if 15 not in result_data: print("ERROR: kW to W conversion test failed - no data at minute 15") failed = True @@ -436,7 +436,7 @@ def test_minute_data(my_predbat): print("ERROR: kW to W conversion test failed - expected 3500 W at minute 15, got {}".format(result_data[15])) failed = True - # Minute 30: transition minute for item@11:30 → state=3000W + # Minute 30: transition minute for item@11:30 -> state=3000W if 30 not in result_data: print("ERROR: kW to W conversion test failed - no data at minute 30") failed = True @@ -444,7 +444,7 @@ def test_minute_data(my_predbat): print("ERROR: kW to W conversion test failed - expected 3000 W at minute 30, got {}".format(result_data[30])) failed = True - # Minute 45: fill from item@11:30 → last_state=2500W + # Minute 45: fill from item@11:30 -> last_state=2500W if 45 not in result_data: print("ERROR: kW to W conversion test failed - no data at minute 45") failed = True @@ -865,9 +865,9 @@ def test_minute_data_no_smoothing_backwards(my_predbat): # ------------------------------------------------------------------ # now = 12:00, item-1 at 11:30 (30 min ago), item-2 at 11:50 (10 min ago). # Expected layout (minutes ago from now): - # 0-9 → 20.0 (newest state, gap-filled by the post-process logic) - # 10 → 20.0 (transition minute for item-2, state written explicitly) - # 11-30 → 10.0 (older-period fill with last_state from item-1) + # 0-9 -> 20.0 (newest state, gap-filled by the post-process logic) + # 10 -> 20.0 (transition minute for item-2, state written explicitly) + # 11-30 -> 10.0 (older-period fill with last_state from item-1) print("Test 1: two entries – transition minute and older-period fill") now = datetime(2024, 10, 4, 12, 0, 0, tzinfo=utc) history = [ @@ -902,9 +902,9 @@ def test_minute_data_no_smoothing_backwards(my_predbat): # ------------------------------------------------------------------ # now = 12:00, items at 11:00 (60 min), 11:20 (40 min), 11:40 (20 min) # Expected: - # 0-20 → 20.0 (newest; transition at 20 written explicitly then gap-filled forward) - # 21-40 → 10.0 (fill for item-3's older period) - # 41-60 → 5.0 (fill for item-2's older period) + # 0-20 -> 20.0 (newest; transition at 20 written explicitly then gap-filled forward) + # 21-40 -> 10.0 (fill for item-3's older period) + # 41-60 -> 5.0 (fill for item-2's older period) print("Test 2: three entries – chained transitions") history3 = [ {"state": "5.0", "last_updated": "2024-10-04T11:00:00+00:00"}, # 60 min ago @@ -916,7 +916,7 @@ def test_minute_data_no_smoothing_backwards(my_predbat): checks3 = { 0: 20.0, # gap-filled with newest 20: 20.0, # transition minute for item-3 - 21: 10.0, # start of older-period fill (item-3 → item-2's state) + 21: 10.0, # start of older-period fill (item-3 -> item-2's state) 40: 10.0, # last minute of that fill (item-2 transition is overwritten by fill from item-3) 41: 5.0, # start of item-2's older-period fill 60: 5.0, # last minute (item-1 set minutes==minutes_to, then overwritten to 5.0 by item-2's fill) @@ -1009,8 +1009,8 @@ def test_minute_data_no_smoothing_forward(my_predbat): # ------------------------------------------------------------------ # With to_key set, the gap-fill logic does NOT run (guarded by `if not to_key:`). # Only the explicitly-defined half-open windows are populated. - # Window 1: last_updated=12:10, last_changed=12:20 → mdata[10..19] = 10.0 - # Window 2: last_updated=12:20, last_changed=12:40 → mdata[20..39] = 20.0 + # Window 1: last_updated=12:10, last_changed=12:20 -> mdata[10..19] = 10.0 + # Window 2: last_updated=12:20, last_changed=12:40 -> mdata[20..39] = 20.0 # Anything outside those ranges is absent from the result dict. print("Test 1: forward with to_key – state fills half-open window [start, end)") history_fwd = [ @@ -1129,19 +1129,19 @@ def test_minute_data_no_smoothing_forward(my_predbat): smoothing=False, ) - # Window 1 → 5..9 = 3.0 + # Window 1 -> 5..9 = 3.0 for m in range(5, 10): if result_gap.get(m) != 3.0: print(f"ERROR: Test 4 failed – minute {m} (window 1) expected 3.0, got {result_gap.get(m)}") failed = True break - # Gap → 10..19 is absent because to_key prevents gap-fill + # Gap -> 10..19 is absent because to_key prevents gap-fill for m in range(10, 20): if result_gap.get(m) is not None: print(f"ERROR: Test 4 failed – minute {m} (gap) expected None (no fill with to_key), got {result_gap.get(m)}") failed = True break - # Window 2 → 20..29 = 9.0 + # Window 2 -> 20..29 = 9.0 for m in range(20, 30): if result_gap.get(m) != 9.0: print(f"ERROR: Test 4 failed – minute {m} (window 2) expected 9.0, got {result_gap.get(m)}") diff --git a/apps/predbat/tests/test_ml_load_fallback.py b/apps/predbat/tests/test_ml_load_fallback.py new file mode 100644 index 000000000..df7500cba --- /dev/null +++ b/apps/predbat/tests/test_ml_load_fallback.py @@ -0,0 +1,69 @@ +from tests.test_infra import TestHAInterface +from fetch import Fetch +from datetime import datetime, timezone + + +class TestFetchMLFallback(Fetch): + def __init__(self, ha_interface, base): + self.ha_interface = ha_interface + self.base = base + self.prefix = "predbat" + self.forecast_days = 2 + self.midnight_utc = datetime.now(timezone.utc) + self.minutes_now = 0 + self.queried_results = False + + def get_state_wrapper(self, entity_id, attribute=None, default=None): + if attribute is None: + if "inactive_test" in entity_id: + return "error" + else: + return "active" + if attribute == "results": + self.queried_results = True + # Return an empty dict so minute_data doesn't crash on mocked data + return {} + return default + + def log(self, msg): + pass + + +def run_ml_load_fallback_tests(my_predbat): + failed = False + print("\n============================================================") + print("Running ML Load Fallback tests") + print("============================================================") + + ha = TestHAInterface() + fetch = TestFetchMLFallback(ha, my_predbat) + now_utc = datetime.now(timezone.utc) + + # Test 1: Active status proceeds + fetch.prefix = "active_test" + fetch.queried_results = False + fetch.fetch_ml_load_forecast(now_utc) + if not fetch.queried_results: + print("FAIL: ML Load forecast did NOT query 'results' on active path") + failed = True + else: + print("PASS: ML Load forecast active status proceeds past early return") + + # Test 2: Inactive status falls back + fetch.prefix = "inactive_test" + fetch.queried_results = False + fetch.fetch_ml_load_forecast(now_utc) + if fetch.queried_results: + print("FAIL: ML Load forecast queried 'results' when inactive") + failed = True + else: + print("PASS: ML Load forecast ignored when inactive (fallback)") + + print("============================================================") + if failed: + print("FAIL: SOME TESTS FAILED") + else: + print("PASS: ALL TESTS PASSED") + print("============================================================") + + return failed diff --git a/apps/predbat/tests/test_octopus_day_night_rates.py b/apps/predbat/tests/test_octopus_day_night_rates.py index f875dad59..4d640d975 100644 --- a/apps/predbat/tests/test_octopus_day_night_rates.py +++ b/apps/predbat/tests/test_octopus_day_night_rates.py @@ -4,9 +4,9 @@ Tests for OctopusAPI.async_get_day_night_rates — verifies that the correct night-window is selected for each of the three tariff families: - 1. IOG TOU (INTELLI or IOG+TOU in tariff_code) → 23:30–05:30, cross_midnight=True - 2. GO / generic day-night (not E-2R-*) → 00:30–05:30, cross_midnight=False - 3. Economy 7 (E-2R-* tariff_code) → 00:30–07:30, cross_midnight=False + 1. IOG TOU (INTELLI or IOG+TOU in tariff_code) -> 23:30–05:30, cross_midnight=True + 2. GO / generic day-night (not E-2R-*) -> 00:30–05:30, cross_midnight=False + 3. Economy 7 (E-2R-* tariff_code) -> 00:30–07:30, cross_midnight=False """ import asyncio @@ -67,11 +67,11 @@ async def test_octopus_day_night_rates(my_predbat): Test async_get_day_night_rates for all three tariff window cases. Tests: - - Test 1: IOG TOU tariff → night window 23:30–05:30 (crosses midnight) - - Test 2: INTELLI tariff → same IOG 23:30–05:30 window - - Test 3: GO-style tariff (non E-2R-) → night window 00:30–05:30 - - Test 4: Economy 7 tariff (E-2R-*) → night window 00:30–07:30 - - Test 5: Missing rates → returns empty list + - Test 1: IOG TOU tariff -> night window 23:30–05:30 (crosses midnight) + - Test 2: INTELLI tariff -> same IOG 23:30–05:30 window + - Test 3: GO-style tariff (non E-2R-) -> night window 00:30–05:30 + - Test 4: Economy 7 tariff (E-2R-*) -> night window 00:30–07:30 + - Test 5: Missing rates -> returns empty list """ print("\n**** Running async_get_day_night_rates tests ****") failed = False @@ -81,7 +81,7 @@ async def test_octopus_day_night_rates(my_predbat): # ------------------------------------------------------------------ # Test 1: IOG TOU — night window must be 23:30–05:30, cross-midnight # ------------------------------------------------------------------ - print("\n*** Test 1: IOG TOU tariff → night 23:30–05:30 ***") + print("\n*** Test 1: IOG TOU tariff -> night 23:30–05:30 ***") api1 = _make_api(my_predbat, day_rate=29.14, night_rate=7.00) with patch.object(type(api1), "now_utc_exact", new_callable=PropertyMock) as mock_now: @@ -118,7 +118,7 @@ async def test_octopus_day_night_rates(my_predbat): # ------------------------------------------------------------------ # Test 2: INTELLI tariff — same IOG window # ------------------------------------------------------------------ - print("\n*** Test 2: INTELLI tariff → same IOG 23:30–05:30 window ***") + print("\n*** Test 2: INTELLI tariff -> same IOG 23:30–05:30 window ***") api2 = _make_api(my_predbat, day_rate=29.14, night_rate=7.00) with patch.object(type(api2), "now_utc_exact", new_callable=PropertyMock) as mock_now: @@ -137,7 +137,7 @@ async def test_octopus_day_night_rates(my_predbat): # ------------------------------------------------------------------ # Test 3: GO-style tariff (non E-2R-) — night 00:30–05:30 # ------------------------------------------------------------------ - print("\n*** Test 3: GO-style tariff → night 00:30–05:30 ***") + print("\n*** Test 3: GO-style tariff -> night 00:30–05:30 ***") api3 = _make_api(my_predbat, day_rate=24.0, night_rate=8.5) with patch.object(type(api3), "now_utc_exact", new_callable=PropertyMock) as mock_now: @@ -162,7 +162,7 @@ async def test_octopus_day_night_rates(my_predbat): # ------------------------------------------------------------------ # Test 4: Economy 7 (E-2R-*) — night 00:30–07:30 # ------------------------------------------------------------------ - print("\n*** Test 4: Economy 7 tariff → night 00:30–07:30 ***") + print("\n*** Test 4: Economy 7 tariff -> night 00:30–07:30 ***") api4 = _make_api(my_predbat, day_rate=20.0, night_rate=10.0) with patch.object(type(api4), "now_utc_exact", new_callable=PropertyMock) as mock_now: @@ -185,9 +185,9 @@ async def test_octopus_day_night_rates(my_predbat): print("PASS: Economy 7 night window is 00:30–07:30") # ------------------------------------------------------------------ - # Test 5: Missing day/night rate data → returns empty list + # Test 5: Missing day/night rate data -> returns empty list # ------------------------------------------------------------------ - print("\n*** Test 5: Missing rates → returns empty list ***") + print("\n*** Test 5: Missing rates -> returns empty list ***") api5 = OctopusAPI(my_predbat, key="test-key", account_id="test-account", automatic=False) api5.fetch_url_cached = AsyncMock(return_value=[]) @@ -208,7 +208,7 @@ async def test_octopus_day_night_rates(my_predbat): # of the older rate (32.11992, valid from 2025-11-24) because the API # returns results newest-first and the loop was keeping the last match. # ------------------------------------------------------------------ - print("\n*** Test 6: Multiple historical day rates (newest first) → most-recent rate selected ***") + print("\n*** Test 6: Multiple historical day rates (newest first) -> most-recent rate selected ***") _NOW6 = datetime(2026, 5, 16, 12, 0, 0, tzinfo=timezone.utc) api6 = OctopusAPI(my_predbat, key="test-key", account_id="test-account", automatic=False) @@ -263,9 +263,9 @@ async def mock_fetch6(url, **kwargs): # rate snapshotted at "now". # Setup: now = 2026-04-02 12:00 UTC (2 days after rate change at 2026-03-31 23:00 UTC) # Schedule starts 2 days back = 2026-03-31 00:30 UTC (eco7 night start) - # Slots starting before 2026-03-31 23:00 → old rates; slots from 2026-04-01 onwards → new rates + # Slots starting before 2026-03-31 23:00 -> old rates; slots from 2026-04-01 onwards -> new rates # ------------------------------------------------------------------ - print("\n*** Test 7: Rate change within schedule window → per-day rate lookup ***") + print("\n*** Test 7: Rate change within schedule window -> per-day rate lookup ***") _NOW7 = datetime(2026, 4, 2, 12, 0, 0, tzinfo=timezone.utc) api7 = OctopusAPI(my_predbat, key="test-key", account_id="test-account", automatic=False) @@ -292,8 +292,8 @@ async def mock_fetch7(url, **kwargs): mdata7 = await api7.async_get_day_night_rates(base_url, tariff_code="E-2R-OE-FIX-12M-25-11-24-J") # The rate change is 2026-03-31T23:00Z. - # Eco7 night starts at 00:30 and day at 07:30, both before 23:00 on 03-31 → old rates. - # From 2026-04-01 00:30 onwards → new rates. + # Eco7 night starts at 00:30 and day at 07:30, both before 23:00 on 03-31 -> old rates. + # From 2026-04-01 00:30 onwards -> new rates. rate_change_dt = datetime(2026, 3, 31, 23, 0, 0, tzinfo=timezone.utc) old_rates = {11.55, 32.11992} new_rates = {13.65, 28.61292} @@ -330,9 +330,9 @@ async def mock_fetch7(url, **kwargs): _TS = datetime(2026, 5, 16, 12, 0, 0, tzinfo=timezone.utc) api8 = OctopusAPI(my_predbat, key="test-key", account_id="test-account", automatic=False) - # Entry A: valid_from missing (epoch), valid_to in the future → should match - # Entry B: valid_from very recent but valid_to already expired → must NOT match - # Entry C: valid_from set, valid_to absent (forever) → should match and beats A + # Entry A: valid_from missing (epoch), valid_to in the future -> should match + # Entry B: valid_from very recent but valid_to already expired -> must NOT match + # Entry C: valid_from set, valid_to absent (forever) -> should match and beats A rates8 = [ {"value_inc_vat": 10.0, "valid_from": None, "valid_to": "2027-01-01T00:00:00+0000"}, {"value_inc_vat": 99.0, "valid_from": "2026-05-16T11:00:00+0000", "valid_to": "2026-05-16T11:30:00+0000"}, @@ -412,8 +412,8 @@ async def mock_fetch7(url, **kwargs): # ------------------------------------------------------------------ if failed: - print("\n**** ❌ async_get_day_night_rates tests FAILED ****") + print("\n**** ERROR: async_get_day_night_rates tests FAILED ****") else: - print("\n**** ✅ All async_get_day_night_rates tests PASSED ****") + print("\n**** PASS: All async_get_day_night_rates tests PASSED ****") return 1 if failed else 0 diff --git a/apps/predbat/tests/test_octopus_download_rates.py b/apps/predbat/tests/test_octopus_download_rates.py index 81a6836d8..bfce051dd 100644 --- a/apps/predbat/tests/test_octopus_download_rates.py +++ b/apps/predbat/tests/test_octopus_download_rates.py @@ -50,13 +50,13 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates("https://api.octopus.energy/v1/products/AGILE-24-04-03/electricity-tariffs/E-1R-AGILE-24-04-03-A/standard-unit-rates/") if result != mock_rate_data: - print(f"✗ Test 1 failed - Expected rate data, got {result}") + print(f"FAIL: Test 1 failed - Expected rate data, got {result}") failed = True elif "https://api.octopus.energy/v1/products/AGILE-24-04-03/electricity-tariffs/E-1R-AGILE-24-04-03-A/standard-unit-rates/" not in my_predbat.octopus_url_cache: - print("✗ Test 1 failed - URL not in cache") + print("FAIL: Test 1 failed - URL not in cache") failed = True else: - print("✓ Test 1 passed - Single page download successful") + print("PASS: Test 1 passed - Single page download successful") # Test 2: Cache hit - returns cached data when fresh print("\nTest 2: Cache hit - returns cached data when fresh") @@ -77,13 +77,13 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates(test_url) if result != cached_data: - print(f"✗ Test 2 failed - Expected cached data {cached_data}, got {result}") + print(f"FAIL: Test 2 failed - Expected cached data {cached_data}, got {result}") failed = True elif mock_download.called: - print("✗ Test 2 failed - download_octopus_rates_func should not be called") + print("FAIL: Test 2 failed - download_octopus_rates_func should not be called") failed = True else: - print("✓ Test 2 passed - Cache hit returns fresh data") + print("PASS: Test 2 passed - Cache hit returns fresh data") # Test 3: Cache miss - downloads when cache expired (>30 min) print("\nTest 3: Cache miss - downloads when cache expired") @@ -104,13 +104,13 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates(test_url) if result != new_data: - print(f"✗ Test 3 failed - Expected new data {new_data}, got {result}") + print(f"FAIL: Test 3 failed - Expected new data {new_data}, got {result}") failed = True elif my_predbat.octopus_url_cache[test_url]["data"] != new_data: - print("✗ Test 3 failed - Cache not updated with new data") + print("FAIL: Test 3 failed - Cache not updated with new data") failed = True else: - print("✓ Test 3 passed - Cache miss triggers download") + print("PASS: Test 3 passed - Cache miss triggers download") # Test 4: Download failure - returns stale cache when available print("\nTest 4: Download failure - returns stale cache when available") @@ -129,10 +129,10 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates(test_url) if result != cached_data: - print(f"✗ Test 4 failed - Expected stale cached data {cached_data}, got {result}") + print(f"FAIL: Test 4 failed - Expected stale cached data {cached_data}, got {result}") failed = True else: - print("✓ Test 4 passed - Download failure returns stale cache") + print("PASS: Test 4 passed - Download failure returns stale cache") # Test 4a: Cache invalidation - midnight crossing print("\nTest 4a: Cache invalidation - midnight crossing") @@ -155,16 +155,16 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates(test_url) if result != new_data: - print(f"✗ Test 4a failed - Expected new data {new_data}, got {result}") + print(f"FAIL: Test 4a failed - Expected new data {new_data}, got {result}") failed = True elif not mock_download.called: - print("✗ Test 4a failed - download_octopus_rates_func should be called after midnight crossing") + print("FAIL: Test 4a failed - download_octopus_rates_func should be called after midnight crossing") failed = True elif my_predbat.octopus_url_cache[test_url]["midnight_utc"] != my_predbat.midnight_utc: - print(f"✗ Test 4a failed - Cache should be updated with new midnight_utc {my_predbat.midnight_utc}, got {my_predbat.octopus_url_cache[test_url]['midnight_utc']}") + print(f"FAIL: Test 4a failed - Cache should be updated with new midnight_utc {my_predbat.midnight_utc}, got {my_predbat.octopus_url_cache[test_url]['midnight_utc']}") failed = True else: - print("✓ Test 4a passed - Cache invalidated after midnight crossing") + print("PASS: Test 4a passed - Cache invalidated after midnight crossing") # Test 5: Download failure - raises ValueError when no cache available print("\nTest 5: Download failure - raises ValueError when no cache") @@ -174,10 +174,10 @@ def test_octopus_download_rates(my_predbat): with patch.object(my_predbat, 'download_octopus_rates_func', return_value={}): try: result = my_predbat.download_octopus_rates(test_url) - print("✗ Test 5 failed - Expected ValueError to be raised") + print("FAIL: Test 5 failed - Expected ValueError to be raised") failed = True except ValueError: - print("✓ Test 5 passed - ValueError raised when no cache available") + print("PASS: Test 5 passed - ValueError raised when no cache available") # Test 6: Retry mechanism - succeeds on second attempt print("\nTest 6: Retry mechanism - succeeds on second attempt") @@ -190,10 +190,10 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates(test_url) if result != success_data: - print(f"✗ Test 6 failed - Expected success data {success_data}, got {result}") + print(f"FAIL: Test 6 failed - Expected success data {success_data}, got {result}") failed = True else: - print("✓ Test 6 passed - Retry mechanism works correctly") + print("PASS: Test 6 passed - Retry mechanism works correctly") # Test 7: download_octopus_rates_func - successful single page print("\nTest 7: download_octopus_rates_func - successful single page") @@ -220,10 +220,10 @@ def test_octopus_download_rates(my_predbat): # minute_data expands to every minute in the range, so check key points if result.get(0) != 10.5 or result.get(29) != 10.5 or result.get(30) != 12.0 or result.get(59) != 12.0: - print(f"✗ Test 7 failed - Expected rate data at key minutes, got {result.get(0)}, {result.get(29)}, {result.get(30)}, {result.get(59)}") + print(f"FAIL: Test 7 failed - Expected rate data at key minutes, got {result.get(0)}, {result.get(29)}, {result.get(30)}, {result.get(59)}") failed = True else: - print("✓ Test 7 passed - download_octopus_rates_func single page success") + print("PASS: Test 7 passed - download_octopus_rates_func single page success") # Test 8: download_octopus_rates_func - pagination (multiple pages) print("\nTest 8: download_octopus_rates_func - pagination") @@ -256,10 +256,10 @@ def test_octopus_download_rates(my_predbat): # Check that both pages were fetched and combined if result.get(0) != 10.5 or result.get(29) != 10.5 or result.get(30) != 12.0 or result.get(59) != 12.0: - print(f"✗ Test 8 failed - Expected combined rate data, got minute 0: {result.get(0)}, minute 29: {result.get(29)}, minute 30: {result.get(30)}, minute 59: {result.get(59)}") + print(f"FAIL: Test 8 failed - Expected combined rate data, got minute 0: {result.get(0)}, minute 29: {result.get(29)}, minute 30: {result.get(30)}, minute 59: {result.get(59)}") failed = True else: - print("✓ Test 8 passed - Pagination works correctly") + print("PASS: Test 8 passed - Pagination works correctly") # Test 9: download_octopus_rates_func - ConnectionError print("\nTest 9: download_octopus_rates_func - ConnectionError") @@ -271,10 +271,10 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates_func(test_url) if result != {}: - print(f"✗ Test 9 failed - Expected empty dict, got {result}") + print(f"FAIL: Test 9 failed - Expected empty dict, got {result}") failed = True else: - print("✓ Test 9 passed - ConnectionError handled correctly") + print("PASS: Test 9 passed - ConnectionError handled correctly") # Test 10: download_octopus_rates_func - HTTP error status print("\nTest 10: download_octopus_rates_func - HTTP error status") @@ -289,10 +289,10 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates_func(test_url) if result != {}: - print(f"✗ Test 10 failed - Expected empty dict, got {result}") + print(f"FAIL: Test 10 failed - Expected empty dict, got {result}") failed = True else: - print("✓ Test 10 passed - HTTP error status handled") + print("PASS: Test 10 passed - HTTP error status handled") # Test 11: download_octopus_rates_func - JSON decode error print("\nTest 11: download_octopus_rates_func - JSON decode error") @@ -308,13 +308,13 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates_func(test_url) if result != {}: - print(f"✗ Test 11 failed - Expected empty dict, got {result}") + print(f"FAIL: Test 11 failed - Expected empty dict, got {result}") failed = True elif my_predbat.failures_total != 1: - print(f"✗ Test 11 failed - Expected failures_total=1, got {my_predbat.failures_total}") + print(f"FAIL: Test 11 failed - Expected failures_total=1, got {my_predbat.failures_total}") failed = True else: - print("✓ Test 11 passed - JSON decode error handled") + print("PASS: Test 11 passed - JSON decode error handled") # Test 12: download_octopus_rates_func - missing 'results' key print("\nTest 12: download_octopus_rates_func - missing 'results' key") @@ -330,10 +330,10 @@ def test_octopus_download_rates(my_predbat): result = my_predbat.download_octopus_rates_func(test_url) if result != {}: - print(f"✗ Test 12 failed - Expected empty dict, got {result}") + print(f"FAIL: Test 12 failed - Expected empty dict, got {result}") failed = True else: - print("✓ Test 12 passed - Missing 'results' key handled") + print("PASS: Test 12 passed - Missing 'results' key handled") if failed: print("\n=== Some download_octopus_rates tests FAILED ===") diff --git a/apps/predbat/tests/test_octopus_misc.py b/apps/predbat/tests/test_octopus_misc.py index e6f123a97..23d81e9e3 100644 --- a/apps/predbat/tests/test_octopus_misc.py +++ b/apps/predbat/tests/test_octopus_misc.py @@ -29,9 +29,9 @@ async def test_octopus_misc(my_predbat): failed += await test_octopus_run(my_predbat) if failed == 0: - print("\n**** ✅ All Octopus Misc API tests PASSED ****") + print("\n**** PASS: All Octopus Misc API tests PASSED ****") else: - print(f"\n**** ❌ Octopus Misc API tests FAILED ({failed} test(s) failed) ****") + print(f"\n**** ERROR: Octopus Misc API tests FAILED ({failed} test(s) failed) ****") return failed @@ -273,10 +273,10 @@ def capture_log(msg): print("PASS: returns_data=False parameter set correctly") if failed: - print("\n**** ❌ Octopus async_set_intelligent_target_schedule tests FAILED ****") + print("\n**** ERROR: Octopus async_set_intelligent_target_schedule tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus async_set_intelligent_target_schedule tests PASSED ****") + print("\n**** PASS: Octopus async_set_intelligent_target_schedule tests PASSED ****") return 0 @@ -442,10 +442,10 @@ def capture_log(msg): print("PASS: Multiple events can be joined sequentially") if failed: - print("\n**** ❌ Octopus async_join_saving_session_events tests FAILED ****") + print("\n**** ERROR: Octopus async_join_saving_session_events tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus async_join_saving_session_events tests PASSED ****") + print("\n**** PASS: Octopus async_join_saving_session_events tests PASSED ****") return 0 @@ -613,10 +613,10 @@ async def test_octopus_get_saving_sessions(my_predbat): print("PASS: Returns correct savingSessions structure") if failed: - print("\n**** ❌ Octopus async_get_saving_sessions tests FAILED ****") + print("\n**** ERROR: Octopus async_get_saving_sessions tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus async_get_saving_sessions tests PASSED ****") + print("\n**** PASS: Octopus async_get_saving_sessions tests PASSED ****") return 0 @@ -858,8 +858,8 @@ def capture_dashboard_item(entity_id, state, attributes=None, app=None): else: print("PASS: All dashboard_item calls use app='octopus'") - # Test 7: Product info returns day/night links → async_get_day_night_rates is used - print("\n*** Test 7: Product info with day_unit_rates links → routes to async_get_day_night_rates ***") + # Test 7: Product info returns day/night links -> async_get_day_night_rates is used + print("\n*** Test 7: Product info with day_unit_rates links -> routes to async_get_day_night_rates ***") api7 = OctopusAPI(my_predbat, key="test-api-key-7", account_id="test-account-7", automatic=False) iog_tariff_code = "E-1R-IOG-SMB-TOU-25-12-12-H" @@ -926,8 +926,8 @@ async def mock_fetch_url7(url, **kwargs): else: print("PASS: Day/night rate data stored correctly in tariff") - # Test 8: INTELLI-FLUX-EXPORT 404s → fetches FLUX-IMPORT as fallback - print("\n*** Test 8: INTELLI-FLUX-EXPORT 404s → fetches FLUX-IMPORT as fallback ***") + # Test 8: INTELLI-FLUX-EXPORT 404s -> fetches FLUX-IMPORT as fallback + print("\n*** Test 8: INTELLI-FLUX-EXPORT 404s -> fetches FLUX-IMPORT as fallback ***") api8 = OctopusAPI(my_predbat, key="test-api-key-8", account_id="test-account-8", automatic=False) flux_export_product = "INTELLI-FLUX-EXPORT-23-07-14" @@ -981,8 +981,8 @@ async def mock_fetch_url8(url, **kwargs): else: print("PASS: FLUX-IMPORT URL constructed correctly with correct product and tariff codes") - # Test 9: INTELLI-FLUX-EXPORT 404s, FLUX-IMPORT also 404s → falls back to current import data - print("\n*** Test 9: FLUX-EXPORT and FLUX-IMPORT both fail → falls back to current import data ***") + # Test 9: INTELLI-FLUX-EXPORT 404s, FLUX-IMPORT also 404s -> falls back to current import data + print("\n*** Test 9: FLUX-EXPORT and FLUX-IMPORT both fail -> falls back to current import data ***") api9 = OctopusAPI(my_predbat, key="test-api-key-9", account_id="test-account-9", automatic=False) fix_import_rates = [{"valid_from": "2025-01-01T00:00:00Z", "valid_to": "2025-01-01T00:30:00Z", "value_inc_vat": 28.14}] @@ -1012,10 +1012,10 @@ async def mock_fetch_url9(url, **kwargs): print("PASS: Current import rates used as final fallback when FLUX-IMPORT also unavailable") if failed: - print("\n**** ❌ Octopus fetch_tariffs tests FAILED ****") + print("\n**** ERROR: Octopus fetch_tariffs tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus fetch_tariffs tests PASSED ****") + print("\n**** PASS: Octopus fetch_tariffs tests PASSED ****") return 0 @@ -1245,10 +1245,10 @@ def capture_log(msg): print(f"PASS: Result covers minutes {min_minute} to {max_minute}") if failed: - print("\n**** ❌ Octopus get_octopus_rates_direct tests FAILED ****") + print("\n**** ERROR: Octopus get_octopus_rates_direct tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus get_octopus_rates_direct tests PASSED ****") + print("\n**** PASS: Octopus get_octopus_rates_direct tests PASSED ****") return 0 @@ -1397,10 +1397,10 @@ def test_octopus_get_intelligent_target_soc(my_predbat): print("PASS: Returns None when weekend_target_soc missing") if failed: - print("\n**** ❌ Octopus get_intelligent_target_soc tests FAILED ****") + print("\n**** ERROR: Octopus get_intelligent_target_soc tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus get_intelligent_target_soc tests PASSED ****") + print("\n**** PASS: Octopus get_intelligent_target_soc tests PASSED ****") return 0 @@ -1547,10 +1547,10 @@ def test_octopus_get_intelligent_target_time(my_predbat): print("PASS: Returns None when weekend_target_time missing") if failed: - print("\n**** ❌ Octopus get_intelligent_target_time tests FAILED ****") + print("\n**** ERROR: Octopus get_intelligent_target_time tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus get_intelligent_target_time tests PASSED ****") + print("\n**** PASS: Octopus get_intelligent_target_time tests PASSED ****") return 0 @@ -1649,10 +1649,10 @@ def test_octopus_get_intelligent_battery_size(my_predbat): print("PASS: Float battery size retrieved correctly") if failed: - print("\n**** ❌ Octopus get_intelligent_battery_size tests FAILED ****") + print("\n**** ERROR: Octopus get_intelligent_battery_size tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus get_intelligent_battery_size tests PASSED ****") + print("\n**** PASS: Octopus get_intelligent_battery_size tests PASSED ****") return 0 @@ -1845,10 +1845,10 @@ def test_octopus_get_intelligent_vehicle(my_predbat): print("PASS: Returns empty dict when device has no vehicle fields") if failed: - print("\n**** ❌ Octopus get_intelligent_vehicle tests FAILED ****") + print("\n**** ERROR: Octopus get_intelligent_vehicle tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus get_intelligent_vehicle tests PASSED ****") + print("\n**** PASS: Octopus get_intelligent_vehicle tests PASSED ****") return 0 @@ -1888,7 +1888,7 @@ async def test_octopus_run(my_predbat): # Test 1: First run with no cache — all methods should be called print("\n*** Test 1: First run calls all expected methods ***") api = _mock_run_api(my_predbat, key="test-api-key", account_id="test-account") - # tariff_fetched_at and device_fetched_at are None → _data_age_minutes returns 9999 + # tariff_fetched_at and device_fetched_at are None -> _data_age_minutes returns 9999 result = await api.run(seconds=0, first=True) @@ -2087,8 +2087,8 @@ async def test_octopus_run(my_predbat): print("PASS: Automatic config called on first run when automatic=True") if failed: - print("\n**** ❌ Octopus run method tests FAILED ****") + print("\n**** ERROR: Octopus run method tests FAILED ****") return 1 else: - print("\n**** ✅ Octopus run method tests PASSED ****") + print("\n**** PASS: Octopus run method tests PASSED ****") return 0 diff --git a/apps/predbat/tests/test_octopus_read_response_retry.py b/apps/predbat/tests/test_octopus_read_response_retry.py index eae6a8341..afea80724 100644 --- a/apps/predbat/tests/test_octopus_read_response_retry.py +++ b/apps/predbat/tests/test_octopus_read_response_retry.py @@ -267,8 +267,8 @@ def create_mock_response(status, text_content, headers=None): print("PASS: Auth error triggers retry with exponential backoff") if failed: - print("\n**** ❌ Octopus async_read_response_retry tests FAILED ****") + print("\n**** ERROR: Octopus async_read_response_retry tests FAILED ****") return 1 else: - print("\n**** ✅ All Octopus async_read_response_retry tests PASSED ****") + print("\n**** PASS: All Octopus async_read_response_retry tests PASSED ****") return 0 diff --git a/apps/predbat/tests/test_octopus_url.py b/apps/predbat/tests/test_octopus_url.py index da33935c4..a3d909ecc 100644 --- a/apps/predbat/tests/test_octopus_url.py +++ b/apps/predbat/tests/test_octopus_url.py @@ -54,13 +54,13 @@ def test_octopus_url(my_predbat=None): try: result = test_func(my_predbat) if result: - print(f"✗ FAILED: {key}") + print(f"FAIL: FAILED: {key}") failed += 1 else: - print(f"✓ PASSED: {key}") + print(f"PASS: PASSED: {key}") passed += 1 except Exception as e: - print(f"✗ EXCEPTION in {key}: {e}") + print(f"FAIL: EXCEPTION in {key}: {e}") import traceback traceback.print_exc() diff --git a/apps/predbat/tests/test_ohme.py b/apps/predbat/tests/test_ohme.py index c2547448e..ec2dcdb03 100644 --- a/apps/predbat/tests/test_ohme.py +++ b/apps/predbat/tests/test_ohme.py @@ -291,13 +291,13 @@ def test_ohme(my_predbat=None): try: result = test_func(my_predbat) if result: - print(f"✗ FAILED: {key}") + print(f"FAIL: FAILED: {key}") failed += 1 else: - print(f"✓ PASSED: {key}") + print(f"PASS: PASSED: {key}") passed += 1 except Exception as e: - print(f"✗ EXCEPTION in {key}: {e}") + print(f"FAIL: EXCEPTION in {key}: {e}") import traceback traceback.print_exc() failed += 1 diff --git a/apps/predbat/tests/test_open_meteo.py b/apps/predbat/tests/test_open_meteo.py index fb9bec7c6..b0fe5f4cf 100644 --- a/apps/predbat/tests/test_open_meteo.py +++ b/apps/predbat/tests/test_open_meteo.py @@ -58,7 +58,7 @@ def _make_ensemble_response(times=None, members=None): def test_ensemble_returns_p10_values(my_predbat): """ - download_open_meteo_ensemble_data should return a dict of ts→kW10 + download_open_meteo_ensemble_data should return a dict of ts->kW10 where each value is the 10th-percentile GTI across members, converted to kW. """ print(" - test_ensemble_returns_p10_values") @@ -133,7 +133,7 @@ def test_ensemble_empty_on_http_failure(my_predbat): test_api = create_test_solar_api() try: test_api.solar.open_meteo_forecast_max_age = 1.0 - # No mock response registered → cache_get_url will return None + # No mock response registered -> cache_get_url will return None def create_mock_session(*args, **kwargs): return test_api.mock_aiohttp_session() @@ -235,7 +235,7 @@ def test_download_open_meteo_data_temperature_derating(my_predbat): test_api.solar.open_meteo_forecast_max_age = 1.0 # GTI=1000, T=45°C, wind=1.0 m/s: T_cell via SAPM model, eta = 1 - 0.004*(T_cell-25) - # pv50 = (1000/1000) * 4.0 * eta_temp (same at both ends → trapz average equals point value) + # pv50 = (1000/1000) * 4.0 * eta_temp (same at both ends -> trapz average equals point value) forecast_response = _make_forecast_response(times=["2025-06-15T12:00", "2025-06-15T13:00"], gti=[1000.0, 1000.0], temp=[45.0, 45.0], wind=[1.0, 1.0]) ensemble_response = _make_ensemble_response(times=["2025-06-15T12:00", "2025-06-15T13:00"], members={"global_tilted_irradiance_member01": [900.0, 900.0]}) test_api.set_mock_response("api.open-meteo.com", forecast_response) @@ -273,7 +273,7 @@ def test_download_open_meteo_data_multi_config(my_predbat): test_api = create_test_solar_api() try: - # Two identical 2 kWp arrays → combined should be 4 kWp + # Two identical 2 kWp arrays -> combined should be 4 kWp test_api.solar.open_meteo_forecast = [ {"latitude": 51.5, "longitude": -0.1, "declination": 35, "azimuth": 180, "kwp": 2.0, "efficiency": 1.0}, {"latitude": 51.5, "longitude": -0.1, "declination": 35, "azimuth": 180, "kwp": 2.0, "efficiency": 1.0}, @@ -295,8 +295,8 @@ def create_mock_session(*args, **kwargs): print(f"ERROR: max_kwh expected 4.0, got {max_kwh}") failed = True - # Each array: GTI=1000, T=25°C, wind=1.0 m/s → cell temp via SAPM, pv50 = (1000/1000)*2.0*eta - # Same at both ends → trapz average equals point value + # Each array: GTI=1000, T=25°C, wind=1.0 m/s -> cell temp via SAPM, pv50 = (1000/1000)*2.0*eta + # Same at both ends -> trapz average equals point value if sorted_data: pv50 = sorted_data[0].get("pv_estimate", 0) t_cell = pvwatts_cell_temperature(1000.0, 25.0, 1.0) @@ -369,7 +369,7 @@ def test_download_open_meteo_data_cool_temp_efficiency(my_predbat): test_api.solar.open_meteo_forecast_max_age = 1.0 # 10 degC ambient, 200 W/m2, 1 m/s wind: SAPM T_cell < 25 degC -> eta > 1.0 - # Same at both ends → trapz average equals point value + # Same at both ends -> trapz average equals point value forecast_response = _make_forecast_response(times=["2025-04-15T12:00", "2025-04-15T13:00"], gti=[200.0, 200.0], temp=[10.0, 10.0], wind=[1.0, 1.0]) ensemble_response = _make_ensemble_response(times=["2025-04-15T12:00", "2025-04-15T13:00"], members={"global_tilted_irradiance_member01": [150.0, 150.0]}) test_api.set_mock_response("api.open-meteo.com", forecast_response) @@ -456,8 +456,8 @@ def test_download_open_meteo_data_two_aspect_configs(my_predbat): test_api = create_test_solar_api() try: - # Array 1: WSW-facing, shallow tilt (az -133 Solcast → convert_azimuth → -47 OM) - # Array 2: NW-facing, steep tilt (az +45 Solcast → convert_azimuth → 135 OM) + # Array 1: WSW-facing, shallow tilt (az -133 Solcast -> convert_azimuth -> -47 OM) + # Array 2: NW-facing, steep tilt (az +45 Solcast -> convert_azimuth -> 135 OM) test_api.solar.open_meteo_forecast = [ {"latitude": 51.49, "longitude": -2.49, "declination": 23.0, "azimuth": -133.0, "kwp": 1.56, "efficiency": 1.0}, {"latitude": 51.49, "longitude": -2.49, "declination": 45.0, "azimuth": 45.0, "kwp": 2.73, "efficiency": 1.0}, @@ -465,7 +465,7 @@ def test_download_open_meteo_data_two_aspect_configs(my_predbat): test_api.solar.open_meteo_forecast_max_age = 1.0 times = ["2025-06-15T12:00", "2025-06-15T13:00"] - # Array 1 (WSW): 400 W/m² Array 2 (NW): 200 W/m² (same at both ends → trapz average = point value) + # Array 1 (WSW): 400 W/m² Array 2 (NW): 200 W/m² (same at both ends -> trapz average = point value) forecast_wsw = {"hourly": {"time": times, "global_tilted_irradiance": [400.0, 400.0], "temperature_2m": [25.0, 25.0], "wind_speed_10m": [1.0, 1.0]}} forecast_nw = {"hourly": {"time": times, "global_tilted_irradiance": [200.0, 200.0], "temperature_2m": [25.0, 25.0], "wind_speed_10m": [1.0, 1.0]}} ensemble_wsw = {"hourly": {"time": times, "global_tilted_irradiance_member01": [320.0, 320.0]}} @@ -542,7 +542,7 @@ def test_download_open_meteo_data_http_failure(my_predbat): try: test_api.solar.open_meteo_forecast = [{"latitude": 51.5, "longitude": -0.1, "declination": 35, "azimuth": 180, "kwp": 3.0, "efficiency": 0.86}] test_api.solar.open_meteo_forecast_max_age = 1.0 - # No mocks set → cache_get_url returns None + # No mocks set -> cache_get_url returns None def create_mock_session(*args, **kwargs): return test_api.mock_aiohttp_session() @@ -714,7 +714,7 @@ def create_mock_session(*args, **kwargs): test_api.cleanup() # --- Case 2: azimuth_zero_south=False (default), azimuth=0 (North in Predbat convention) --- - # convert_azimuth(0) → 180; URL should contain azimuth=180 + # convert_azimuth(0) -> 180; URL should contain azimuth=180 test_api = create_test_solar_api() try: test_api.solar.open_meteo_forecast = [{"latitude": 51.5, "longitude": -0.1, "declination": 35, "azimuth": 0, "kwp": 3.0, "efficiency": 1.0}] diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index abac7bbf4..e3cd93baa 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -271,7 +271,7 @@ def run_rate_add_io_slots_tests(my_predbat): # Test 15: Midday-to-midday cap boundary # 14 slots starting at 22:00 today (minute 1320) and crossing midnight into the early hours of tomorrow. - # All slots fall within the same midday-to-midday period (noon today → noon tomorrow), so the + # All slots fall within the same midday-to-midday period (noon today -> noon tomorrow), so the # 12-slot cap applies across midnight and only the first 12 slots should be cheap. # Under the old midnight-to-midnight logic, today would have 4 cheap slots and tomorrow 10 # cheap slots (each under the limit), so ALL 14 would be cheap — the opposite of what we want. diff --git a/apps/predbat/tests/test_rate_min_forward_calc.py b/apps/predbat/tests/test_rate_min_forward_calc.py index 7d399eb64..114dbd925 100644 --- a/apps/predbat/tests/test_rate_min_forward_calc.py +++ b/apps/predbat/tests/test_rate_min_forward_calc.py @@ -49,7 +49,7 @@ def test_rate_min_forward_calc(my_predbat): result = my_predbat.rate_min_forward_calc(rates) failed |= _check_range(result, output_start, output_end, 10.0, "flat rate") - # --- Test 2: lower rate in the future → every minute sees that future minimum --- + # --- Test 2: lower rate in the future -> every minute sees that future minimum --- print("*** rate_min_forward_calc test 2: lower rate in future") low_minute = my_predbat.minutes_now + 6 * 60 # 6 hours from now rates = {m: 10.0 for m in range(0, my_predbat.forecast_minutes + my_predbat.minutes_now + 48 * 60)} @@ -64,7 +64,7 @@ def test_rate_min_forward_calc(my_predbat): # After low_minute back to 10.0 failed |= _check_range(result, low_minute + 1, output_end, 10.0, "after low_minute") - # --- Test 3: rates strictly increasing → min forward equals the rate at each minute --- + # --- Test 3: rates strictly increasing -> min forward equals the rate at each minute --- print("*** rate_min_forward_calc test 3: increasing rates") rates = {} for m in range(my_predbat.forecast_minutes + my_predbat.minutes_now + 48 * 60): diff --git a/apps/predbat/tests/test_rate_replicate_missing_slots.py b/apps/predbat/tests/test_rate_replicate_missing_slots.py index b02520525..6726d20c3 100644 --- a/apps/predbat/tests/test_rate_replicate_missing_slots.py +++ b/apps/predbat/tests/test_rate_replicate_missing_slots.py @@ -53,13 +53,13 @@ def test_rate_replicate(my_predbat): try: test_result = test_func(my_predbat) if test_result: - print(f"✗ FAILED: {test_name}") + print(f"FAIL: FAILED: {test_name}") failed += 1 else: - print(f"✓ PASSED: {test_name}") + print(f"PASS: PASSED: {test_name}") passed += 1 except Exception as e: - print(f"✗ EXCEPTION in {test_name}: {e}") + print(f"FAIL: EXCEPTION in {test_name}: {e}") import traceback traceback.print_exc() failed += 1 @@ -128,32 +128,32 @@ def _test_missing_slots(my_predbat): # Expected behavior: 23:00 and 23:30 should be replicated from previous day if 1380 not in result: - print(f" ✗ ERROR: Minute 1380 (23:00) is still missing after rate_replicate") + print(f" FAIL: ERROR: Minute 1380 (23:00) is still missing after rate_replicate") failed |= 1 elif result[1380] != rates[-60]: - print(f" ✗ ERROR: Minute 1380 (23:00) should be {rates[-60]} (from minute -60), got {result[1380]}") + print(f" FAIL: ERROR: Minute 1380 (23:00) should be {rates[-60]} (from minute -60), got {result[1380]}") failed |= 1 elif result[1380] == 0.0: - print(f" ✗ ERROR: Minute 1380 (23:00) is 0.0 - this is the BUG we're fixing!") + print(f" FAIL: ERROR: Minute 1380 (23:00) is 0.0 - this is the BUG we're fixing!") failed |= 1 if 1410 not in result: - print(f" ✗ ERROR: Minute 1410 (23:30) is still missing after rate_replicate") + print(f" FAIL: ERROR: Minute 1410 (23:30) is still missing after rate_replicate") failed |= 1 elif result[1410] != rates[-30]: - print(f" ✗ ERROR: Minute 1410 (23:30) should be {rates[-30]} (from minute -30), got {result[1410]}") + print(f" FAIL: ERROR: Minute 1410 (23:30) should be {rates[-30]} (from minute -30), got {result[1410]}") failed |= 1 elif result[1410] == 0.0: - print(f" ✗ ERROR: Minute 1410 (23:30) is 0.0 - this is the BUG we're fixing!") + print(f" FAIL: ERROR: Minute 1410 (23:30) is 0.0 - this is the BUG we're fixing!") failed |= 1 # Check that replicated type is marked as "copy" if result_replicated.get(1380) != "copy": - print(f" ✗ ERROR: Minute 1380 should be marked as 'copy', got {result_replicated.get(1380)}") + print(f" FAIL: ERROR: Minute 1380 should be marked as 'copy', got {result_replicated.get(1380)}") failed |= 1 if result_replicated.get(1410) != "copy": - print(f" ✗ ERROR: Minute 1410 should be marked as 'copy', got {result_replicated.get(1410)}") + print(f" FAIL: ERROR: Minute 1410 should be marked as 'copy', got {result_replicated.get(1410)}") failed |= 1 # Restore time context to current time my_predbat.now_utc = datetime.now(my_predbat.local_tz) @@ -190,17 +190,17 @@ def _test_no_previous_day(my_predbat): # Without previous day data, the function should fall back to rate_last (last seen rate) # which should be rates[1350] = 15.0 (22:30's rate) if 1380 not in result: - print(f" ✗ ERROR: Minute 1380 should be filled (even without previous day data)") + print(f" FAIL: ERROR: Minute 1380 should be filled (even without previous day data)") failed |= 1 elif result[1380] == 0.0: - print(f" ✗ ERROR: Minute 1380 should not be 0.0 (should use rate_last fallback)") + print(f" FAIL: ERROR: Minute 1380 should not be 0.0 (should use rate_last fallback)") failed |= 1 if 1410 not in result: - print(f" ✗ ERROR: Minute 1410 should be filled (even without previous day data)") + print(f" FAIL: ERROR: Minute 1410 should be filled (even without previous day data)") failed |= 1 elif result[1410] == 0.0: - print(f" ✗ ERROR: Minute 1410 should not be 0.0 (should use rate_last fallback)") + print(f" FAIL: ERROR: Minute 1410 should not be 0.0 (should use rate_last fallback)") failed |= 1 # Restore time context to current time @@ -250,22 +250,22 @@ def _test_zero_rates(my_predbat): # Expected: 23:00 and 23:30 should be replicated from previous day's 0.0 rates if 1380 not in result: - print(f" ✗ ERROR: Minute 1380 (23:00) is missing after rate_replicate") + print(f" FAIL: ERROR: Minute 1380 (23:00) is missing after rate_replicate") failed |= 1 elif result[1380] != 0.0: - print(f" ✗ ERROR: Minute 1380 (23:00) should be 0.0 (from minute -60), got {result[1380]}") + print(f" FAIL: ERROR: Minute 1380 (23:00) should be 0.0 (from minute -60), got {result[1380]}") failed |= 1 if 1410 not in result: - print(f" ✗ ERROR: Minute 1410 (23:30) is missing after rate_replicate") + print(f" FAIL: ERROR: Minute 1410 (23:30) is missing after rate_replicate") failed |= 1 elif result[1410] != 0.0: - print(f" ✗ ERROR: Minute 1410 (23:30) should be 0.0 (from minute -30), got {result[1410]}") + print(f" FAIL: ERROR: Minute 1410 (23:30) should be 0.0 (from minute -30), got {result[1410]}") failed |= 1 # Also verify that previous day rates with 0 are still present if result.get(-60) != 0.0: - print(f" ✗ ERROR: Previous day minute -60 should still be 0.0, got {result.get(-60)}") + print(f" FAIL: ERROR: Previous day minute -60 should still be 0.0, got {result.get(-60)}") failed |= 1 # Restore time context to current time my_predbat.now_utc = datetime.now(my_predbat.local_tz) @@ -317,7 +317,7 @@ def _test_undefined_negative_minutes(my_predbat): missing_minutes.append(minute) if missing_minutes: - print(f" ✗ ERROR: {len(missing_minutes)} negative minutes are undefined after rate_replicate") + print(f" FAIL: ERROR: {len(missing_minutes)} negative minutes are undefined after rate_replicate") print(f" First missing: {missing_minutes[0]}, Last missing: {missing_minutes[-1]}") print(f" This would cause KeyError in publish_rates when iterating from -1440") @@ -328,17 +328,17 @@ def _test_undefined_negative_minutes(my_predbat): value = result[test_minute] # This will raise KeyError print(f" No KeyError at minute {test_minute} (unexpected!)") except KeyError as e: - print(f" ✓ Confirmed: KeyError accessing minute {test_minute}: {e}") + print(f" PASS: Confirmed: KeyError accessing minute {test_minute}: {e}") failed |= 1 else: - print(f" ✓ All negative minutes were filled correctly") + print(f" PASS: All negative minutes were filled correctly") # Also check if any positive minutes were created (there shouldn't be any with only negative input) positive_minutes = [m for m in result.keys() if m >= 0] if positive_minutes: print(f" Note: {len(positive_minutes)} positive minutes were created despite no input data") else: - print(f" ✓ No positive minutes created (expected with only negative input)") + print(f" PASS: No positive minutes created (expected with only negative input)") # Restore time context to current time my_predbat.now_utc = datetime.now(my_predbat.local_tz) @@ -383,20 +383,20 @@ def _test_rate_io(my_predbat): next_day_intelligent_start = 1440 + 120 if next_day_intelligent_start in result: if result[next_day_intelligent_start] == my_predbat.rate_max: - print(f" ✓ Intelligent slot correctly replaced with rate_max: {result[next_day_intelligent_start]}") + print(f" PASS: Intelligent slot correctly replaced with rate_max: {result[next_day_intelligent_start]}") else: - print(f" ✗ ERROR: Next day intelligent slot should be {my_predbat.rate_max}, got {result[next_day_intelligent_start]}") + print(f" FAIL: ERROR: Next day intelligent slot should be {my_predbat.rate_max}, got {result[next_day_intelligent_start]}") failed |= 1 else: - print(f" ✗ ERROR: Next day intelligent slot minute {next_day_intelligent_start} missing") + print(f" FAIL: ERROR: Next day intelligent slot minute {next_day_intelligent_start} missing") failed |= 1 # Normal rates should still replicate correctly normal_minute_next_day = 1440 + 600 # 10:00 next day if normal_minute_next_day in result and result[normal_minute_next_day] == 25.0: - print(f" ✓ Normal rates replicate correctly: {result[normal_minute_next_day]}") + print(f" PASS: Normal rates replicate correctly: {result[normal_minute_next_day]}") else: - print(f" ✗ ERROR: Normal rate replication failed at minute {normal_minute_next_day}") + print(f" FAIL: ERROR: Normal rate replication failed at minute {normal_minute_next_day}") failed |= 1 # Restore context @@ -443,17 +443,17 @@ def _test_future_rate_adjust_import(my_predbat): next_day_minute = 1440 + 600 if next_day_minute in result: if result[next_day_minute] == 20.0: - print(f" ✓ Future rate adjustment applied: {result[next_day_minute]}") + print(f" PASS: Future rate adjustment applied: {result[next_day_minute]}") if result_replicated.get(next_day_minute) == "future": - print(f" ✓ Replicated type correctly marked as 'future'") + print(f" PASS: Replicated type correctly marked as 'future'") else: - print(f" ✗ ERROR: Should be marked as 'future', got {result_replicated.get(next_day_minute)}") + print(f" FAIL: ERROR: Should be marked as 'future', got {result_replicated.get(next_day_minute)}") failed |= 1 else: - print(f" ✗ ERROR: Future rate should be 20.0, got {result[next_day_minute]}") + print(f" FAIL: ERROR: Future rate should be 20.0, got {result[next_day_minute]}") failed |= 1 else: - print(f" ✗ ERROR: Future rate minute {next_day_minute} missing") + print(f" FAIL: ERROR: Future rate minute {next_day_minute} missing") failed |= 1 # Restore context @@ -502,17 +502,17 @@ def _test_future_rate_adjust_export(my_predbat): next_day_minute = 1440 + 600 if next_day_minute in result: if result[next_day_minute] == 0.0: - print(f" ✓ Negative export rate clamped to 0: {result[next_day_minute]}") + print(f" PASS: Negative export rate clamped to 0: {result[next_day_minute]}") if result_replicated.get(next_day_minute) == "future": - print(f" ✓ Replicated type correctly marked as 'future'") + print(f" PASS: Replicated type correctly marked as 'future'") else: - print(f" ✗ ERROR: Should be marked as 'future', got {result_replicated.get(next_day_minute)}") + print(f" FAIL: ERROR: Should be marked as 'future', got {result_replicated.get(next_day_minute)}") failed |= 1 else: - print(f" ✗ ERROR: Negative export rate should be clamped to 0.0, got {result[next_day_minute]}") + print(f" FAIL: ERROR: Negative export rate should be clamped to 0.0, got {result[next_day_minute]}") failed |= 1 else: - print(f" ✗ ERROR: Future rate minute {next_day_minute} missing") + print(f" FAIL: ERROR: Future rate minute {next_day_minute} missing") failed |= 1 # Restore context @@ -553,21 +553,21 @@ def _test_import_offset(my_predbat): if next_day_minute in result: expected_rate = 15.0 + 5.0 if result[next_day_minute] == expected_rate: - print(f" ✓ Import offset applied correctly: {result[next_day_minute]}") + print(f" PASS: Import offset applied correctly: {result[next_day_minute]}") if result_replicated.get(next_day_minute) == "offset": - print(f" ✓ Replicated type correctly marked as 'offset'") + print(f" PASS: Replicated type correctly marked as 'offset'") else: - print(f" ✗ ERROR: Should be marked as 'offset', got {result_replicated.get(next_day_minute)}") + print(f" FAIL: ERROR: Should be marked as 'offset', got {result_replicated.get(next_day_minute)}") failed |= 1 else: - print(f" ✗ ERROR: Rate should be {expected_rate}, got {result[next_day_minute]}") + print(f" FAIL: ERROR: Rate should be {expected_rate}, got {result[next_day_minute]}") failed |= 1 # Current day should not have offset if result[600] == 15.0: - print(f" ✓ Current day rate unchanged: {result[600]}") + print(f" PASS: Current day rate unchanged: {result[600]}") else: - print(f" ✗ ERROR: Current day should be 15.0, got {result[600]}") + print(f" FAIL: ERROR: Current day should be 15.0, got {result[600]}") failed |= 1 # Restore context @@ -606,14 +606,14 @@ def _test_export_offset_negative(my_predbat): next_day_minute = 1440 + 600 if next_day_minute in result: if result[next_day_minute] == 0.0: - print(f" ✓ Export rate with offset clamped to 0: {result[next_day_minute]}") + print(f" PASS: Export rate with offset clamped to 0: {result[next_day_minute]}") if result_replicated.get(next_day_minute) == "offset": - print(f" ✓ Replicated type correctly marked as 'offset'") + print(f" PASS: Replicated type correctly marked as 'offset'") else: - print(f" ✗ ERROR: Should be marked as 'offset', got {result_replicated.get(next_day_minute)}") + print(f" FAIL: ERROR: Should be marked as 'offset', got {result_replicated.get(next_day_minute)}") failed |= 1 else: - print(f" ✗ ERROR: Rate should be 0.0 (clamped), got {result[next_day_minute]}") + print(f" FAIL: ERROR: Rate should be 0.0 (clamped), got {result[next_day_minute]}") failed |= 1 # Test with higher rate that doesn't go negative @@ -624,9 +624,9 @@ def _test_export_offset_negative(my_predbat): result2, result_replicated2 = my_predbat.rate_replicate(rates2, is_import=False, is_gas=False) expected_rate = 15.0 - 8.0 if next_day_minute in result2 and result2[next_day_minute] == expected_rate: - print(f" ✓ Export rate with offset (not clamped): {result2[next_day_minute]}") + print(f" PASS: Export rate with offset (not clamped): {result2[next_day_minute]}") else: - print(f" ✗ ERROR: Rate should be {expected_rate}, got {result2.get(next_day_minute)}") + print(f" FAIL: ERROR: Rate should be {expected_rate}, got {result2.get(next_day_minute)}") failed |= 1 # Restore context @@ -667,9 +667,9 @@ def _test_gas_rates(my_predbat): expected_rate = 10.0 + 3.0 # Import offset is applied if next_day_minute in result: if result[next_day_minute] == expected_rate: - print(f" ✓ Gas import rates get import offset applied: {result[next_day_minute]}") + print(f" PASS: Gas import rates get import offset applied: {result[next_day_minute]}") else: - print(f" ✗ ERROR: Gas rate should be {expected_rate} (with import offset), got {result[next_day_minute]}") + print(f" FAIL: ERROR: Gas rate should be {expected_rate} (with import offset), got {result[next_day_minute]}") failed |= 1 # Test export gas rates - should NOT get export offset due to is_gas check @@ -682,9 +682,9 @@ def _test_gas_rates(my_predbat): # Export gas rates should replicate WITHOUT export offset (is_gas bypasses export offset) if next_day_minute in result2: if result2[next_day_minute] == 10.0: - print(f" ✓ Gas export rates replicate without export offset: {result2[next_day_minute]}") + print(f" PASS: Gas export rates replicate without export offset: {result2[next_day_minute]}") else: - print(f" ✗ ERROR: Gas export rate should be 10.0 (no export offset due to is_gas), got {result2[next_day_minute]}") + print(f" FAIL: ERROR: Gas export rate should be 10.0 (no export offset due to is_gas), got {result2[next_day_minute]}") failed |= 1 # Restore context diff --git a/apps/predbat/tests/test_sigenergy.py b/apps/predbat/tests/test_sigenergy.py index 351012f51..651276668 100644 --- a/apps/predbat/tests/test_sigenergy.py +++ b/apps/predbat/tests/test_sigenergy.py @@ -142,7 +142,7 @@ def __init__(self, prefix="predbat"): # ComponentBase attributes not set by initialize() — wire them manually self.api_started = False self.api_stop = False - # Skip mode-switch → command delay in unit tests + # Skip mode-switch -> command delay in unit tests self._command_delay = 0 # ComponentBase.storage looks at self.base.components, which this mock doesn't set up — # override it directly so tests can plug in a FakeStorage via self._mock_storage. @@ -204,15 +204,15 @@ def test_sigenergy_helper_functions(my_predbat): # _safe_float assert _safe_float(3.14) == 3.14, "_safe_float: float passthrough" assert _safe_float("2.5") == 2.5, "_safe_float: string to float" - assert _safe_float(None) == 0.0, "_safe_float: None → 0.0" - assert _safe_float("abc") == 0.0, "_safe_float: invalid string → 0.0" + assert _safe_float(None) == 0.0, "_safe_float: None -> 0.0" + assert _safe_float("abc") == 0.0, "_safe_float: invalid string -> 0.0" assert _safe_float(None, default=99.0) == 99.0, "_safe_float: None with custom default" # _safe_int assert _safe_int(42) == 42, "_safe_int: int passthrough" assert _safe_int("7") == 7, "_safe_int: string to int" - assert _safe_int(None) == 0, "_safe_int: None → 0" - assert _safe_int("bad") == 0, "_safe_int: invalid → 0" + assert _safe_int(None) == 0, "_safe_int: None -> 0" + assert _safe_int("bad") == 0, "_safe_int: invalid -> 0" assert _safe_int(None, default=5) == 5, "_safe_int: None with custom default" return failed @@ -250,7 +250,7 @@ def test_sigenergy_system_slug(my_predbat): failed = False api = MockSigenergyAPI() - # Long ID → last 12 chars + # Long ID -> last 12 chars slug = api._system_slug("ABCDEFGHIJKLMNOPQRSTUVWXYZ") assert len(slug) <= 12, "Slug max 12 chars: {}".format(slug) @@ -571,10 +571,10 @@ def test_sigenergy_apply_service_to_toggle(my_predbat): failed = False api = MockSigenergyAPI() - assert api._apply_service_to_toggle(False, "turn_on") is True, "turn_on → True" - assert api._apply_service_to_toggle(True, "turn_off") is False, "turn_off → False" - assert api._apply_service_to_toggle(False, "toggle") is True, "toggle False → True" - assert api._apply_service_to_toggle(True, "toggle") is False, "toggle True → False" + assert api._apply_service_to_toggle(False, "turn_on") is True, "turn_on -> True" + assert api._apply_service_to_toggle(True, "turn_off") is False, "turn_off -> False" + assert api._apply_service_to_toggle(False, "toggle") is True, "toggle False -> True" + assert api._apply_service_to_toggle(True, "toggle") is False, "toggle True -> False" assert api._apply_service_to_toggle(True, "unknown") is True, "unknown keeps current" return failed @@ -1773,9 +1773,9 @@ def test_sigenergy_fetch_inverter_realtime(my_predbat): "deviceType": "Inverter", "realTimeInfo": { "batSoc": 72.0, - "batPower": 3.0, # discharging → batteryPower should be -3.0 + "batPower": 3.0, # discharging -> batteryPower should be -3.0 "pvPower": 5.0, - "activePower": 1.5, # export → gridPower = 1.5 + "activePower": 1.5, # export -> gridPower = 1.5 "pvEnergyDaily": 12.5, }, }, @@ -1870,22 +1870,22 @@ def test_sigenergy_get_inverter_serial(my_predbat): failed = False api = MockSigenergyAPI() - # No devices → None + # No devices -> None api.devices["SYS1"] = [] assert api._get_inverter_serial("SYS1") is None, "Empty device list returns None" - # Only battery → None + # Only battery -> None api.devices["SYS1"] = [{"deviceType": "Battery", "serialNumber": "BAT001"}] assert api._get_inverter_serial("SYS1") is None, "Battery-only list returns None" - # Inverter type → found + # Inverter type -> found api.devices["SYS1"] = [ {"deviceType": "Battery", "serialNumber": "BAT001"}, {"deviceType": "Inverter", "serialNumber": "INV001"}, ] assert api._get_inverter_serial("SYS1") == "INV001", "Inverter serial returned" - # AIO type → found + # AIO type -> found api.devices["SYS2"] = [{"deviceType": "AIO", "serialNumber": "AIO001"}] assert api._get_inverter_serial("SYS2") == "AIO001", "AIO serial returned" @@ -1992,7 +1992,7 @@ def _make_api_with_system(system_id="SIG001"): def test_sigenergy_manage_vpp_registration_switch_to_msc(my_predbat): - """Readonly=True + VPP active → set_operating_mode(MSC) called, returns False.""" + """Readonly=True + VPP active -> set_operating_mode(MSC) called, returns False.""" from sigenergy import SIGENERGY_MODE_MSC failed = False sid = "SIG001" @@ -2017,7 +2017,7 @@ async def mock_set_mode(system_id, mode_int): def test_sigenergy_manage_vpp_registration_switch_to_vpp(my_predbat): - """Readonly=False + not VPP → set_operating_mode(VPP) called, returns False (activating async).""" + """Readonly=False + not VPP -> set_operating_mode(VPP) called, returns False (activating async).""" failed = False sid = "SIG001" api = _make_api_with_system(sid) @@ -2041,7 +2041,7 @@ async def mock_set_mode(system_id, mode_int): def test_sigenergy_onboard_systems_pending_per_item(my_predbat): - """onboard_systems: real API per-item response result=False codeList=[1116] → returns None and logs warning.""" + """onboard_systems: real API per-item response result=False codeList=[1116] -> returns None and logs warning.""" failed = False sid = "SIG001" api = _make_api_with_system(sid) @@ -2062,7 +2062,7 @@ async def mock_request(method, path, json_data=None, params=None, retries=3): def test_sigenergy_onboard_systems_other_vpp(my_predbat): - """onboard_systems: per-item codeList=[1103] (other VPP) → returns False and logs warning.""" + """onboard_systems: per-item codeList=[1103] (other VPP) -> returns False and logs warning.""" failed = False sid = "SIG001" api = _make_api_with_system(sid) @@ -2082,7 +2082,7 @@ async def mock_request(method, path, json_data=None, params=None, retries=3): def test_sigenergy_manage_vpp_registration_ready(my_predbat): - """Readonly=False + VPP active → no onboard/offboard, returns True.""" + """Readonly=False + VPP active -> no onboard/offboard, returns True.""" failed = False sid = "SIG001" api = _make_api_with_system(sid) @@ -2109,7 +2109,7 @@ async def mock_offboard(system_ids): def test_sigenergy_manage_vpp_registration_readonly_no_vpp(my_predbat): - """Readonly=True + VPP not active → nothing to do, returns False.""" + """Readonly=True + VPP not active -> nothing to do, returns False.""" failed = False sid = "SIG001" api = _make_api_with_system(sid) @@ -2244,7 +2244,7 @@ def test_sigenergy_update_control_time_validation(my_predbat): def test_sigenergy_offboard_toggle_in_vpp(my_predbat): - """offboard=True → return False immediately regardless of VPP state (no mode switch).""" + """offboard=True -> return False immediately regardless of VPP state (no mode switch).""" failed = False sid = "SIG001" api = _make_api_with_system(sid) @@ -2266,7 +2266,7 @@ async def mock_set_mode(system_id, mode_int): def test_sigenergy_offboard_toggle_not_in_vpp(my_predbat): - """offboard=True + not in VPP → return False, no mode switch.""" + """offboard=True + not in VPP -> return False, no mode switch.""" failed = False sid = "SIG001" api = _make_api_with_system(sid) @@ -2330,14 +2330,14 @@ def test_sigenergy_onboard_status(my_predbat): api = MockSigenergyAPI() assert api.onboard_status == {}, "onboard_status starts empty" - # Pending approval (1116) → pending_approval, returns None + # Pending approval (1116) -> pending_approval, returns None api._request = AsyncMock(return_value=None) api._last_api_code = SIGENERGY_CODE_SYSTEM_PENDING_REVIEW result = run_async(api.onboard_systems(["sys-1"])) assert result is None, "pending review returns None" assert api.onboard_status["sys-1"] == "pending_approval", "pending_approval status set" - # Registered to another VPP (1103) → in_other_vpp, returns False + # Registered to another VPP (1103) -> in_other_vpp, returns False api2 = MockSigenergyAPI() api2._request = AsyncMock(return_value=None) api2._last_api_code = SIGENERGY_CODE_IN_OTHER_VPP @@ -2420,7 +2420,7 @@ def _make_api(mode, offboard_on=False): api.apply_controls = AsyncMock() return api - # System in VPP mode → active + # System in VPP mode -> active api_active = _make_api(SIGENERGY_MODE_VPP) ok = run_async(api_active.run(seconds=300, first=False)) assert ok is True, "run() returns True on success" @@ -2429,13 +2429,13 @@ def _make_api(mode, offboard_on=False): assert api_active.dashboard_items[sensor_key]["state"] == "active", "active sensor published" assert api_active.dashboard_items[sensor_key]["attributes"]["in_vpp"] is True - # System in MSC mode, not offboarded → pending_approval + # System in MSC mode, not offboarded -> pending_approval api_pending = _make_api(SIGENERGY_MODE_MSC) run_async(api_pending.run(seconds=300, first=False)) assert api_pending.onboard_status[sid] == "pending_approval", "pending_approval derived from MSC mode" assert api_pending.dashboard_items[sensor_key]["state"] == "pending_approval" - # Offboard toggle on → offboarded regardless of mode + # Offboard toggle on -> offboarded regardless of mode api_offboard = _make_api(SIGENERGY_MODE_VPP, offboard_on=True) run_async(api_offboard.run(seconds=300, first=False)) assert api_offboard.onboard_status[sid] == "offboarded", "offboarded when toggle is on" diff --git a/apps/predbat/tests/test_solax.py b/apps/predbat/tests/test_solax.py index eb38f2fa7..a09b8594f 100644 --- a/apps/predbat/tests/test_solax.py +++ b/apps/predbat/tests/test_solax.py @@ -259,7 +259,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Second call not self_consume_mode, got {second_call_endpoint} ****") failed = True else: - print(f"✓ ECO mode applied correctly at 12:00") + print(f"PASS: ECO mode applied correctly at 12:00") # Test 2: Charge mode (inside charge window) at 03:00 print("\n--- Test 2: Charge mode (03:00) ---") @@ -302,7 +302,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Wrong charge power, expected 5000 got {power} ****") failed = True else: - print(f"✓ Charge mode applied correctly at 03:00 (power=5000W, target_soc=95%)") + print(f"PASS: Charge mode applied correctly at 03:00 (power=5000W, target_soc=95%)") # Test 3: Export mode (inside export window) at 18:00 print("\n--- Test 3: Export mode (18:00) ---") @@ -345,7 +345,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Wrong export power, expected -4500 got {power} ****") failed = True else: - print(f"✓ Export mode applied correctly at 18:00 (power=-4500W, target_soc=15%)") + print(f"PASS: Export mode applied correctly at 18:00 (power=-4500W, target_soc=15%)") # Test 4: Hash prevents re-application (same charge mode at 03:00) print("\n--- Test 4: Hash caching (repeat charge at 03:00) ---") @@ -369,7 +369,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: send_command_and_wait called {mock_send.call_count} times when hash should have prevented it ****") failed = True else: - print(f"✓ Hash correctly prevented re-application of same mode") + print(f"PASS: Hash correctly prevented re-application of same mode") # Test 5: Hash expires after 15 minutes print("\n--- Test 5: Hash expiry (16 minutes later) ---") @@ -394,7 +394,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Expected 2 API calls when hash expired, got {mock_send.call_count} ****") failed = True else: - print(f"✓ Hash correctly expired after 15 minutes, mode re-applied") + print(f"PASS: Hash correctly expired after 15 minutes, mode re-applied") # Test 6: Charge disabled - should use eco mode print("\n--- Test 6: Charge disabled (03:00) ---") @@ -428,7 +428,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Second call not self_consume_mode, got {second_call_endpoint} ****") failed = True else: - print(f"✓ ECO mode correctly applied when charge disabled at 03:00") + print(f"PASS: ECO mode correctly applied when charge disabled at 03:00") # Test 7: Freeze charge mode (inside charge window, target_soc == current_soc) print("\n--- Test 7: Freeze charge mode (03:00, target_soc == current_soc) ---") @@ -464,7 +464,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Second call not self_consume_charge_only_mode, got {second_call_endpoint} ****") failed = True else: - print(f"✓ Freeze charge mode applied correctly at 03:00 (target_soc == current_soc)") + print(f"PASS: Freeze charge mode applied correctly at 03:00 (target_soc == current_soc)") # Test 8: Freeze export mode (inside export window, target_soc >= current_soc) print("\n--- Test 8: Freeze export mode (18:00, target_soc >= current_soc) ---") @@ -499,7 +499,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Second call not exit_vpp_mode, got {second_call_endpoint} ****") failed = True else: - print(f"✓ Freeze export mode applied correctly at 18:00 (target_soc >= current_soc)") + print(f"PASS: Freeze export mode applied correctly at 18:00 (target_soc >= current_soc)") # Test 9: Midnight-spanning charge window - currently after midnight (00:30) inside 23:30-05:30 window # This was the bug: charge_start (23:30 today) > now (00:30 today) so the window was missed @@ -533,7 +533,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Expected soc_target_control_mode (charge mode) after midnight, got: {calls_str} ****") failed = True else: - print(f"✓ Midnight-spanning charge window correctly detected at 00:30") + print(f"PASS: Midnight-spanning charge window correctly detected at 00:30") # Test 10: Midnight-spanning charge window - currently before midnight (23:45) inside 23:30-05:30 window print("\n--- Test 10: Midnight-spanning charge window at 23:45 (window 23:30-05:30) ---") @@ -559,7 +559,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Expected soc_target_control_mode (charge mode) before midnight, got: {calls_str} ****") failed = True else: - print(f"✓ Midnight-spanning charge window correctly detected at 23:45") + print(f"PASS: Midnight-spanning charge window correctly detected at 23:45") # Test 11: After window end (06:00) should be eco mode, not charge mode print("\n--- Test 11: After midnight-spanning window end at 06:00 (window 23:30-05:30) ---") @@ -588,7 +588,7 @@ async def test_apply_controls(solax_api, test_plant_id): print(f"**** ERROR: Expected eco mode (charge_or_discharge_mode) at 06:00, got: {calls_str} ****") failed = True else: - print(f"✓ After midnight-spanning window end (06:00) correctly uses eco mode") + print(f"PASS: After midnight-spanning window end (06:00) correctly uses eco mode") return failed @@ -627,7 +627,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Reserve not updated. Expected {new_value}, got {solax_api.controls[test_plant_id]['reserve']} ****") failed = True else: - print(f"✓ Reserve setting updated to {new_value}") + print(f"PASS: Reserve setting updated to {new_value}") # Test 2: Invalid entity ID format invalid_entity_id = "number.invalid_format" @@ -638,7 +638,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Reserve changed on invalid entity ID ****") failed = True else: - print(f"✓ Invalid entity ID handled gracefully") + print(f"PASS: Invalid entity ID handled gracefully") # Test 3: Plant not in controls missing_plant_id = "9999999999999999999" @@ -650,7 +650,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Missing plant incorrectly added to controls ****") failed = True else: - print(f"✓ Missing plant handled correctly") + print(f"PASS: Missing plant handled correctly") # Test 4: Invalid number value entity_id = f"number.{solax_api.prefix}_solax_{test_plant_id}_setting_reserve" @@ -661,7 +661,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Reserve changed on invalid number value ****") failed = True else: - print(f"✓ Invalid number value handled gracefully") + print(f"PASS: Invalid number value handled gracefully") # Test 5: Update charge start_time (battery schedule) entity_id = f"select.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_charge_start_time" @@ -672,7 +672,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Charge start_time not updated. Expected {new_time}, got {solax_api.controls[test_plant_id]['charge']['start_time']} ****") failed = True else: - print(f"✓ Charge start_time updated to {new_time}") + print(f"PASS: Charge start_time updated to {new_time}") # Test 6: Update charge end_time entity_id = f"select.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_charge_end_time" @@ -683,7 +683,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Charge end_time not updated. Expected {new_time}, got {solax_api.controls[test_plant_id]['charge']['end_time']} ****") failed = True else: - print(f"✓ Charge end_time updated to {new_time}") + print(f"PASS: Charge end_time updated to {new_time}") # Test 7: Update charge enable (switch - turn_on service) entity_id = f"switch.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_charge_enable" @@ -693,7 +693,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Charge enable not set to True ****") failed = True else: - print(f"✓ Charge enable turned on") + print(f"PASS: Charge enable turned on") # Test 8: Update charge enable (switch - turn_off service) await solax_api.switch_event(entity_id, "turn_off") @@ -702,7 +702,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Charge enable not set to False ****") failed = True else: - print(f"✓ Charge enable turned off") + print(f"PASS: Charge enable turned off") # Test 9: Update export start_time entity_id = f"select.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_export_start_time" @@ -713,7 +713,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Export start_time not updated. Expected {new_time}, got {solax_api.controls[test_plant_id]['export']['start_time']} ****") failed = True else: - print(f"✓ Export start_time updated to {new_time}") + print(f"PASS: Export start_time updated to {new_time}") # Test 10: Update charge target_soc entity_id = f"number.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_charge_target_soc" @@ -724,7 +724,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Charge target_soc not updated. Expected {new_soc}, got {solax_api.controls[test_plant_id]['charge']['target_soc']} ****") failed = True else: - print(f"✓ Charge target_soc updated to {new_soc}") + print(f"PASS: Charge target_soc updated to {new_soc}") # Test 11: Update charge rate entity_id = f"number.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_charge_rate" @@ -735,7 +735,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Charge rate not updated. Expected {new_rate}, got {solax_api.controls[test_plant_id]['charge']['rate']} ****") failed = True else: - print(f"✓ Charge rate updated to {new_rate}") + print(f"PASS: Charge rate updated to {new_rate}") # Test 12: Invalid time format (HH:MM instead of HH:MM:SS) - should be auto-fixed entity_id = f"select.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_charge_start_time" @@ -747,7 +747,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Time format conversion failed. Expected {expected_time}, got {solax_api.controls[test_plant_id]['charge']['start_time']} ****") failed = True else: - print(f"✓ Time format auto-converted from {new_time_short} to {expected_time}") + print(f"PASS: Time format auto-converted from {new_time_short} to {expected_time}") # Test 13: Update export target_soc entity_id = f"number.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_export_target_soc" @@ -758,7 +758,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Export target_soc not updated. Expected {new_export_soc}, got {solax_api.controls[test_plant_id]['export']['target_soc']} ****") failed = True else: - print(f"✓ Export target_soc updated to {new_export_soc}") + print(f"PASS: Export target_soc updated to {new_export_soc}") # Test 14: Update export rate entity_id = f"number.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_export_rate" @@ -769,7 +769,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Export rate not updated. Expected {new_export_rate}, got {solax_api.controls[test_plant_id]['export']['rate']} ****") failed = True else: - print(f"✓ Export rate updated to {new_export_rate}") + print(f"PASS: Export rate updated to {new_export_rate}") # Test 15: Update export end_time entity_id = f"select.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_export_end_time" @@ -780,7 +780,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Export end_time not updated. Expected {new_time}, got {solax_api.controls[test_plant_id]['export']['end_time']} ****") failed = True else: - print(f"✓ Export end_time updated to {new_time}") + print(f"PASS: Export end_time updated to {new_time}") # Test 16: Update export enable (switch - turn_on service) entity_id = f"switch.{solax_api.prefix}_solax_{test_plant_id}_battery_schedule_export_enable" @@ -790,7 +790,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Export enable not set to True ****") failed = True else: - print(f"✓ Export enable turned on") + print(f"PASS: Export enable turned on") # Test 17: Update export enable (switch - toggle service) await solax_api.switch_event(entity_id, "toggle") @@ -799,7 +799,7 @@ async def test_write_setting_from_event(my_predbat): print(f"**** ERROR: Export enable not toggled to False ****") failed = True else: - print(f"✓ Export enable toggled off") + print(f"PASS: Export enable toggled off") return failed @@ -844,7 +844,7 @@ async def test_fetch_controls(solax_api, test_plant_id): print(f"**** ERROR: Reserve not fetched correctly. Expected 25, got {solax_api.controls[test_plant_id].get('reserve')} ****") failed = True else: - print("✓ Reserve fetched correctly (25)") + print("PASS: Reserve fetched correctly (25)") # Verify charge schedule charge_controls = solax_api.controls[test_plant_id]["charge"] @@ -852,31 +852,31 @@ async def test_fetch_controls(solax_api, test_plant_id): print(f"**** ERROR: Charge start_time not fetched correctly. Expected 02:30:00, got {charge_controls.get('start_time')} ****") failed = True else: - print("✓ Charge start_time fetched correctly (02:30:00)") + print("PASS: Charge start_time fetched correctly (02:30:00)") if charge_controls.get("end_time") != "06:00:00": print(f"**** ERROR: Charge end_time not fetched correctly. Expected 06:00:00, got {charge_controls.get('end_time')} ****") failed = True else: - print("✓ Charge end_time fetched correctly (06:00:00)") + print("PASS: Charge end_time fetched correctly (06:00:00)") if charge_controls.get("enable") != True: print(f"**** ERROR: Charge enable not fetched correctly. Expected True, got {charge_controls.get('enable')} ****") failed = True else: - print("✓ Charge enable fetched correctly (True)") + print("PASS: Charge enable fetched correctly (True)") if charge_controls.get("target_soc") != 90: print(f"**** ERROR: Charge target_soc not fetched correctly. Expected 90, got {charge_controls.get('target_soc')} ****") failed = True else: - print("✓ Charge target_soc fetched correctly (90)") + print("PASS: Charge target_soc fetched correctly (90)") if charge_controls.get("rate") != 5000: print(f"**** ERROR: Charge rate not fetched correctly. Expected 5000, got {charge_controls.get('rate')} ****") failed = True else: - print("✓ Charge rate fetched correctly (5000)") + print("PASS: Charge rate fetched correctly (5000)") # Verify export schedule export_controls = solax_api.controls[test_plant_id]["export"] @@ -884,31 +884,31 @@ async def test_fetch_controls(solax_api, test_plant_id): print(f"**** ERROR: Export start_time not fetched correctly. Expected 16:30:00, got {export_controls.get('start_time')} ****") failed = True else: - print("✓ Export start_time fetched correctly (16:30:00)") + print("PASS: Export start_time fetched correctly (16:30:00)") if export_controls.get("end_time") != "19:00:00": print(f"**** ERROR: Export end_time not fetched correctly. Expected 19:00:00, got {export_controls.get('end_time')} ****") failed = True else: - print("✓ Export end_time fetched correctly (19:00:00)") + print("PASS: Export end_time fetched correctly (19:00:00)") if export_controls.get("enable") != False: print(f"**** ERROR: Export enable not fetched correctly. Expected False, got {export_controls.get('enable')} ****") failed = True else: - print("✓ Export enable fetched correctly (False)") + print("PASS: Export enable fetched correctly (False)") if export_controls.get("target_soc") != 20: print(f"**** ERROR: Export target_soc not fetched correctly. Expected 20, got {export_controls.get('target_soc')} ****") failed = True else: - print("✓ Export target_soc fetched correctly (20)") + print("PASS: Export target_soc fetched correctly (20)") if export_controls.get("rate") != 4500: print(f"**** ERROR: Export rate not fetched correctly. Expected 4500, got {export_controls.get('rate')} ****") failed = True else: - print("✓ Export rate fetched correctly (4500)") + print("PASS: Export rate fetched correctly (4500)") return failed @@ -1000,7 +1000,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Battery SOC unit incorrect ****") failed = True else: - print(f"✓ Battery SOC sensor published correctly ({expected_soc} kWh)") + print(f"PASS: Battery SOC sensor published correctly ({expected_soc} kWh)") # Test 2: Battery capacity sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_battery_capacity" @@ -1013,7 +1013,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Battery capacity incorrect. Expected 15.0, got {item['state']} ****") failed = True else: - print(f"✓ Battery capacity sensor published correctly (15.0 kWh)") + print(f"PASS: Battery capacity sensor published correctly (15.0 kWh)") # Test 3: Battery temperature sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_battery_temperature" @@ -1029,7 +1029,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Battery temperature unit incorrect ****") failed = True else: - print(f"✓ Battery temperature sensor published correctly (18.5°C)") + print(f"PASS: Battery temperature sensor published correctly (18.5°C)") # Test 4: Battery max power sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_battery_max_power" @@ -1046,7 +1046,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Battery max power unit incorrect ****") failed = True else: - print(f"✓ Battery max power sensor published correctly ({expected_power}W)") + print(f"PASS: Battery max power sensor published correctly ({expected_power}W)") # Test 5: Inverter max power sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_inverter_max_power" @@ -1063,7 +1063,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Inverter max power unit incorrect ****") failed = True else: - print(f"✓ Inverter max power sensor published correctly ({expected_power}W)") + print(f"PASS: Inverter max power sensor published correctly ({expected_power}W)") # Test 6: PV capacity sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_pv_capacity" @@ -1079,7 +1079,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: PV capacity unit incorrect ****") failed = True else: - print(f"✓ PV capacity sensor published correctly (8.5 kWp)") + print(f"PASS: PV capacity sensor published correctly (8.5 kWp)") # Test 7: Total yield sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_total_yield" @@ -1095,7 +1095,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Total yield unit incorrect ****") failed = True else: - print(f"✓ Total yield sensor published correctly (3250.5 kWh)") + print(f"PASS: Total yield sensor published correctly (3250.5 kWh)") # Test 8: Total charged sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_total_charged" @@ -1108,7 +1108,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Total charged incorrect. Expected 1850.2, got {item['state']} ****") failed = True else: - print(f"✓ Total charged sensor published correctly (1850.2 kWh)") + print(f"PASS: Total charged sensor published correctly (1850.2 kWh)") # Test 9: Total discharged sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_total_discharged" @@ -1121,7 +1121,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Total discharged incorrect. Expected 1720.8, got {item['state']} ****") failed = True else: - print(f"✓ Total discharged sensor published correctly (1720.8 kWh)") + print(f"PASS: Total discharged sensor published correctly (1720.8 kWh)") # Test 10: Total imported sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_total_imported" @@ -1134,7 +1134,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Total imported incorrect. Expected 4200.3, got {item['state']} ****") failed = True else: - print(f"✓ Total imported sensor published correctly (4200.3 kWh)") + print(f"PASS: Total imported sensor published correctly (4200.3 kWh)") # Test 11: Total exported sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_total_exported" @@ -1147,7 +1147,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Total exported incorrect. Expected 2800.7, got {item['state']} ****") failed = True else: - print(f"✓ Total exported sensor published correctly (2800.7 kWh)") + print(f"PASS: Total exported sensor published correctly (2800.7 kWh)") # Test 12: Total load sensor (calculated) entity_id = f"sensor.{prefix}_solax_{test_plant_id}_total_load" @@ -1163,7 +1163,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Total load incorrect. Expected {expected_load}, got {item['state']} ****") failed = True else: - print(f"✓ Total load sensor published correctly ({expected_load} kWh)") + print(f"PASS: Total load sensor published correctly ({expected_load} kWh)") # Test 13: Total earnings sensor entity_id = f"sensor.{prefix}_solax_{test_plant_id}_total_earnings" @@ -1179,7 +1179,7 @@ async def test_publish_plant_info(solax_api, test_plant_id, test_plant_name): print(f"**** ERROR: Total earnings unit incorrect ****") failed = True else: - print(f"✓ Total earnings sensor published correctly (485.50)") + print(f"PASS: Total earnings sensor published correctly (485.50)") return failed @@ -1224,7 +1224,7 @@ async def test_get_access_token_main(my_predbat): print(f"**** ERROR: error_count should be 0, got {solax_api.error_count} ****") failed = True else: - print(f"✓ Successful authentication test passed") + print(f"PASS: Successful authentication test passed") # Test 2: Invalid credentials (10402) print("Test 2: Invalid credentials (code 10402)") @@ -1252,7 +1252,7 @@ async def test_get_access_token_main(my_predbat): print(f"**** ERROR: error_count should be 1, got {solax_api.error_count} ****") failed = True else: - print(f"✓ Invalid credentials test passed") + print(f"PASS: Invalid credentials test passed") # Test 3: Other error codes print("Test 3: Other API error codes") @@ -1272,7 +1272,7 @@ async def test_get_access_token_main(my_predbat): print(f"**** ERROR: error_count should be 1, got {solax_api.error_count} ****") failed = True else: - print(f"✓ Other error codes test passed") + print(f"PASS: Other error codes test passed") # Test 4: Network timeout print("Test 4: Network timeout") @@ -1291,7 +1291,7 @@ async def test_get_access_token_main(my_predbat): print(f"**** ERROR: error_count should be 1, got {solax_api.error_count} ****") failed = True else: - print(f"✓ Network timeout test passed") + print(f"PASS: Network timeout test passed") # Test 5: HTTP errors print("Test 5: HTTP 500 error") @@ -1311,7 +1311,7 @@ async def test_get_access_token_main(my_predbat): print(f"**** ERROR: error_count should be 1, got {solax_api.error_count} ****") failed = True else: - print(f"✓ HTTP error test passed") + print(f"PASS: HTTP error test passed") # Test 6: JSON decode errors print("Test 6: JSON decode error") @@ -1331,7 +1331,7 @@ async def test_get_access_token_main(my_predbat): print(f"**** ERROR: error_count should be 1, got {solax_api.error_count} ****") failed = True else: - print(f"✓ JSON decode error test passed") + print(f"PASS: JSON decode error test passed") # Test 7: Missing access_token print("Test 7: Missing access_token in response") @@ -1351,7 +1351,7 @@ async def test_get_access_token_main(my_predbat): print(f"**** ERROR: error_count should be 1, got {solax_api.error_count} ****") failed = True else: - print(f"✓ Missing access_token test passed") + print(f"PASS: Missing access_token test passed") # Test 8: Default expires_in print("Test 8: Default expires_in fallback") @@ -1389,10 +1389,10 @@ async def test_get_access_token_main(my_predbat): print(f"**** ERROR: token_expiry time difference too large for default: {time_diff} seconds ****") failed = True else: - print(f"✓ Default expires_in test passed") + print(f"PASS: Default expires_in test passed") if not failed: - print("✓ Authentication tests passed") + print("PASS: Authentication tests passed") return failed @@ -1428,7 +1428,7 @@ async def successful_func(): print(f"**** ERROR: Expected 1 call, got {call_count} ****") failed = True else: - print(f"✓ Successful call test passed") + print(f"PASS: Successful call test passed") # Test 2: ClientError with retry and eventual success print("\n--- Test 2: ClientError with retry and eventual success ---") @@ -1450,7 +1450,7 @@ async def retry_then_succeed(): print(f"**** ERROR: Expected 2 calls (1 failure + 1 success), got {call_count} ****") failed = True else: - print(f"✓ ClientError retry test passed") + print(f"PASS: ClientError retry test passed") # Test 3: TimeoutError with retry and eventual success print("\n--- Test 3: TimeoutError with retry and eventual success ---") @@ -1472,7 +1472,7 @@ async def timeout_then_succeed(): print(f"**** ERROR: Expected 2 calls (1 timeout + 1 success), got {call_count} ****") failed = True else: - print(f"✓ TimeoutError retry test passed") + print(f"PASS: TimeoutError retry test passed") # Test 4: Max retries exceeded (SOLAX_RETRIES) print("\n--- Test 4: Max retries exceeded ---") @@ -1502,7 +1502,7 @@ async def always_fail(): print(f"**** ERROR: Expected error_count to increment by 1, got {solax_api.error_count - initial_error_count} ****") failed = True else: - print(f"✓ Max retries test passed (SOLAX_RETRIES={SOLAX_RETRIES})") + print(f"PASS: Max retries test passed (SOLAX_RETRIES={SOLAX_RETRIES})") # Test 5: Unexpected exception breaks retry loop print("\n--- Test 5: Unexpected exception breaks retry loop ---") @@ -1526,7 +1526,7 @@ async def unexpected_error(): print(f"**** ERROR: Expected error_count to increment by 1, got {solax_api.error_count - initial_error_count} ****") failed = True else: - print(f"✓ Unexpected exception test passed") + print(f"PASS: Unexpected exception test passed") # Test 6: Verify exponential backoff (retry * 0.5) # Note: We mock asyncio.sleep to speed up the test @@ -1552,10 +1552,10 @@ async def fail_twice_then_succeed(): print(f"**** ERROR: Expected 3 calls (2 failures + 1 success), got {call_count} ****") failed = True else: - print(f"✓ Multiple retry test passed (exponential backoff pattern verified)") + print(f"PASS: Multiple retry test passed (exponential backoff pattern verified)") if not failed: - print("✓ request_wrapper tests passed") + print("PASS: request_wrapper tests passed") return failed @@ -1682,7 +1682,7 @@ async def test_request_get_impl_get(my_predbat): print(f"**** ERROR: Expected success response, got {result} ****") failed = True else: - print(f"✓ Successful GET with valid token test passed") + print(f"PASS: Successful GET with valid token test passed") # Test 2: Token expired - should refresh print("\n--- Test 2: Token expired - should refresh ---") @@ -1742,7 +1742,7 @@ async def session_aexit(*args): print(f"**** ERROR: Expected success after refresh, got {result} ****") failed = True else: - print(f"✓ Token expired and refreshed test passed") + print(f"PASS: Token expired and refreshed test passed") # Test 3: HTTP 404 error print("\n--- Test 3: HTTP 404 error ---") @@ -1765,7 +1765,7 @@ async def session_aexit(*args): print(f"**** ERROR: Expected error_count to increment by 1, got {solax_api.error_count - initial_error_count} ****") failed = True else: - print(f"✓ HTTP 404 error test passed") + print(f"PASS: HTTP 404 error test passed") # Test 4: HTTP 500 error print("\n--- Test 4: HTTP 500 error ---") @@ -1788,7 +1788,7 @@ async def session_aexit(*args): print(f"**** ERROR: Expected error_count to increment by 1, got {solax_api.error_count - initial_error_count} ****") failed = True else: - print(f"✓ HTTP 500 error test passed") + print(f"PASS: HTTP 500 error test passed") # Test 5: Authentication error in response (code 10401) print("\n--- Test 5: Authentication error in response (code 10401) ---") @@ -1820,7 +1820,7 @@ async def session_aexit(*args): print(f"**** ERROR: Expected error_count to increment by 1, got {solax_api.error_count - initial_error_count} ****") failed = True else: - print(f"✓ Authentication error (10401) test passed") + print(f"PASS: Authentication error (10401) test passed") # Test 6: JSON decode error print("\n--- Test 6: JSON decode error ---") @@ -1845,7 +1845,7 @@ async def session_aexit(*args): print(f"**** ERROR: Expected error_count to increment by 1, got {solax_api.error_count - initial_error_count} ****") failed = True else: - print(f"✓ JSON decode error test passed") + print(f"PASS: JSON decode error test passed") # Test 7: No token available and refresh fails print("\n--- Test 7: No token available and refresh fails ---") @@ -1864,10 +1864,10 @@ async def session_aexit(*args): print(f"**** ERROR: Expected None when token refresh fails, got {result} ****") failed = True else: - print(f"✓ Failed token refresh test passed") + print(f"PASS: Failed token refresh test passed") if not failed: - print("✓ _request_get_impl GET tests passed") + print("PASS: _request_get_impl GET tests passed") return failed @@ -1903,7 +1903,7 @@ async def test_request_get_impl_post(my_predbat): print(f"**** ERROR: Expected success response, got {result} ****") failed = True else: - print(f"✓ Successful POST with JSON body test passed") + print(f"PASS: Successful POST with JSON body test passed") # Test 2: POST with Content-Type header verification print("\n--- Test 2: POST with Content-Type header ---") @@ -1925,7 +1925,7 @@ async def test_request_get_impl_post(my_predbat): print(f"**** ERROR: Expected success response, got {result} ****") failed = True else: - print(f"✓ POST with Content-Type header test passed") + print(f"PASS: POST with Content-Type header test passed") # Test 3: POST HTTP 400 error (bad request) print("\n--- Test 3: POST HTTP 400 error ---") @@ -1949,7 +1949,7 @@ async def test_request_get_impl_post(my_predbat): print(f"**** ERROR: Expected error_count to increment by 1, got {solax_api.error_count - initial_error_count} ****") failed = True else: - print(f"✓ POST HTTP 400 error test passed") + print(f"PASS: POST HTTP 400 error test passed") # Test 4: POST with authentication error in response print("\n--- Test 4: POST with authentication error (code 10400) ---") @@ -1982,7 +1982,7 @@ async def test_request_get_impl_post(my_predbat): print(f"**** ERROR: Expected error_count to increment by 1 ****") failed = True else: - print(f"✓ POST authentication error test passed") + print(f"PASS: POST authentication error test passed") # Test 5: POST with JSON decode error print("\n--- Test 5: POST with JSON decode error ---") @@ -2008,7 +2008,7 @@ async def test_request_get_impl_post(my_predbat): print(f"**** ERROR: Expected error_count to increment by 1 ****") failed = True else: - print(f"✓ POST JSON decode error test passed") + print(f"PASS: POST JSON decode error test passed") # Test 6: POST with empty body print("\n--- Test 6: POST with empty body ---") @@ -2027,10 +2027,10 @@ async def test_request_get_impl_post(my_predbat): print(f"**** ERROR: Expected success response, got {result} ****") failed = True else: - print(f"✓ POST with empty body test passed") + print(f"PASS: POST with empty body test passed") if not failed: - print("✓ _request_get_impl POST tests passed") + print("PASS: _request_get_impl POST tests passed") return failed @@ -2082,7 +2082,7 @@ async def test_fetch_paginated_data(my_predbat): print(f"**** ERROR: Record data mismatch ****") failed = True else: - print(f"✓ Single page with records test passed") + print(f"PASS: Single page with records test passed") # Test 2: Multiple pages (3 pages) print("\n--- Test 2: Multiple pages (3 pages) ---") @@ -2175,7 +2175,7 @@ async def session_aexit(*args): print(f"**** ERROR: Records not collected correctly across pages ****") failed = True else: - print(f"✓ Multiple pages (3 pages) test passed") + print(f"PASS: Multiple pages (3 pages) test passed") # Test 3: Empty result (no records) print("\n--- Test 3: Empty result (no records) ---") @@ -2208,7 +2208,7 @@ async def session_aexit(*args): print(f"**** ERROR: Expected 0 records, got {len(result)} ****") failed = True else: - print(f"✓ Empty result test passed") + print(f"PASS: Empty result test passed") # Test 4: API error on first page print("\n--- Test 4: API error on first page ---") @@ -2231,7 +2231,7 @@ async def session_aexit(*args): print(f"**** ERROR: Expected error_count to increment by 1, got {solax_api.error_count - initial_error_count} ****") failed = True else: - print(f"✓ API error on first page test passed") + print(f"PASS: API error on first page test passed") # Test 5: Network failure on second page print("\n--- Test 5: Network failure on second page ---") @@ -2294,7 +2294,7 @@ async def session_aexit(*args): print(f"**** ERROR: Expected None when second page fails, got {result} ****") failed = True else: - print(f"✓ Network failure on second page test passed") + print(f"PASS: Network failure on second page test passed") # Test 6: Missing result field in response print("\n--- Test 6: Missing result field in response ---") @@ -2322,10 +2322,10 @@ async def session_aexit(*args): print(f"**** ERROR: Expected 0 records for missing result field, got {len(result)} ****") failed = True else: - print(f"✓ Missing result field test passed") + print(f"PASS: Missing result field test passed") if not failed: - print("✓ fetch_paginated_data tests passed") + print("PASS: fetch_paginated_data tests passed") return failed @@ -2373,7 +2373,7 @@ async def test_fetch_single_result(my_predbat): print(f"**** ERROR: Expected requestId 'req-123-456', got {request_id} ****") failed = True else: - print(f"✓ Successful GET request test passed") + print(f"PASS: Successful GET request test passed") # Test 2: Successful POST request with result print("\n--- Test 2: Successful POST request with result ---") @@ -2406,7 +2406,7 @@ async def test_fetch_single_result(my_predbat): print(f"**** ERROR: Expected requestId 'req-789', got {request_id} ****") failed = True else: - print(f"✓ Successful POST request test passed") + print(f"PASS: Successful POST request test passed") # Test 3: Empty result field print("\n--- Test 3: Empty result field ---") @@ -2437,7 +2437,7 @@ async def test_fetch_single_result(my_predbat): print(f"**** ERROR: Expected requestId 'req-empty', got {request_id} ****") failed = True else: - print(f"✓ Empty result field test passed") + print(f"PASS: Empty result field test passed") # Test 4: API error code print("\n--- Test 4: API error code (10001) ---") @@ -2463,7 +2463,7 @@ async def test_fetch_single_result(my_predbat): print(f"**** ERROR: Expected error_count to increment by 1, got {solax_api.error_count - initial_error_count} ****") failed = True else: - print(f"✓ API error code test passed") + print(f"PASS: API error code test passed") # Test 5: Network failure (response is None) print("\n--- Test 5: Network failure (response is None) ---") @@ -2485,7 +2485,7 @@ async def test_fetch_single_result(my_predbat): print(f"**** ERROR: Expected None requestId for network failure, got {request_id} ****") failed = True else: - print(f"✓ Network failure test passed") + print(f"PASS: Network failure test passed") # Test 6: Missing requestId field print("\n--- Test 6: Missing requestId field ---") @@ -2517,10 +2517,10 @@ async def test_fetch_single_result(my_predbat): print(f"**** ERROR: Expected empty string for missing requestId, got {request_id} ****") failed = True else: - print(f"✓ Missing requestId field test passed") + print(f"PASS: Missing requestId field test passed") if not failed: - print("✓ fetch_single_result tests passed") + print("PASS: fetch_single_result tests passed") return failed @@ -2576,7 +2576,7 @@ async def test_query_plant_info(my_predbat): print(f"**** ERROR: plant_info not updated correctly ****") failed = True else: - print(f"✓ Successful query with multiple plants test passed") + print(f"PASS: Successful query with multiple plants test passed") # Test 2: Query with plant ID filter print("\n--- Test 2: Query with plant ID filter ---") @@ -2613,7 +2613,7 @@ async def test_query_plant_info(my_predbat): print(f"**** ERROR: Wrong plant returned ****") failed = True else: - print(f"✓ Query with plant ID filter test passed") + print(f"PASS: Query with plant ID filter test passed") # Test 3: Empty result (no plants found) print("\n--- Test 3: Empty result (no plants found) ---") @@ -2650,7 +2650,7 @@ async def test_query_plant_info(my_predbat): print(f"**** ERROR: plant_info should be empty list ****") failed = True else: - print(f"✓ Empty result test passed") + print(f"PASS: Empty result test passed") # Test 4: API error during query print("\n--- Test 4: API error during query (code 10001) ---") @@ -2674,7 +2674,7 @@ async def test_query_plant_info(my_predbat): print(f"**** ERROR: plant_info should not be updated on error ****") failed = True else: - print(f"✓ API error during query test passed") + print(f"PASS: API error during query test passed") # Test 5: Multi-page plant results print("\n--- Test 5: Multi-page plant results ---") @@ -2755,10 +2755,10 @@ async def session_aexit(*args): print(f"**** ERROR: Plants not collected correctly across pages ****") failed = True else: - print(f"✓ Multi-page plant results test passed") + print(f"PASS: Multi-page plant results test passed") if not failed: - print("✓ query_plant_info tests passed") + print("PASS: query_plant_info tests passed") return failed @@ -2829,7 +2829,7 @@ async def test_query_device_info(my_predbat): print(f"**** ERROR: deviceType not set correctly ****") failed = True else: - print(f"✓ Inverter devices query test passed") + print(f"PASS: Inverter devices query test passed") # Test 2: Query battery devices (device_type=2) print("\n--- Test 2: Query battery devices (device_type=2) ---") @@ -2876,7 +2876,7 @@ async def test_query_device_info(my_predbat): print(f"**** ERROR: Battery deviceType not set correctly ****") failed = True else: - print(f"✓ Battery devices query test passed") + print(f"PASS: Battery devices query test passed") # Test 3: Query with device serial number filter print("\n--- Test 3: Query with device serial number filter ---") @@ -2917,7 +2917,7 @@ async def test_query_device_info(my_predbat): print(f"**** ERROR: Wrong device returned ****") failed = True else: - print(f"✓ Device serial number filter test passed") + print(f"PASS: Device serial number filter test passed") # Test 4: Empty result (no devices found) print("\n--- Test 4: Empty result (no devices found) ---") @@ -2950,7 +2950,7 @@ async def test_query_device_info(my_predbat): print(f"**** ERROR: Expected 0 devices, got {len(result)} ****") failed = True else: - print(f"✓ Empty result test passed") + print(f"PASS: Empty result test passed") # Test 5: API error during query print("\n--- Test 5: API error during query (code 10001) ---") @@ -2969,7 +2969,7 @@ async def test_query_device_info(my_predbat): print(f"**** ERROR: Expected None for API error, got {result} ****") failed = True else: - print(f"✓ API error during query test passed") + print(f"PASS: API error during query test passed") # Test 6: Multiple device types in same plant print("\n--- Test 6: Multiple device types in same plant ---") @@ -3056,7 +3056,7 @@ async def session_aexit(*args): print(f"**** ERROR: Battery not in plant_batteries ****") failed = True else: - print(f"✓ Multiple device types in same plant test passed") + print(f"PASS: Multiple device types in same plant test passed") # Test 7: Device without serial number (edge case) print("\n--- Test 7: Device without serial number (edge case) ---") @@ -3095,10 +3095,10 @@ async def session_aexit(*args): print(f"**** ERROR: Device with SN should be stored ****") failed = True else: - print(f"✓ Device without serial number test passed") + print(f"PASS: Device without serial number test passed") if not failed: - print("✓ query_device_info tests passed") + print("PASS: query_device_info tests passed") return failed @@ -3153,7 +3153,7 @@ async def mock_fetch_success(path, params=None, post=False, json_data=None): print(f"**** ERROR: Stored data mismatch ****") failed = True else: - print(f"✓ Successful fetch test passed") + print(f"PASS: Successful fetch test passed") # Test 2: API error response (non-10000 code) print("Test 2: API error response") @@ -3175,7 +3175,7 @@ async def mock_fetch_error(path, params=None, post=False, json_data=None): print(f"**** ERROR: Data should not be stored on error ****") failed = True else: - print(f"✓ API error test passed") + print(f"PASS: API error test passed") # Test 3: Multiple plants with different data print("Test 3: Multiple plants with different data") @@ -3218,7 +3218,7 @@ async def mock_fetch_multiple(path, params=None, post=False, json_data=None): print(f"**** ERROR: Plant 2 data mismatch ****") failed = True else: - print(f"✓ Multiple plants test passed") + print(f"PASS: Multiple plants test passed") # Test 4: Custom business_type parameter print("Test 4: Custom business_type parameter") @@ -3243,7 +3243,7 @@ async def mock_fetch_capture_params(path, params=None, post=False, json_data=Non print(f"**** ERROR: Expected business_type 1, got {captured_params[0].get('businessType')} ****") failed = True else: - print(f"✓ Default business_type test passed") + print(f"PASS: Default business_type test passed") # Test with custom business_type captured_params.clear() @@ -3256,7 +3256,7 @@ async def mock_fetch_capture_params(path, params=None, post=False, json_data=Non print(f"**** ERROR: Expected business_type 4, got {captured_params[0].get('businessType')} ****") failed = True else: - print(f"✓ Custom business_type test passed") + print(f"PASS: Custom business_type test passed") # Test 5: Empty result (valid API response but no data) print("Test 5: Empty result dictionary") @@ -3277,7 +3277,7 @@ async def mock_fetch_empty(path, params=None, post=False, json_data=None): print(f"**** ERROR: Empty result should still be stored ****") failed = True else: - print(f"✓ Empty result test passed") + print(f"PASS: Empty result test passed") # Test 6: Overwrite existing data with new fetch print("Test 6: Overwrite existing data with new fetch") @@ -3312,10 +3312,10 @@ async def mock_fetch_overwrite(path, params=None, post=False, json_data=None): print(f"**** ERROR: Data not overwritten correctly ****") failed = True else: - print(f"✓ Overwrite existing data test passed") + print(f"PASS: Overwrite existing data test passed") if not failed: - print("✓ query_plant_realtime_data tests passed") + print("PASS: query_plant_realtime_data tests passed") return failed @@ -3375,7 +3375,7 @@ async def mock_fetch_inverter(path, params=None, post=False, json_data=None): print(f"**** ERROR: Stored data mismatch ****") failed = True else: - print(f"✓ Successful inverter fetch test passed") + print(f"PASS: Successful inverter fetch test passed") # Test 2: Successful fetch for battery device (device_type=2) print("Test 2: Successful fetch for battery device") @@ -3422,7 +3422,7 @@ async def mock_fetch_battery(path, params=None, post=False, json_data=None): print(f"**** ERROR: Stored battery data mismatch ****") failed = True else: - print(f"✓ Successful battery fetch test passed") + print(f"PASS: Successful battery fetch test passed") # Test 3: API error response (fetch_single_result returns None) print("Test 3: API error response") @@ -3443,7 +3443,7 @@ async def mock_fetch_error(path, params=None, post=False, json_data=None): print(f"**** ERROR: Data should not be stored on error ****") failed = True else: - print(f"✓ API error test passed") + print(f"PASS: API error test passed") # Test 4: Empty result list print("Test 4: Empty result list") @@ -3464,7 +3464,7 @@ async def mock_fetch_empty(path, params=None, post=False, json_data=None): print(f"**** ERROR: Data should not be stored for empty result ****") failed = True else: - print(f"✓ Empty result test passed") + print(f"PASS: Empty result test passed") # Test 5: Custom business_type parameter print("Test 5: Custom business_type parameter") @@ -3495,7 +3495,7 @@ async def mock_fetch_capture(path, params=None, post=False, json_data=None): print(f"**** ERROR: Expected snList ['TEST_SN'], got {captured_params[0].get('snList')} ****") failed = True else: - print(f"✓ Default business_type test passed") + print(f"PASS: Default business_type test passed") # Test with custom business_type captured_params.clear() @@ -3511,7 +3511,7 @@ async def mock_fetch_capture(path, params=None, post=False, json_data=None): print(f"**** ERROR: Expected device_type 2, got {captured_params[0].get('deviceType')} ****") failed = True else: - print(f"✓ Custom business_type test passed") + print(f"PASS: Custom business_type test passed") # Test 6: Multiple devices queried separately print("Test 6: Multiple devices queried separately") @@ -3544,7 +3544,7 @@ async def mock_fetch_multiple(path, params=None, post=False, json_data=None): print(f"**** ERROR: DEV_003 data mismatch ****") failed = True else: - print(f"✓ Multiple devices test passed") + print(f"PASS: Multiple devices test passed") # Test 7: Overwrite existing device data print("Test 7: Overwrite existing device data with new fetch") @@ -3573,7 +3573,7 @@ async def mock_fetch_overwrite(path, params=None, post=False, json_data=None): print(f"**** ERROR: Data not overwritten correctly ****") failed = True else: - print(f"✓ Overwrite existing device data test passed") + print(f"PASS: Overwrite existing device data test passed") # Test 8: real_sn != sn (shared Device ID / fake battery suffix) # When real_sn differs from sn, snList must use real_sn and requestSnType must be set. @@ -3606,7 +3606,7 @@ async def mock_fetch_capture_params(path, params=None, post=False, json_data=Non print(f"**** ERROR: Result stored under wrong key or data incorrect ****") failed = True else: - print(f"✓ real_sn != sn request params test passed") + print(f"PASS: real_sn != sn request params test passed") # Also verify that when real_sn == sn, requestSnType is NOT added to params print("Test 8b: real_sn == sn - requestSnType is absent") @@ -3635,10 +3635,10 @@ async def mock_fetch_no_override(path, params=None, post=False, json_data=None): print(f"**** ERROR: requestSnType should not be present when real_sn == sn, got {p.get('requestSnType')} ****") failed = True else: - print(f"✓ real_sn == sn no requestSnType test passed") + print(f"PASS: real_sn == sn no requestSnType test passed") if not failed: - print("✓ query_device_realtime_data tests passed") + print("PASS: query_device_realtime_data tests passed") return failed @@ -3685,7 +3685,7 @@ async def mock_query_device(sn, device_type, business_type=None, real_sn=None): print(f"**** ERROR: Unexpected device SNs called ****") failed = True else: - print(f"✓ Successful fetch with multiple devices test passed") + print(f"PASS: Successful fetch with multiple devices test passed") # Test 2: Empty device_info (no devices) print("Test 2: Empty device_info (no devices)") @@ -3711,7 +3711,7 @@ async def mock_query_never_called(sn, device_type, business_type=None, real_sn=N print(f"**** ERROR: query_device_realtime_data should not be called when no devices ****") failed = True else: - print(f"✓ Empty device_info test passed") + print(f"PASS: Empty device_info test passed") # Test 3: Some devices return None (error handling) print("Test 3: Some devices return None (error handling)") @@ -3740,7 +3740,7 @@ async def mock_query_with_error(sn, device_type, business_type=None, real_sn=Non print(f"**** ERROR: Result data mismatch ****") failed = True else: - print(f"✓ Error handling test passed") + print(f"PASS: Error handling test passed") # Test 4: Custom business_type parameter print("Test 4: Custom business_type parameter") @@ -3769,7 +3769,7 @@ async def mock_query_capture_business_type(sn, device_type, business_type=None, print(f"**** ERROR: Expected business_type None, got {captured_business_type[0]} ****") failed = True else: - print(f"✓ Default business_type test passed") + print(f"PASS: Default business_type test passed") # Test with custom business_type captured_business_type.clear() @@ -3779,7 +3779,7 @@ async def mock_query_capture_business_type(sn, device_type, business_type=None, print(f"**** ERROR: Expected business_type 4, got {captured_business_type[0]} ****") failed = True else: - print(f"✓ Custom business_type test passed") + print(f"PASS: Custom business_type test passed") # Test 5: Mixed device types (verify deviceType extracted correctly) print("Test 5: Mixed device types verification") @@ -3821,7 +3821,7 @@ async def mock_query_log_types(sn, device_type, business_type=None, real_sn=None print(f"**** ERROR: Meter device_type incorrect ****") failed = True else: - print(f"✓ Mixed device types verification test passed") + print(f"PASS: Mixed device types verification test passed") # Test 6: Results are aggregated correctly (extend not append) print("Test 6: Results aggregation (extend behavior)") @@ -3845,7 +3845,7 @@ async def mock_query_multi_records(sn, device_type, business_type=None, real_sn= print(f"**** ERROR: Expected 4 total records (2 devices x 2 records), got {len(result6)} ****") failed = True else: - print(f"✓ Results aggregation test passed") + print(f"PASS: Results aggregation test passed") # Test 7: Shared Device ID (battery SN has _battery suffix, real_sn strips it) print("Test 7: Shared Device ID - battery uses inverter SN with _battery suffix") @@ -3889,10 +3889,10 @@ async def mock_query_capture_real_sn(sn, device_type, business_type=None, real_s print(f"**** ERROR: Battery real_sn should be 'INV001' (suffix stripped), got '{bat_call['real_sn']}' ****") failed = True else: - print(f"✓ Shared Device ID real_sn test passed") + print(f"PASS: Shared Device ID real_sn test passed") if not failed: - print("✓ query_device_realtime_data_all tests passed") + print("PASS: query_device_realtime_data_all tests passed") return failed @@ -3951,7 +3951,7 @@ async def mock_query_statistics(plant_id, date_type, date, business_type=None): print(f"**** ERROR: Expected date format YYYY-MM, got {captured_calls[0]['date']} ****") failed = True else: - print(f"✓ Successful fetch for current month test passed") + print(f"PASS: Successful fetch for current month test passed") # Test 2: API error response (None returned) print("Test 2: API error response") @@ -3969,7 +3969,7 @@ async def mock_query_error(plant_id, date_type, date, business_type=None): print(f"**** ERROR: Expected None on API error, got {result2} ****") failed = True else: - print(f"✓ API error response test passed") + print(f"PASS: API error response test passed") # Test 3: Custom business_type parameter print("Test 3: Custom business_type parameter") @@ -3994,7 +3994,7 @@ async def mock_query_capture_business_type(plant_id, date_type, date, business_t print(f"**** ERROR: Expected business_type None, got {captured_business_type[0]} ****") failed = True else: - print(f"✓ Default business_type test passed") + print(f"PASS: Default business_type test passed") # Test with custom business_type captured_business_type.clear() @@ -4004,7 +4004,7 @@ async def mock_query_capture_business_type(plant_id, date_type, date, business_t print(f"**** ERROR: Expected business_type 4, got {captured_business_type[0]} ****") failed = True else: - print(f"✓ Custom business_type test passed") + print(f"PASS: Custom business_type test passed") # Test 4: Empty plantEnergyStatDataList (no data for month) print("Test 4: Empty plantEnergyStatDataList (no data for month)") @@ -4025,7 +4025,7 @@ async def mock_query_empty_data(plant_id, date_type, date, business_type=None): print(f"**** ERROR: Expected empty list, got {len(result4['plantEnergyStatDataList'])} records ****") failed = True else: - print(f"✓ Empty plantEnergyStatDataList test passed") + print(f"PASS: Empty plantEnergyStatDataList test passed") # Test 5: Verify date format passed to query_plant_statistics print("Test 5: Verify date format passed to query_plant_statistics") @@ -4051,10 +4051,10 @@ async def mock_query_capture_date(plant_id, date_type, date, business_type=None) print(f"**** ERROR: Expected YYYY-MM format, got {captured_date_formats[0]} ****") failed = True else: - print(f"✓ Date format verification test passed") + print(f"PASS: Date format verification test passed") if not failed: - print("✓ query_plant_statistics_daily tests passed") + print("PASS: query_plant_statistics_daily tests passed") return failed @@ -4090,7 +4090,7 @@ async def mock_query_result_immediate_success(request_id): print(f"**** ERROR: Expected True for successful execution, got {result} ****") failed = True else: - print(f"✓ Immediate success test passed") + print(f"PASS: Immediate success test passed") # Test 2: Command issuance failed (device offline) print("Test 2: Command issuance failed (device offline)") @@ -4108,7 +4108,7 @@ async def mock_fetch_offline(endpoint, post, json_data): print(f"**** ERROR: Expected False for offline device, got {result2} ****") failed = True else: - print(f"✓ Device offline test passed") + print(f"PASS: Device offline test passed") # Test 3: fetch_single_result returns None (network error) print("Test 3: fetch_single_result returns None (network error)") @@ -4126,7 +4126,7 @@ async def mock_fetch_none(endpoint, post, json_data): print(f"**** ERROR: Expected False for None result, got {result3} ****") failed = True else: - print(f"✓ Network error test passed") + print(f"PASS: Network error test passed") # Test 4: Successful after polling retries print("Test 4: Successful after polling retries") @@ -4158,7 +4158,7 @@ async def mock_query_result_delayed(request_id): print(f"**** ERROR: Expected 3 polling attempts, got {poll_count[0]} ****") failed = True else: - print(f"✓ Polling retry test passed") + print(f"PASS: Polling retry test passed") # Test 5: Execution failed after polling print("Test 5: Execution failed after polling") @@ -4179,7 +4179,7 @@ async def mock_query_result_exec_failed(request_id): print(f"**** ERROR: Expected False for execution failure, got {result5} ****") failed = True else: - print(f"✓ Execution failed test passed") + print(f"PASS: Execution failed test passed") # Test 6: Timeout after max retries print("Test 6: Timeout after max retries") @@ -4208,7 +4208,7 @@ async def mock_query_result_always_pending(request_id): print(f"**** ERROR: Expected {SOLAX_COMMAND_MAX_RETRIES} polling attempts, got {retry_count[0]} ****") failed = True else: - print(f"✓ Timeout after max retries test passed") + print(f"PASS: Timeout after max retries test passed") # Test 7: No request_id returned (edge case) print("Test 7: No request_id returned (edge case)") @@ -4226,10 +4226,10 @@ async def mock_fetch_no_request_id(endpoint, post, json_data): print(f"**** ERROR: Expected False for missing request_id, got {result7} ****") failed = True else: - print(f"✓ Missing request_id test passed") + print(f"PASS: Missing request_id test passed") if not failed: - print("✓ send_command_and_wait tests passed") + print("PASS: send_command_and_wait tests passed") return failed @@ -4279,7 +4279,7 @@ async def mock_send_command(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Wrong command_name: {captured_calls[0]['command_name']} ****") failed = True else: - print(f"✓ self_consume_mode successful execution test passed") + print(f"PASS: self_consume_mode successful execution test passed") # Test 2: self_consume_mode() - command failed print("Test 2: self_consume_mode() - command failed") @@ -4297,7 +4297,7 @@ async def mock_send_command_failed(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Expected False for failed command, got {result2} ****") failed = True else: - print(f"✓ self_consume_mode command failed test passed") + print(f"PASS: self_consume_mode command failed test passed") # Test 3: self_consume_mode() - custom business_type print("Test 3: self_consume_mode() - custom business_type") @@ -4313,7 +4313,7 @@ async def mock_send_command_failed(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Expected business_type 4, got {captured_calls[0]['payload']['businessType']} ****") failed = True else: - print(f"✓ self_consume_mode custom business_type test passed") + print(f"PASS: self_consume_mode custom business_type test passed") # Test 4: soc_target_control_mode() - charge mode (positive power) print("Test 4: soc_target_control_mode() - charge mode (positive power)") @@ -4341,7 +4341,7 @@ async def mock_send_command_failed(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Wrong command_name ****") failed = True else: - print(f"✓ soc_target_control_mode charge mode test passed") + print(f"PASS: soc_target_control_mode charge mode test passed") # Test 5: soc_target_control_mode() - discharge mode (negative power) print("Test 5: soc_target_control_mode() - discharge mode (negative power)") @@ -4363,7 +4363,7 @@ async def mock_send_command_failed(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Expected power -4500, got {captured_calls[0]['payload']['chargeDischargPower']} ****") # cspell:disable-line failed = True else: - print(f"✓ soc_target_control_mode discharge mode test passed") + print(f"PASS: soc_target_control_mode discharge mode test passed") # Test 6: set_work_mode() - selfuse mode print("Test 6: set_work_mode() - selfuse mode") @@ -4397,7 +4397,7 @@ async def mock_send_command_failed(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Wrong command_name ****") failed = True else: - print(f"✓ set_work_mode selfuse mode test passed") + print(f"PASS: set_work_mode selfuse mode test passed") # Test 7: set_work_mode() - backup mode print("Test 7: set_work_mode() - backup mode") @@ -4419,7 +4419,7 @@ async def mock_send_command_failed(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Wrong command_name ****") failed = True else: - print(f"✓ set_work_mode backup mode test passed") + print(f"PASS: set_work_mode backup mode test passed") # Test 8: set_work_mode() - feedin mode print("Test 8: set_work_mode() - feedin mode") @@ -4441,7 +4441,7 @@ async def mock_send_command_failed(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Wrong command_name ****") failed = True else: - print(f"✓ set_work_mode feedin mode test passed") + print(f"PASS: set_work_mode feedin mode test passed") # Test 9: set_work_mode() - unknown mode print("Test 9: set_work_mode() - unknown mode") @@ -4460,7 +4460,7 @@ async def mock_send_command_failed(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Expected no calls for unknown mode, got {len(captured_calls)} ****") failed = True else: - print(f"✓ set_work_mode unknown mode test passed") + print(f"PASS: set_work_mode unknown mode test passed") # Test 10: Multiple devices in sn_list print("Test 10: Multiple devices in sn_list") @@ -4479,10 +4479,10 @@ async def mock_send_command_failed(endpoint, payload, command_name, sn_list): print(f"**** ERROR: Wrong sn_list in payload ****") failed = True else: - print(f"✓ Multiple devices test passed") + print(f"PASS: Multiple devices test passed") if not failed: - print("✓ control mode functions tests passed") + print("PASS: control mode functions tests passed") return failed @@ -4530,7 +4530,7 @@ async def test_publish_device_info_main(): print(f"**** ERROR: Incorrect friendly_name: {attrs.get('friendly_name')} ****") failed = True else: - print(f"✓ Inverter device info publishing test passed") + print(f"PASS: Inverter device info publishing test passed") # Test 2: Battery device info publishing print("Test 2: Battery device info publishing") @@ -4558,7 +4558,7 @@ async def test_publish_device_info_main(): print(f"**** ERROR: Expected rated_power 5000W, got {attrs.get('rated_power')} ****") failed = True else: - print(f"✓ Battery device info publishing test passed") + print(f"PASS: Battery device info publishing test passed") # Test 3: Meter device info publishing print("Test 3: Meter device info publishing") @@ -4582,7 +4582,7 @@ async def test_publish_device_info_main(): print(f"**** ERROR: Expected device_model 'Meter X', got {attrs.get('device_model')} ****") failed = True else: - print(f"✓ Meter device info publishing test passed") + print(f"PASS: Meter device info publishing test passed") # Test 4: EV Charger device info publishing print("Test 4: EV Charger device info publishing") @@ -4606,7 +4606,7 @@ async def test_publish_device_info_main(): print(f"**** ERROR: Expected rated_power 7000W, got {attrs.get('rated_power')} ****") failed = True else: - print(f"✓ EV Charger device info publishing test passed") + print(f"PASS: EV Charger device info publishing test passed") # Test 5: Unknown device type print("Test 5: Unknown device type") @@ -4627,7 +4627,7 @@ async def test_publish_device_info_main(): print(f"**** ERROR: Expected device_model 'Unknown Device', got {attrs.get('device_model')} ****") failed = True else: - print(f"✓ Unknown device type test passed") + print(f"PASS: Unknown device type test passed") # Test 6: Multiple devices print("Test 6: Multiple devices") @@ -4669,7 +4669,7 @@ async def test_publish_device_info_main(): print(f"**** ERROR: Wrong meter model: {mtr_attrs.get('device_model')} ****") failed = True else: - print(f"✓ Multiple devices test passed") + print(f"PASS: Multiple devices test passed") # Test 7: Empty device_info (no devices to publish) print("Test 7: Empty device_info") @@ -4686,7 +4686,7 @@ async def test_publish_device_info_main(): print(f"**** ERROR: Expected no sensors, got {len(api7.dashboard_items)} ****") failed = True else: - print(f"✓ Empty device_info test passed") + print(f"PASS: Empty device_info test passed") # Test 8: Missing optional fields (should use defaults) print("Test 8: Missing optional fields") @@ -4720,10 +4720,10 @@ async def test_publish_device_info_main(): print(f"**** ERROR: Expected plant_id None, got {attrs.get('plant_id')} ****") failed = True else: - print(f"✓ Missing optional fields test passed") + print(f"PASS: Missing optional fields test passed") if not failed: - print("✓ publish_device_info tests passed") + print("PASS: publish_device_info tests passed") return failed @@ -4771,7 +4771,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected status_value 102, got {api.dashboard_items[sensor_id]['attributes']['status_value']} ****") failed = True else: - print(f"✓ Inverter device status sensor correct") + print(f"PASS: Inverter device status sensor correct") # Verify AC power sensor (sum of 3 phases) sensor_id = "sensor.predbat_solax_1618699116555534337_H1231231932123_ac_power" @@ -4782,7 +4782,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected AC power 3000, got {api.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Inverter AC power sensor correct") + print(f"PASS: Inverter AC power sensor correct") # Verify PV power sensor (sum from pvMap) sensor_id = "sensor.predbat_solax_1618699116555534337_H1231231932123_pv_power" @@ -4793,7 +4793,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected PV power 3500, got {api.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Inverter PV power sensor correct") + print(f"PASS: Inverter PV power sensor correct") # Verify grid power sensor sensor_id = "sensor.predbat_solax_1618699116555534337_H1231231932123_grid_power" @@ -4804,7 +4804,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected grid power -2500, got {api.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Inverter grid power sensor correct") + print(f"PASS: Inverter grid power sensor correct") # Verify total yield sensor sensor_id = "sensor.predbat_solax_1618699116555534337_H1231231932123_total_yield" @@ -4815,7 +4815,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected total yield 12500.5, got {api.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Inverter total yield sensor correct") + print(f"PASS: Inverter total yield sensor correct") # Test 2: Battery realtime data publishing print("Test 2: Battery realtime data publishing") @@ -4839,7 +4839,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected status 'Work', got {api2.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Battery device status sensor correct") + print(f"PASS: Battery device status sensor correct") # Verify battery SOC sensor sensor_id = "sensor.predbat_solax_1618699116555534337_TP123456123123_battery_soc" @@ -4853,7 +4853,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Wrong SOC unit ****") failed = True else: - print(f"✓ Battery SOC sensor correct") + print(f"PASS: Battery SOC sensor correct") # Verify battery voltage sensor sensor_id = "sensor.predbat_solax_1618699116555534337_TP123456123123_battery_voltage" @@ -4864,7 +4864,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected voltage 450.5V, got {api2.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Battery voltage sensor correct") + print(f"PASS: Battery voltage sensor correct") # Verify charge/discharge power sensor sensor_id = "sensor.predbat_solax_1618699116555534337_TP123456123123_charge_discharge_power" @@ -4875,7 +4875,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected power 2500W, got {api2.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Battery charge/discharge power sensor correct") + print(f"PASS: Battery charge/discharge power sensor correct") # Verify battery current sensor sensor_id = "sensor.predbat_solax_1618699116555534337_TP123456123123_battery_current" @@ -4886,7 +4886,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected current 5.5A, got {api2.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Battery current sensor correct") + print(f"PASS: Battery current sensor correct") # Verify battery temperature sensor sensor_id = "sensor.predbat_solax_1618699116555534337_TP123456123123_battery_temperature" @@ -4900,7 +4900,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Wrong temperature unit ****") failed = True else: - print(f"✓ Battery temperature sensor correct") + print(f"PASS: Battery temperature sensor correct") # Test 3: Inverter with mpptMap instead of pvMap print("Test 3: Inverter with mpptMap instead of pvMap") @@ -4933,7 +4933,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected PV power 1500 from mpptMap, got {api3.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Inverter mpptMap PV power sensor correct") + print(f"PASS: Inverter mpptMap PV power sensor correct") # Test 4: Device with no pvMap or mpptMap print("Test 4: Device with no pvMap or mpptMap") @@ -4966,7 +4966,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected PV power 0 (no map), got {api4.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Inverter with no PV map defaults to 0") + print(f"PASS: Inverter with no PV map defaults to 0") # Test 5: Unknown device status codes print("Test 5: Unknown device status codes") @@ -4987,7 +4987,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected 'Unknown Status', got {api5.dashboard_items[sensor_id]['state']} ****") failed = True else: - print(f"✓ Unknown device status handled correctly") + print(f"PASS: Unknown device status handled correctly") # Test 6: Empty realtime_device_data print("Test 6: Empty realtime_device_data") @@ -5005,7 +5005,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected no sensors, got {len(api6.dashboard_items)} ****") failed = True else: - print(f"✓ Empty realtime_device_data test passed") + print(f"PASS: Empty realtime_device_data test passed") # Test 7: Multiple devices (inverter + battery) print("Test 7: Multiple devices (inverter + battery)") @@ -5036,7 +5036,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Wrong battery SOC ****") failed = True else: - print(f"✓ Multiple devices test passed") + print(f"PASS: Multiple devices test passed") # Test 8: load_power is calculated correctly from inverter + battery data # load_power = pv - battery_charge_discharge - grid @@ -5084,7 +5084,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Wrong load_power unit_of_measurement ****") failed = True else: - print(f"✓ load_power sensor calculated correctly ({expected_load}W)") + print(f"PASS: load_power sensor calculated correctly ({expected_load}W)") # Test 9: load_power with None battery power (API returns None for chargeDischargePower) # load_power should treat None as 0: load = pv - 0 - grid @@ -5128,7 +5128,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected load_power {expected_load9}W with None battery, got {api9.dashboard_items[load_sensor9]['state']} ****") failed = True else: - print(f"✓ load_power with None chargeDischargePower treated as 0 ({expected_load9}W)") + print(f"PASS: load_power with None chargeDischargePower treated as 0 ({expected_load9}W)") # Test 10: Multi-inverter plant — PV and grid are aggregated; entity SN uses plant_inverters[0] # Plant has two inverters: INV_A (PV=1500W, grid=-200W) and INV_B (PV=500W, grid=-100W) @@ -5192,7 +5192,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected aggregated load_power {expected_load10}W, got {api10.dashboard_items[load_sensor10]['state']} ****") failed = True else: - print(f"✓ Multi-inverter load_power aggregated correctly ({expected_load10}W) and tied to first inverter SN") + print(f"PASS: Multi-inverter load_power aggregated correctly ({expected_load10}W) and tied to first inverter SN") # Test 11: Battery SOH sensor published correctly, including zero-SOH guard and aggregate print("Test 11: Battery SOH sensor published correctly") @@ -5218,7 +5218,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Wrong battery_soh unit ****") failed = True else: - print(f"✓ Per-device battery_soh sensor correct (0.92)") + print(f"PASS: Per-device battery_soh sensor correct (0.92)") # Per-inverter aggregate SOH sensor (updated in second pass) agg_soh_sensor = "sensor.predbat_solax_soh_plant_INV_SOH_battery_soh" @@ -5229,7 +5229,7 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected aggregate battery_soh 0.92, got {api11.dashboard_items[agg_soh_sensor]['state']} ****") failed = True else: - print(f"✓ Aggregate battery_soh sensor correct (0.92)") + print(f"PASS: Aggregate battery_soh sensor correct (0.92)") # Verify batterySOH=0 is treated as 1.0 (guard against bad API data) api11b = MockSolaxAPI() @@ -5250,10 +5250,10 @@ async def test_publish_device_realtime_data_main(): print(f"**** ERROR: Expected battery_soh 1.0 for batterySOH=0, got {api11b.dashboard_items[zero_soh_sensor]['state']} ****") failed = True else: - print(f"✓ batterySOH=0 correctly guarded to 1.0") + print(f"PASS: batterySOH=0 correctly guarded to 1.0") if not failed: - print("✓ publish_device_realtime_data tests passed") + print("PASS: publish_device_realtime_data tests passed") return failed @@ -5278,7 +5278,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 10000W, got {result}W ****") failed = True else: - print(f"✓ Single inverter power calculation correct (10000W)") + print(f"PASS: Single inverter power calculation correct (10000W)") # Test 2: get_max_power_inverter - multiple inverters print("Test 2: get_max_power_inverter - multiple inverters") @@ -5295,7 +5295,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 23500W, got {result2}W ****") failed = True else: - print(f"✓ Multiple inverter power calculation correct (23500W)") + print(f"PASS: Multiple inverter power calculation correct (23500W)") # Test 3: get_max_power_inverter - no inverters print("Test 3: get_max_power_inverter - no inverters") @@ -5307,7 +5307,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 0W for no inverters, got {result3}W ****") failed = True else: - print(f"✓ No inverters returns 0W") + print(f"PASS: No inverters returns 0W") # Test 4: get_max_power_battery - single battery print("Test 4: get_max_power_battery - single battery") @@ -5322,7 +5322,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 5000W, got {result4}W ****") failed = True else: - print(f"✓ Single battery power calculation correct (5000W)") + print(f"PASS: Single battery power calculation correct (5000W)") # Test 5: get_max_power_battery - multiple batteries print("Test 5: get_max_power_battery - multiple batteries") @@ -5338,7 +5338,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 10000W, got {result5}W ****") failed = True else: - print(f"✓ Multiple battery power calculation correct (10000W)") + print(f"PASS: Multiple battery power calculation correct (10000W)") # Test 6: get_max_power_battery - fallback to inverter power print("Test 6: get_max_power_battery - fallback to inverter power") @@ -5354,7 +5354,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 12000W (inverter fallback), got {result6}W ****") failed = True else: - print(f"✓ Battery power fallback to inverter correct (12000W)") + print(f"PASS: Battery power fallback to inverter correct (12000W)") # Test 7: get_max_soc_battery print("Test 7: get_max_soc_battery") @@ -5368,7 +5368,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 15.0 kWh, got {result7} kWh ****") failed = True else: - print(f"✓ Max SOC battery correct (15.0 kWh)") + print(f"PASS: Max SOC battery correct (15.0 kWh)") # Test 8: get_current_soc_battery_kwh - single battery print("Test 8: get_current_soc_battery_kwh - single battery") @@ -5385,7 +5385,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected {expected8} kWh, got {result8} kWh ****") failed = True else: - print(f"✓ Single battery current SOC correct (11.25 kWh)") + print(f"PASS: Single battery current SOC correct (11.25 kWh)") # Test 9: get_current_soc_battery_kwh - multiple batteries (average) print("Test 9: get_current_soc_battery_kwh - multiple batteries (average)") @@ -5403,7 +5403,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected {expected9} kWh, got {result9} kWh ****") failed = True else: - print(f"✓ Multiple battery current SOC average correct (14.0 kWh)") + print(f"PASS: Multiple battery current SOC average correct (14.0 kWh)") # Test 10: get_current_soc_battery_kwh - no batteries print("Test 10: get_current_soc_battery_kwh - no batteries") @@ -5415,7 +5415,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 0 kWh for no batteries, got {result10} kWh ****") failed = True else: - print(f"✓ No batteries returns 0 kWh") + print(f"PASS: No batteries returns 0 kWh") # Test 11: get_battery_temperature - single battery print("Test 11: get_battery_temperature - single battery") @@ -5430,7 +5430,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 22.5°C, got {result11}°C ****") failed = True else: - print(f"✓ Single battery temperature correct (22.5°C)") + print(f"PASS: Single battery temperature correct (22.5°C)") # Test 12: get_battery_temperature - multiple batteries (minimum) print("Test 12: get_battery_temperature - multiple batteries (minimum)") @@ -5447,7 +5447,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected minimum temperature 18.5°C, got {result12}°C ****") failed = True else: - print(f"✓ Multiple battery temperature minimum correct (18.5°C)") + print(f"PASS: Multiple battery temperature minimum correct (18.5°C)") # Test 13: get_battery_temperature - no temperature data print("Test 13: get_battery_temperature - no temperature data") @@ -5462,7 +5462,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected None for no temperature data, got {result13}°C ****") failed = True else: - print(f"✓ No temperature data returns None") + print(f"PASS: No temperature data returns None") # Test 14: get_charge_discharge_power_battery - single battery print("Test 14: get_charge_discharge_power_battery - single battery") @@ -5477,7 +5477,7 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected 2500W, got {result14}W ****") failed = True else: - print(f"✓ Single battery charge/discharge power correct (2500W)") + print(f"PASS: Single battery charge/discharge power correct (2500W)") # Test 15: get_charge_discharge_power_battery - multiple batteries (sum) print("Test 15: get_charge_discharge_power_battery - multiple batteries (sum)") @@ -5493,10 +5493,10 @@ async def test_helper_methods_main(): print(f"**** ERROR: Expected -500W, got {result15}W ****") failed = True else: - print(f"✓ Multiple battery charge/discharge power sum correct (-500W)") + print(f"PASS: Multiple battery charge/discharge power sum correct (-500W)") if not failed: - print("✓ helper methods tests passed") + print("PASS: helper methods tests passed") return failed @@ -5538,7 +5538,7 @@ async def test_automatic_config_main(): print(f"**** ERROR: battery_min_soc should be [{SOLAX_MIN_RESERVE_PERCENT}] so Predbat's reserve logic never targets below what SolaX allows, got {api.get_arg('battery_min_soc')} ****") failed = True else: - print(f"✓ Single plant configuration correct") + print(f"PASS: Single plant configuration correct") # Test 2: Multiple plants with inverters and batteries print("Test 2: Multiple plants with inverters and batteries") @@ -5568,7 +5568,7 @@ async def test_automatic_config_main(): print(f"**** ERROR: battery_min_soc should have one entry per plant, got {api2.get_arg('battery_min_soc')} ****") failed = True else: - print(f"✓ Multiple plant configuration correct") + print(f"PASS: Multiple plant configuration correct") # Test 3: Plant with inverter but no battery (should be skipped) print("Test 3: Plant with inverter but no battery (should be skipped)") @@ -5591,7 +5591,7 @@ async def test_automatic_config_main(): print(f"**** ERROR: Expected num_inverters=1 (only plant with battery), got {api3.get_arg('num_inverters')} ****") failed = True else: - print(f"✓ Plant without battery correctly skipped") + print(f"PASS: Plant without battery correctly skipped") # Test 4: No plants with both inverter and battery (should raise error) print("Test 4: No plants with both inverter and battery (should raise error)") @@ -5607,7 +5607,7 @@ async def test_automatic_config_main(): failed = True except ValueError as e: if "No plants with inverters and batteries found" in str(e): - print(f"✓ Correctly raised ValueError for no valid plants") + print(f"PASS: Correctly raised ValueError for no valid plants") else: print(f"**** ERROR: Wrong error message: {e} ****") failed = True @@ -5627,13 +5627,13 @@ async def test_automatic_config_main(): # Entity names should use plant ID directly battery_power_entity = api5.get_arg("battery_power") if battery_power_entity and "Test_Plant_123" in battery_power_entity[0]: - print(f"✓ Entity names correctly use plant ID") + print(f"PASS: Entity names correctly use plant ID") else: print(f"**** ERROR: Entity name incorrect: {battery_power_entity} ****") failed = True if not failed: - print("✓ automatic_config tests passed") + print("PASS: automatic_config tests passed") return failed @@ -5695,7 +5695,7 @@ async def test_publish_controls_main(): print(f"**** ERROR: Reserve incorrect: {api.dashboard_items[reserve]['state']} ****") failed = True else: - print(f"✓ Control entities created correctly") + print(f"PASS: Control entities created correctly") # Test 2: Verify export controls print("Test 2: Verify export controls") @@ -5715,7 +5715,7 @@ async def test_publish_controls_main(): print(f"**** ERROR: Export rate incorrect: {api.dashboard_items[export_rate]['state']} ****") failed = True else: - print(f"✓ Export control entities correct") + print(f"PASS: Export control entities correct") # Test 3: Multiple plants print("Test 3: Multiple plants") @@ -5752,7 +5752,7 @@ async def test_publish_controls_main(): print(f"**** ERROR: Plant B reserve incorrect ****") failed = True else: - print(f"✓ Multiple plant controls correct") + print(f"PASS: Multiple plant controls correct") # Test 4: Verify attributes (min, max, units, options) print("Test 4: Verify attributes") @@ -5770,7 +5770,7 @@ async def test_publish_controls_main(): print(f"**** ERROR: Target SOC units incorrect: {attrs.get('unit_of_measurement')} ****") failed = True else: - print(f"✓ Entity attributes correct") + print(f"PASS: Entity attributes correct") # Test 5: Verify time options print("Test 5: Verify time options") @@ -5788,10 +5788,10 @@ async def test_publish_controls_main(): print(f"**** ERROR: Start time options count incorrect: {len(attrs.get('options', []))} ****") failed = True else: - print(f"✓ Time options correct") + print(f"PASS: Time options correct") if not failed: - print("✓ publish_controls tests passed") + print("PASS: publish_controls tests passed") return failed @@ -5838,7 +5838,7 @@ async def test_run_main(): print(f"**** ERROR: query_plant_info not called on first run ****") failed = True else: - print(f"✓ First run initialization correct") + print(f"PASS: First run initialization correct") # Test 2: Subsequent run (no first-time actions) print("Test 2: Subsequent run (no first-time actions)") @@ -5867,7 +5867,7 @@ async def test_run_main(): print(f"**** ERROR: query_device_info should not be called at 2 minutes ****") failed = True else: - print(f"✓ Subsequent run correct") + print(f"PASS: Subsequent run correct") # Test 3: 60-second cycle (realtime data refresh) print("Test 3: 60-second cycle (realtime data refresh)") @@ -5898,7 +5898,7 @@ async def test_run_main(): print(f"**** ERROR: publish methods not called at 60 seconds ****") failed = True else: - print(f"✓ 60-second cycle correct") + print(f"PASS: 60-second cycle correct") # Test 4: 30-minute cycle (device info refresh) print("Test 4: 30-minute cycle (device info refresh)") @@ -5926,7 +5926,7 @@ async def test_run_main(): print(f"**** ERROR: query_device_info not called enough times (expected 2+, got {mock_device.call_count}) ****") failed = True else: - print(f"✓ 30-minute cycle correct") + print(f"PASS: 30-minute cycle correct") # Test 5: Read-only mode (controls disabled) print("Test 5: Read-only mode (controls disabled)") @@ -5955,7 +5955,7 @@ async def test_run_main(): print(f"**** ERROR: apply_controls called in read-only mode ****") failed = True else: - print(f"✓ Read-only mode correct") + print(f"PASS: Read-only mode correct") # Test 6: Automatic config on first run print("Test 6: Automatic config on first run") @@ -5985,7 +5985,7 @@ async def test_run_main(): print(f"**** ERROR: automatic_config not called when automatic=True on first run ****") failed = True else: - print(f"✓ Automatic config triggered correctly") + print(f"PASS: Automatic config triggered correctly") # Test 7: Failed plant info fetch print("Test 7: Failed plant info fetch") @@ -6000,10 +6000,10 @@ async def test_run_main(): print(f"**** ERROR: Should return False when plant_info is None ****") failed = True else: - print(f"✓ Failed plant info handled correctly") + print(f"PASS: Failed plant info handled correctly") if not failed: - print("✓ run loop tests passed") + print("PASS: run loop tests passed") return failed @@ -6050,7 +6050,7 @@ async def test_set_default_work_mode_main(): print(f"**** ERROR: Expected charge_upper_soc 100, got {call_args[0][3]} ****") failed = True else: - print(f"✓ Successful call invokes set_work_mode with correct parameters") + print(f"PASS: Successful call invokes set_work_mode with correct parameters") # Test 2: Failed set_work_mode should not set flag print("Test 2: Failed set_work_mode should not set flag") @@ -6066,7 +6066,7 @@ async def test_set_default_work_mode_main(): print(f"**** ERROR: Should return False when set_work_mode fails ****") failed = True else: - print(f"✓ Failed set_work_mode correctly does not set flag") + print(f"PASS: Failed set_work_mode correctly does not set flag") # Test 3: Verify success log messages print("Test 3: Verify success log messages") @@ -6084,7 +6084,7 @@ async def test_set_default_work_mode_main(): print(f"**** ERROR: Success log message not found ****") failed = True else: - print(f"✓ Success log message generated correctly") + print(f"PASS: Success log message generated correctly") # Test 4: Failed call should generate warning log print("Test 4: Failed call should generate warning log") @@ -6102,7 +6102,7 @@ async def test_set_default_work_mode_main(): print(f"**** ERROR: Warning log message not found ****") failed = True else: - print(f"✓ Warning log message generated correctly") + print(f"PASS: Warning log message generated correctly") # Test 5: Verify business_type parameter is passed through print("Test 5: Verify business_type parameter is passed through") @@ -6120,7 +6120,7 @@ async def test_set_default_work_mode_main(): print(f"**** ERROR: Expected business_type=4, got {call_kwargs.get('business_type')} ****") failed = True else: - print(f"✓ business_type parameter passed through correctly") + print(f"PASS: business_type parameter passed through correctly") # Test 6: Verify mode parameter is passed through (backup mode) print("Test 6: Verify mode parameter is passed through (backup mode)") @@ -6138,10 +6138,10 @@ async def test_set_default_work_mode_main(): print(f"**** ERROR: Expected mode 'backup', got {call_args6[0][0]} ****") failed = True else: - print(f"✓ mode parameter passed through correctly") + print(f"PASS: mode parameter passed through correctly") if not failed: - print("✓ set_default_work_mode tests passed") + print("PASS: set_default_work_mode tests passed") return failed @@ -6199,7 +6199,7 @@ async def test_positive_or_negative_mode_main(): print(f"**** ERROR: Wrong command_name: {command_name} ****") failed = True else: - print(f"✓ Charge mode parameters correct") + print(f"PASS: Charge mode parameters correct") # Test 2: Discharge mode (positive battery_power) print("Test 2: Discharge mode (positive battery_power)") @@ -6221,7 +6221,7 @@ async def test_positive_or_negative_mode_main(): print(f"**** ERROR: Wrong battery_power for discharge: {payload2.get('batteryPower')} ****") failed = True else: - print(f"✓ Discharge mode parameters correct") + print(f"PASS: Discharge mode parameters correct") # Test 3: Default next_motion parameter print("Test 3: Default next_motion parameter") @@ -6244,7 +6244,7 @@ async def test_positive_or_negative_mode_main(): print(f"**** ERROR: Default next_motion should be 161, got {payload3.get('nextMotion')} ****") failed = True else: - print(f"✓ Default next_motion (161) correct") + print(f"PASS: Default next_motion (161) correct") # Test 4: Custom next_motion parameter (Exit Remote Control) print("Test 4: Custom next_motion parameter (Exit Remote Control)") @@ -6266,7 +6266,7 @@ async def test_positive_or_negative_mode_main(): print(f"**** ERROR: next_motion should be 160, got {payload4.get('nextMotion')} ****") failed = True else: - print(f"✓ Custom next_motion (160) correct") + print(f"PASS: Custom next_motion (160) correct") # Test 5: Default business_type (residential) print("Test 5: Default business_type (residential)") @@ -6289,7 +6289,7 @@ async def test_positive_or_negative_mode_main(): print(f"**** ERROR: Default business_type should be 1, got {payload5.get('businessType')} ****") failed = True else: - print(f"✓ Default business_type (residential) correct") + print(f"PASS: Default business_type (residential) correct") # Test 6: Custom business_type (commercial) print("Test 6: Custom business_type (commercial)") @@ -6311,7 +6311,7 @@ async def test_positive_or_negative_mode_main(): print(f"**** ERROR: business_type should be 4 (commercial), got {payload6.get('businessType')} ****") failed = True else: - print(f"✓ Custom business_type (commercial) correct") + print(f"PASS: Custom business_type (commercial) correct") # Test 7: Failed command execution print("Test 7: Failed command execution") @@ -6327,7 +6327,7 @@ async def test_positive_or_negative_mode_main(): print(f"**** ERROR: Should return False on failure ****") failed = True else: - print(f"✓ Failed command execution handled correctly") + print(f"PASS: Failed command execution handled correctly") # Test 8: Zero battery power (edge case) print("Test 8: Zero battery power (edge case)") @@ -6349,7 +6349,7 @@ async def test_positive_or_negative_mode_main(): print(f"**** ERROR: Zero battery_power should be preserved, got {payload8.get('batteryPower')} ****") failed = True else: - print(f"✓ Zero battery power handled correctly") + print(f"PASS: Zero battery power handled correctly") # Test 9: Large duration value print("Test 9: Large duration value") @@ -6371,10 +6371,10 @@ async def test_positive_or_negative_mode_main(): print(f"**** ERROR: Duration should be 43200, got {payload9.get('timeOfDuration')} ****") failed = True else: - print(f"✓ Large duration value handled correctly") + print(f"PASS: Large duration value handled correctly") if not failed: - print("✓ positive_or_negative_mode tests passed") + print("PASS: positive_or_negative_mode tests passed") return failed @@ -6432,7 +6432,7 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: Wrong sn_list passed to send_command_and_wait: {sn_list} ****") failed = True else: - print(f"✓ Single inverter parameters correct") + print(f"PASS: Single inverter parameters correct") # Test 2: Multiple inverters print("Test 2: Multiple inverters") @@ -6454,7 +6454,7 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: Wrong snList for multiple inverters: {payload2.get('snList')} ****") failed = True else: - print(f"✓ Multiple inverters handled correctly") + print(f"PASS: Multiple inverters handled correctly") # Test 3: Default next_motion parameter (Back to Self-Consume) print("Test 3: Default next_motion parameter (Back to Self-Consume)") @@ -6477,7 +6477,7 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: Default next_motion should be 161 (Back to Self-Consume), got {payload3.get('nextMotion')} ****") failed = True else: - print(f"✓ Default next_motion (161) correct") + print(f"PASS: Default next_motion (161) correct") # Test 4: Custom next_motion (Exit Remote Control) print("Test 4: Custom next_motion (Exit Remote Control)") @@ -6499,7 +6499,7 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: next_motion should be 160 (Exit Remote Control), got {payload4.get('nextMotion')} ****") failed = True else: - print(f"✓ Custom next_motion (160) correct") + print(f"PASS: Custom next_motion (160) correct") # Test 5: Default business_type (residential) print("Test 5: Default business_type (residential)") @@ -6522,7 +6522,7 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: Default business_type should be 1 (residential), got {payload5.get('businessType')} ****") failed = True else: - print(f"✓ Default business_type (residential) correct") + print(f"PASS: Default business_type (residential) correct") # Test 6: Custom business_type (commercial) print("Test 6: Custom business_type (commercial)") @@ -6544,7 +6544,7 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: business_type should be 4 (commercial), got {payload6.get('businessType')} ****") failed = True else: - print(f"✓ Custom business_type (commercial) correct") + print(f"PASS: Custom business_type (commercial) correct") # Test 7: Failed command execution print("Test 7: Failed command execution") @@ -6560,7 +6560,7 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: Should return False on failure ****") failed = True else: - print(f"✓ Failed command execution handled correctly") + print(f"PASS: Failed command execution handled correctly") # Test 8: Short duration (edge case) print("Test 8: Short duration (edge case)") @@ -6582,7 +6582,7 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: Short duration should be preserved, got {payload8.get('timeOfDuration')} ****") failed = True else: - print(f"✓ Short duration handled correctly") + print(f"PASS: Short duration handled correctly") # Test 9: Long duration (edge case) print("Test 9: Long duration (edge case)") @@ -6604,7 +6604,7 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: Long duration should be preserved, got {payload9.get('timeOfDuration')} ****") failed = True else: - print(f"✓ Long duration handled correctly") + print(f"PASS: Long duration handled correctly") # Test 10: Empty inverter list (edge case) print("Test 10: Empty inverter list (edge case)") @@ -6626,10 +6626,10 @@ async def test_self_consume_charge_only_mode_main(): print(f"**** ERROR: Empty list should be preserved, got {payload10.get('snList')} ****") failed = True else: - print(f"✓ Empty inverter list handled correctly") + print(f"PASS: Empty inverter list handled correctly") if not failed: - print("✓ self_consume_charge_only_mode tests passed") + print("PASS: self_consume_charge_only_mode tests passed") return failed @@ -6675,7 +6675,7 @@ async def test_query_request_result_main(): print(f"**** ERROR: Wrong requestId: {json_data.get('requestId')} ****") failed = True else: - print(f"✓ Single device success status returned correctly") + print(f"PASS: Single device success status returned correctly") # Test 2: Multiple devices - all successful print("Test 2: Multiple devices - all successful") @@ -6691,7 +6691,7 @@ async def test_query_request_result_main(): print(f"**** ERROR: Should return status 4 for all successful, got {result2} ****") failed = True else: - print(f"✓ Multiple devices all successful") + print(f"PASS: Multiple devices all successful") # Test 3: Multiple devices - one device offline print("Test 3: Multiple devices - one device offline") @@ -6707,7 +6707,7 @@ async def test_query_request_result_main(): print(f"**** ERROR: Should return first non-success status 2, got {result3} ****") failed = True else: - print(f"✓ Non-success status detected correctly") + print(f"PASS: Non-success status detected correctly") # Test 4: Empty result array print("Test 4: Empty result array") @@ -6723,7 +6723,7 @@ async def test_query_request_result_main(): print(f"**** ERROR: Should return default status 4 for empty result, got {result4} ****") failed = True else: - print(f"✓ Empty result array handled correctly") + print(f"PASS: Empty result array handled correctly") # Test 5: API error response print("Test 5: API error response") @@ -6739,7 +6739,7 @@ async def test_query_request_result_main(): print(f"**** ERROR: Should return None on API error, got {result5} ****") failed = True else: - print(f"✓ API error handled correctly") + print(f"PASS: API error handled correctly") # Test 6: request_get returns None (network error) print("Test 6: request_get returns None (network error)") @@ -6755,7 +6755,7 @@ async def test_query_request_result_main(): print(f"**** ERROR: Should return None on network error, got {result6} ****") failed = True else: - print(f"✓ Network error handled correctly") + print(f"PASS: Network error handled correctly") # Test 7: Missing result field in response print("Test 7: Missing result field in response") @@ -6774,7 +6774,7 @@ async def test_query_request_result_main(): print(f"**** ERROR: Should return default status 4 when result missing, got {result7} ****") failed = True else: - print(f"✓ Missing result field handled correctly") + print(f"PASS: Missing result field handled correctly") # Test 8: First device fails, others succeed print("Test 8: First device fails, others succeed") @@ -6790,7 +6790,7 @@ async def test_query_request_result_main(): print(f"**** ERROR: Should return first device status 1, got {result8} ****") failed = True else: - print(f"✓ First device failure detected correctly") + print(f"PASS: First device failure detected correctly") # Test 9: Device result with missing fields print("Test 9: Device result with missing fields") @@ -6807,7 +6807,7 @@ async def test_query_request_result_main(): print(f"**** ERROR: Should return None when device has missing status field ****") failed = True else: - print(f"✓ Missing status field returns None correctly") + print(f"PASS: Missing status field returns None correctly") # Test 10: Long request ID print("Test 10: Long request ID") @@ -6830,10 +6830,10 @@ async def test_query_request_result_main(): print(f"**** ERROR: Long request ID not preserved ****") failed = True else: - print(f"✓ Long request ID handled correctly") + print(f"PASS: Long request ID handled correctly") if not failed: - print("✓ query_request_result tests passed") + print("PASS: query_request_result tests passed") return failed @@ -6899,7 +6899,7 @@ def mock_get_state_text(entity_id, default=None): print(f"**** ERROR: Expected reserve 20, got {api.controls[plant_id]['reserve']} ****") failed = True else: - print(f"✓ Text values converted to numbers correctly") + print(f"PASS: Text values converted to numbers correctly") # Test 2: Values bounded by min/max print("Test 2: Values bounded by min/max") @@ -6938,7 +6938,7 @@ def mock_get_state_out_of_range(entity_id, default=None): print(f"**** ERROR: Expected charge_rate floored at 0, got {api2.controls[plant_id]['charge']['rate']} ****") failed = True else: - print(f"✓ Out-of-range values bounded correctly") + print(f"PASS: Out-of-range values bounded correctly") # Test 3: Default values used when state not available print("Test 3: Default values used when state not available") @@ -6970,7 +6970,7 @@ def mock_get_state_none(entity_id, default=None): print(f"**** ERROR: Expected default reserve 10, got {api3.controls[plant_id]['reserve']} ****") failed = True else: - print(f"✓ Default values used correctly") + print(f"PASS: Default values used correctly") # Test 4: Invalid text values fall back to defaults print("Test 4: Invalid text values fall back to defaults") @@ -7000,7 +7000,7 @@ def mock_get_state_invalid(entity_id, default=None): print(f"**** ERROR: Expected default reserve 10 for invalid input, got {api4.controls[plant_id]['reserve']} ****") failed = True else: - print(f"✓ Invalid text values fall back to defaults") + print(f"PASS: Invalid text values fall back to defaults") # Test 5: Mixed valid numeric and text values print("Test 5: Mixed valid numeric and text values") @@ -7039,7 +7039,7 @@ def mock_get_state_mixed(entity_id, default=None): print(f"**** ERROR: Expected reserve 15, got {api5.controls[plant_id]['reserve']} ****") failed = True else: - print(f"✓ Mixed numeric and text values handled correctly") + print(f"PASS: Mixed numeric and text values handled correctly") # Test 6: Switch entities return "on"/"off" strings from HA — must be normalised to bool print("Test 6: Switch 'on'/'off' strings normalised to bool") @@ -7066,7 +7066,7 @@ def mock_get_state_switch_strings(entity_id, default=None): print(f"**** ERROR: Expected export enable False from 'off' string, got {api6.controls[plant_id]['export']['enable']!r} ****") failed = True else: - print("✓ Switch 'on'/'off' strings normalised to bool correctly") + print("PASS: Switch 'on'/'off' strings normalised to bool correctly") # Test 7: Switch entities return uppercase "ON"/"OFF" — case-insensitive normalisation print("Test 7: Switch uppercase 'ON'/'OFF' strings normalised to bool") @@ -7093,10 +7093,10 @@ def mock_get_state_switch_upper(entity_id, default=None): print(f"**** ERROR: Expected export enable False from 'OFF' string, got {api7.controls[plant_id]['export']['enable']!r} ****") failed = True else: - print("✓ Uppercase switch strings normalised correctly") + print("PASS: Uppercase switch strings normalised correctly") if not failed: - print("✓ fetch_controls value conversion tests passed") + print("PASS: fetch_controls value conversion tests passed") return failed @@ -7147,7 +7147,7 @@ async def test_exit_vpp_mode_main(): print(f"**** ERROR: Wrong sn_list passed: {sn_list} ****") failed = True else: - print(f"✓ Single inverter exit VPP mode correct") + print(f"PASS: Single inverter exit VPP mode correct") # Test 2: Multiple inverters print("Test 2: Multiple inverters") @@ -7169,7 +7169,7 @@ async def test_exit_vpp_mode_main(): print(f"**** ERROR: Wrong snList for multiple inverters: {payload2.get('snList')} ****") failed = True else: - print(f"✓ Multiple inverters handled correctly") + print(f"PASS: Multiple inverters handled correctly") # Test 3: Default business_type (residential) print("Test 3: Default business_type (residential)") @@ -7191,7 +7191,7 @@ async def test_exit_vpp_mode_main(): print(f"**** ERROR: Default business_type should be 1, got {payload3.get('businessType')} ****") failed = True else: - print(f"✓ Default business_type (residential) correct") + print(f"PASS: Default business_type (residential) correct") # Test 4: Custom business_type (commercial) print("Test 4: Custom business_type (commercial)") @@ -7213,7 +7213,7 @@ async def test_exit_vpp_mode_main(): print(f"**** ERROR: business_type should be 4 (commercial), got {payload4.get('businessType')} ****") failed = True else: - print(f"✓ Custom business_type (commercial) correct") + print(f"PASS: Custom business_type (commercial) correct") # Test 5: Failed command execution print("Test 5: Failed command execution") @@ -7229,7 +7229,7 @@ async def test_exit_vpp_mode_main(): print(f"**** ERROR: Should return False on failure ****") failed = True else: - print(f"✓ Failed command execution handled correctly") + print(f"PASS: Failed command execution handled correctly") # Test 6: Maximum 10 devices print("Test 6: Maximum 10 devices") @@ -7252,7 +7252,7 @@ async def test_exit_vpp_mode_main(): print(f"**** ERROR: Expected 10 devices, got {len(payload6.get('snList'))} ****") failed = True else: - print(f"✓ Maximum 10 devices handled correctly") + print(f"PASS: Maximum 10 devices handled correctly") # Test 7: Empty list (edge case) print("Test 7: Empty list (edge case)") @@ -7274,9 +7274,9 @@ async def test_exit_vpp_mode_main(): print(f"**** ERROR: Empty list should be preserved, got {payload7.get('snList')} ****") failed = True else: - print(f"✓ Empty list handled correctly") + print(f"PASS: Empty list handled correctly") if not failed: - print("✓ exit_vpp_mode tests passed") + print("PASS: exit_vpp_mode tests passed") return failed diff --git a/apps/predbat/tests/test_solcast.py b/apps/predbat/tests/test_solcast.py index 3fd294ad9..9abf4b67d 100644 --- a/apps/predbat/tests/test_solcast.py +++ b/apps/predbat/tests/test_solcast.py @@ -1120,7 +1120,7 @@ def test_forecast_solar_rate_limit_suppresses_fetch(my_predbat): } ] - # Step 1: first call gets a 429 → rate limit should be stored + # Step 1: first call gets a 429 -> rate limit should be stored test_api.set_mock_response("forecast.solar", {"error": "rate limit"}, 429) def create_mock_session(*args, **kwargs): @@ -2345,7 +2345,7 @@ def mock_minute_data_import_export(max_days_previous, now_utc, key, scale=1.0, r base.minute_data_import_export = mock_minute_data_import_export - # No forecast history → enabled_calibration will be False (< 3 days), but power + # No forecast history -> enabled_calibration will be False (< 3 days), but power # conversion and capped_data paths still execute. solar.get_history_wrapper = lambda entity_id, days, required=False: [] @@ -2477,7 +2477,7 @@ def make_h0_ha_history(now_utc, days_back): """ Build HA-format h0 forecast history (kW sensor) spanning days_back days. Returns 1.0 kW forecast during 10:00-11:00 UTC for each past day so that - the oldest timestamp is days_back days ago → pv_forecast_hist_days = days_back. + the oldest timestamp is days_back days ago -> pv_forecast_hist_days = days_back. """ entries = [] for day in range(days_back, 0, -1): @@ -2543,7 +2543,7 @@ def mock_get_history(entity_id, days, required=False, _h0=h0_ha_history): total = solar.pv_calibration_total_adjustment if not expect_enabled: - # 2-day history → calibration disabled → hard-coded defaults must be used + # 2-day history -> calibration disabled -> hard-coded defaults must be used if abs(worst - 0.7) > 0.001: print("ERROR: {}-day history (disabled): worst_scaling should be 0.7, got {}".format(days_back, worst)) failed = True @@ -2554,8 +2554,8 @@ def mock_get_history(entity_id, days, required=False, _h0=h0_ha_history): print("ERROR: {}-day history (disabled): total_adjustment should be 1.0, got {}".format(days_back, total)) failed = True else: - # 5-day history → calibration enabled → must NOT produce the disabled-default values - # (actual 0.5 kWh/day < forecast 1.0 kWh/day → worst and best both < 1.0, not 0.7/1.3) + # 5-day history -> calibration enabled -> must NOT produce the disabled-default values + # (actual 0.5 kWh/day < forecast 1.0 kWh/day -> worst and best both < 1.0, not 0.7/1.3) if abs(worst - 0.7) < 0.001: print("ERROR: {}-day history (enabled): worst_scaling is 0.7 (disabled default) – calibration appears disabled".format(days_back)) failed = True @@ -2665,7 +2665,7 @@ def test_pv_calibration_no_history_not_zeroed(my_predbat): base = test_api.mock_base plan_interval = base.plan_interval_minutes # 5 - # No historical actual production and no forecast history at all → no valid days, + # No historical actual production and no forecast history at all -> no valid days, # so max_pv_power_hist = max_pv_power_forecast = 0 and calibration is disabled. def mock_minute_data_import_export(max_days_previous, now_utc, key, scale=1.0, required_unit=None, increment=True, smoothing=True, pad=True): return {} @@ -2720,7 +2720,7 @@ def test_pv_calibration_synthetic_values(my_predbat): - average_day_scaling ≈ 0.5 - worst / best day scaling = 1.0 (no day-to-day variance) - calibrated gen-slot minute ≈ 0.5 × input - - pv_estimate10 / pv_estimate90 ≈ pv_estimateCL (worst=best=1.0 → no spread) + - pv_estimate10 / pv_estimate90 ≈ pv_estimateCL (worst=best=1.0 -> no spread) Sub-case B – variable performance (3 days: actual = 0.5, 1.0, 1.5 kWh each, forecast=1.0): - average_day_scaling ≈ 0.963 (weighted: (0.5×1.0 + 1.0×0.9 + 1.5×0.8) / 2.7 ≈ 0.963) @@ -2794,7 +2794,7 @@ def run_scenario(actual_per_day): hist = build_pv_today_hist(actual_per_day) # Build pv_forecast minute dict directly (bypass full h0 pipeline). - # pv_calibration maps minute N → minute_absolute = minutes_now - N. + # pv_calibration maps minute N -> minute_absolute = minutes_now - N. # For day D's gen window (GEN_START..GEN_END-1 min-of-day): # minute_ago = D*1440 + (minutes_now - m_of_day) # We provide FORECAST_KW kW at each gen-window minute for all past days. @@ -2866,8 +2866,8 @@ def mock_minute_import_export(max_days_prev, now_utc, key, scale=1.0, required_u print("ERROR [A]: adj_minute[630] / raw = {:.4f}, expected ~{:.4f} (total_adj ± 15%)".format(ratio, r["total_adj"])) failed = True - # pv_estimate10 should use worst scaling (=1.0) → equal to pv_estimateCL. - # pv_estimate90 should use best scaling (=1.0) → equal to pv_estimateCL too (no spread). + # pv_estimate10 should use worst scaling (=1.0) -> equal to pv_estimateCL. + # pv_estimate90 should use best scaling (=1.0) -> equal to pv_estimateCL too (no spread). for entry in r["adj_data"]: cl = entry.get("pv_estimateCL") e10 = entry.get("pv_estimate10") @@ -2882,7 +2882,7 @@ def mock_minute_import_export(max_days_prev, now_utc, key, scale=1.0, required_u failed = True break - # --- Sub-case B: 3 days at 0.5x, 1.0x, 1.5x → weighted avg=0.963, worst=0.519, best=1.558 --- + # --- Sub-case B: 3 days at 0.5x, 1.0x, 1.5x -> weighted avg=0.963, worst=0.519, best=1.558 --- r = run_scenario([0.5, 1.0, 1.5]) if abs(r["total_adj"] - 0.963) > TOL: @@ -3172,7 +3172,7 @@ def test_pv_calibration_60min_period(my_predbat): base = test_api.mock_base base.plan_interval_minutes = PLAN_INTERVAL - # Flat historical production matching the forecast → calibration ratio ~1.0 + # Flat historical production matching the forecast -> calibration ratio ~1.0 # so the calibrated values should be very close to the raw forecast values. hist = {} days = 5 @@ -3300,7 +3300,7 @@ def test_pv_calibration_15min_period(my_predbat): base = test_api.mock_base base.plan_interval_minutes = PLAN_INTERVAL - # Flat historical production matching the forecast → calibration ratio ~1.0 + # Flat historical production matching the forecast -> calibration ratio ~1.0 hist = {} days = 5 minutes_now = base.minutes_now # 720 (noon) @@ -3444,7 +3444,7 @@ def create_mock_session(*args, **kwargs): test_api.cleanup() # --- Case 2: azimuth_zero_south=False (default), azimuth=0 (North in Predbat convention) --- - # convert_azimuth(0) → 180; URL path should contain /180/ + # convert_azimuth(0) -> 180; URL path should contain /180/ test_api = create_test_solar_api() try: test_api.solar.forecast_solar = [{"latitude": 51.5, "longitude": -0.1, "declination": 30, "azimuth": 0, "kwp": 3.0, "efficiency": 1.0}] @@ -3473,8 +3473,8 @@ def test_pv_calibration_skips_system_down_days(my_predbat): drag the average downward, causing the forecast to be under-estimated. Scenario: 5 days of history. - - Days 2-5: actual = forecast = 1.0 kWh → scaling factor = 1.0 each - - Day 1 (yesterday): actual = 0.03 kWh, forecast = 1.0 kWh (3% → should be skipped) + - Days 2-5: actual = forecast = 1.0 kWh -> scaling factor = 1.0 each + - Day 1 (yesterday): actual = 0.03 kWh, forecast = 1.0 kWh (3% -> should be skipped) Expected outcome: - average_day_scaling ≈ 1.0 (only the 4 good days are used) diff --git a/apps/predbat/tests/test_solis.py b/apps/predbat/tests/test_solis.py index d9c79cc38..1c5caabb9 100644 --- a/apps/predbat/tests/test_solis.py +++ b/apps/predbat/tests/test_solis.py @@ -1884,7 +1884,7 @@ async def test_write_time_windows_v2_stale_slot_clearing(): # Pass 1 should clear slot 2 charge (enable + time) even though slot 1 is also processed. # Slot 1 charge is active so Pass 1 skips it; slot 1 discharge is already clear. - # Slot 2 charge is disabled AND stale → 2 clear writes expected. + # Slot 2 charge is disabled AND stale -> 2 clear writes expected. slot2_enable_clear = next((c for c in calls if c["cid"] == SOLIS_CID_CHARGE_ENABLE_BASE + 1 and c["value"] == "0"), None) assert slot2_enable_clear is not None, "Pass 1 must clear stale slot 2 charge enable" @@ -2257,7 +2257,7 @@ async def test_write_time_windows_v1_local_time_not_utc(): outside_mode = api.set_storage_mode_calls[-1]["mode"] assert outside_mode == "Self-Use - No Timed Charge/Discharge", f"With UTC time 20:43 (outside window) expected 'Self-Use - No Timed Charge/Discharge', got '{outside_mode}'" - # The fix ensures now_utc_exact (local time) is used, so 21:43 local → Self-Use (in slot) + # The fix ensures now_utc_exact (local time) is used, so 21:43 local -> Self-Use (in slot) # If UTC were used (20:43), result would be Self-Use - No Timed Charge/Discharge (outside slot) print("PASSED: V1 mode uses local time for slot detection (regression: UTC offset no longer causes missed discharge windows)") return False @@ -3361,14 +3361,14 @@ async def mock_read_and_write_cid(sn, cid, value, field_description=None): # Test max_export_power: HA sends watts, inverter expects 100W units (÷100) api.read_and_write_cid_calls = [] entity_id = f"number.predbat_solis_{inverter_sn}_max_export_power" - await api.number_event(entity_id, 5000) # 5000 W → 50 (100W units) + await api.number_event(entity_id, 5000) # 5000 W -> 50 (100W units) assert len(api.read_and_write_cid_calls) == 1, "Should call read_and_write_cid once for max_export_power" call = api.read_and_write_cid_calls[0] assert call["cid"] == SOLIS_CID_MAX_EXPORT_POWER, f"Expected CID {SOLIS_CID_MAX_EXPORT_POWER}, got {call['cid']}" assert call["value"] == "50", f"Expected '50' (5000÷100), got {call['value']}" - # Test with a value that truncates (e.g. 550W → 5 in 100W units, not 5.5) + # Test with a value that truncates (e.g. 550W -> 5 in 100W units, not 5.5) api.read_and_write_cid_calls = [] await api.number_event(entity_id, 550) call = api.read_and_write_cid_calls[0] diff --git a/apps/predbat/tests/test_storage.py b/apps/predbat/tests/test_storage.py index 0fa958e22..a7472efab 100644 --- a/apps/predbat/tests/test_storage.py +++ b/apps/predbat/tests/test_storage.py @@ -106,7 +106,7 @@ def log(msg): # 12. age() returns None for a missing file assert run_async(storage.age("mod", "nonexistent_age")) is None, "age() should return None for missing file" - # fetch_cached: miss → calls fetch_fn once, stores, returns + # fetch_cached: miss -> calls fetch_fn once, stores, returns calls = {"n": 0} async def _fetch(): @@ -117,12 +117,12 @@ async def _fetch(): assert first == {"v": 1}, "fetch_cached miss should fetch: {}".format(first) assert calls["n"] == 1, "fetch_fn should be called exactly once on miss" - # fetch_cached: fresh hit → does NOT call fetch_fn again + # fetch_cached: fresh hit -> does NOT call fetch_fn again second = run_async(storage.fetch_cached("fc", "k", _fetch, fresh_minutes=30, stale_minutes=35, format="json")) assert second == {"v": 1}, "fresh hit should return cached value: {}".format(second) assert calls["n"] == 1, "fetch_fn must not be called on a fresh hit" - # fetch_cached: with fresh_minutes=0 every existing entry is "stale" → refresh path runs once + # fetch_cached: with fresh_minutes=0 every existing entry is "stale" -> refresh path runs once calls2 = {"n": 0} async def _fetch2(): @@ -134,13 +134,13 @@ async def _fetch2(): assert out == {"w": 1}, "stale path should refresh and return fresh data: {}".format(out) assert calls2["n"] == 1, "stale path should call fetch_fn once" - # fetch_cached: fetch_fn returning None on a hard miss → returns None, no crash + # fetch_cached: fetch_fn returning None on a hard miss -> returns None, no crash async def _fetch_none(): return None assert run_async(storage.fetch_cached("fc3", "missing", _fetch_none, format="json")) is None - # fetch_cached: stale window + fetch_fn returns None → serve cached stale value + # fetch_cached: stale window + fetch_fn returns None -> serve cached stale value run_async(storage.save("fc4", "k", {"w": 0}, format="json")) async def _fetch_none_stale(): @@ -149,7 +149,7 @@ async def _fetch_none_stale(): out = run_async(storage.fetch_cached("fc4", "k", _fetch_none_stale, fresh_minutes=0, stale_minutes=999999, format="json")) assert out == {"w": 0}, "stale path with None fetch should return cached stale: {}".format(out) - # fetch_cached: stale window + fetch_fn RAISES → serve cached stale value, do not propagate + # fetch_cached: stale window + fetch_fn RAISES -> serve cached stale value, do not propagate run_async(storage.save("fc5", "k", {"w": 7}, format="json")) async def _fetch_raise(): @@ -158,7 +158,7 @@ async def _fetch_raise(): out = run_async(storage.fetch_cached("fc5", "k", _fetch_raise, fresh_minutes=0, stale_minutes=999999, format="json")) assert out == {"w": 7}, "stale path with raising fetch should return cached stale: {}".format(out) - # fetch_cached: hard miss + fetch_fn RAISES → return None, do not propagate + # fetch_cached: hard miss + fetch_fn RAISES -> return None, do not propagate out = run_async(storage.fetch_cached("fc6", "missing", _fetch_raise, fresh_minutes=30, stale_minutes=35, format="json")) assert out is None, "hard miss with raising fetch should return None: {}".format(out) diff --git a/apps/predbat/tests/test_temperature.py b/apps/predbat/tests/test_temperature.py index 3edcc299b..2c390c494 100644 --- a/apps/predbat/tests/test_temperature.py +++ b/apps/predbat/tests/test_temperature.py @@ -1225,12 +1225,12 @@ def test_temperature(my_predbat=None): test_result = test_func(my_predbat) if test_result: failed += 1 - print(" ❌ FAILED") + print(" ERROR: FAILED") else: passed += 1 - print(" ✅ PASSED") + print(" PASS: PASSED") except Exception as e: - print(" ❌ EXCEPTION: {}".format(e)) + print(" ERROR: EXCEPTION: {}".format(e)) import traceback traceback.print_exc() failed += 1 diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index c011aa9b3..402941c3a 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -15,6 +15,8 @@ import argparse from predbat import PredBat +from tests.test_auto_config import run_auto_config_tests +from tests.test_ml_load_fallback import run_ml_load_fallback_tests from tests.test_infra import TestHAInterface from tests.test_compute_metric import run_compute_metric_tests from tests.test_perf import run_perf_test @@ -200,6 +202,8 @@ def main(): # Format: (name, function, description, slow) TEST_REGISTRY = [ ("secrets", run_secrets_tests, "Secrets loading tests", False), + ("auto_config", run_auto_config_tests, "Auto Config regex tests", False), + ("ml_load_fallback", run_ml_load_fallback_tests, "ML Load Fallback tests", False), ("perf", run_perf_test, "Performance tests", False), ("model", run_model_tests, "Model tests", False), ("model_kernel", run_model_kernel_tests, "Model tests run with the C++ prediction kernel enabled", False), diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 19991a972..6f9b0a6cb 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -1045,10 +1045,9 @@ def resolve_arg_re(self, arg, arg_value, state_keys): for item_value in arg_value: item_matched, item_value = self.resolve_arg_re(arg, item_value, state_keys) if not item_matched: - self.log("Warn: Regular argument {} expression {} failed to match - disabling this item".format(arg, item_value)) - new_list.append(None) - else: - new_list.append(item_value) + self.log("Warn: Regular argument {} expression {} failed to match - will retry".format(arg, item_value)) + matched = False + new_list.append(item_value) arg_value = new_list elif isinstance(arg_value, dict): new_dict = {} @@ -1056,10 +1055,9 @@ def resolve_arg_re(self, arg, arg_value, state_keys): item_value = arg_value[item_name] item_matched, item_value = self.resolve_arg_re(arg, item_value, state_keys) if not item_matched: - self.log("Warn: Regular argument {} expression {} failed to match - disabling this item".format(arg, item_value)) - new_dict[item_name] = None - else: - new_dict[item_name] = item_value + self.log("Warn: Regular argument {} expression {} failed to match - will retry".format(arg, item_value)) + matched = False + new_dict[item_name] = item_value arg_value = new_dict elif isinstance(arg_value, str) and arg_value.startswith("re:"): matched = False @@ -1088,10 +1086,13 @@ def auto_config(self, final=False): states = self.get_state_wrapper() state_keys = states.keys() disabled = [] - self.unmatched_args = {} + enabled = [] + + if not hasattr(self, "unmatched_args"): + self.unmatched_args = {} # Find each arg re to match - for arg in self.args: + for arg in list(self.args.keys()): arg_value = self.args[arg] matched, arg_value = self.resolve_arg_re(arg, arg_value, state_keys) if not matched: @@ -1101,7 +1102,20 @@ def auto_config(self, final=False): else: self.args[arg] = arg_value - # Remove unmatched keys + # Check previously unmatched args + for arg in list(self.unmatched_args.keys()): + arg_value = self.unmatched_args[arg] + matched, arg_value = self.resolve_arg_re(arg, arg_value, state_keys) + if matched: + self.args[arg] = arg_value + enabled.append(arg) + self.log("Info: Regular expression argument: {} matched on retry".format(arg)) + + # Remove matched keys from unmatched list + for key in enabled: + del self.unmatched_args[key] + + # Remove unmatched keys from args for key in disabled: self.unmatched_args[key] = self.args[key] del self.args[key] diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index a59088bf5..fb67123f4 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -931,6 +931,23 @@ def dp4(value): return round(value, 4) +def safe_float(v): + """ + Convert a value to a float safely, returning None for invalid values (None, NaN, Inf) + """ + import math + + if v is None: + return None + try: + v = float(v) + if math.isnan(v) or math.isinf(v): + return None + return round(v, 4) + except (ValueError, TypeError): + return None + + def minutes_to_time(updated, now): """ Compute the number of minutes between a time (now) and the updated time diff --git a/coverage/cases/predbat_debug_pre_saving1.yaml.expected.json b/coverage/cases/predbat_debug_pre_saving1.yaml.expected.json index 57b8eadf6..850b891e3 100644 --- a/coverage/cases/predbat_debug_pre_saving1.yaml.expected.json +++ b/coverage/cases/predbat_debug_pre_saving1.yaml.expected.json @@ -1 +1 @@ -{"charge_limit_best": [3.02, 0.38, 0.38, 9.52, 9.52, 9.52, 8.27, 0.38, 9.52, 9.52], "charge_window_best": [{"start": 1020, "end": 1050, "average": 25.58, "target": 3.02}, {"start": 1050, "end": 1080, "average": 25.58, "target": 0.38}, {"start": 1140, "end": 1260, "average": 25.58, "target": 0.38}, {"start": 1410, "end": 1680, "average": 7.0, "target": 9.52}, {"start": 1710, "end": 1770, "average": 7.0, "target": 9.509}, {"start": 1770, "end": 1980, "average": 25.58, "target": 9.52}, {"start": 2070, "end": 2220, "average": 25.58, "target": 8.27}, {"start": 2700, "end": 2820, "average": 25.58, "target": 0.38}, {"start": 2850, "end": 3210, "average": 7.0, "target": 9.52}, {"start": 3210, "end": 3900, "average": 25.58, "target": 9.52}], "export_window_best": [{"average": 75.0, "end": 1140, "start": 1080, "set": 69.8, "start_orig": 1080, "target": 12}, {"average": 15.0, "end": 1410, "start": 1320, "set": 14.0, "target": 4}, {"average": 15.0, "end": 1710, "start": 1680, "set": 14.0, "target": 89}], "export_limits_best": [7.0, 0, 85]} +{"charge_limit_best": [3.02, 0.38, 0.38, 0.38, 9.52, 9.52, 9.52, 0.38, 0.38, 0.38, 7.02, 0.38, 9.52, 9.52], "charge_window_best": [{"start": 1020, "end": 1050, "average": 25.58, "target": 3.02}, {"start": 1050, "end": 1080, "average": 25.58, "target": 0.38}, {"start": 1140, "end": 1200, "average": 25.58, "target": 0.38}, {"start": 1230, "end": 1380, "average": 25.58, "target": 0.38}, {"start": 1410, "end": 1660, "average": 7.0, "target": 9.478}, {"start": 1680, "end": 1735, "average": 7.0, "target": 9.519}, {"start": 1740, "end": 1770, "average": 7.0, "target": 9.52}, {"start": 1830, "end": 2100, "average": 25.58, "target": 0.38}, {"start": 2160, "end": 2250, "average": 25.58, "target": 0.38}, {"start": 2310, "end": 2340, "average": 25.58, "target": 0.38}, {"start": 2340, "end": 2370, "average": 25.58, "target": 7.02}, {"start": 2370, "end": 2430, "average": 25.58, "target": 0.38}, {"start": 2850, "end": 3210, "average": 7.0, "target": 9.52}, {"start": 3210, "end": 3900, "average": 25.58, "target": 9.52}], "export_window_best": [{"average": 75.0, "end": 1140, "start": 1080, "set": 69.8, "start_orig": 1080, "target": 12}, {"average": 15.0, "end": 1680, "start": 1660, "set": 14.0, "target": 92}, {"average": 15.0, "end": 1740, "start": 1735, "set": 14.0, "start_orig": 1710, "target": 98}], "export_limits_best": [7.0, 88.0, 94.0]} diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 002fee314..f66c3d60f 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -988,7 +988,7 @@ Example: inverter_limit: 5000 # 5 kW — must be in watts when set as a literal ``` -NB: inverter_limit is ONLY used by Predbat to improve the quality of the plan, any solar clipping is done by the inverter and is not controlled by Predbat. +NB: inverter_limit is ONLY used by Predbat to improve the quality of the plan, any solar clipping is done by the inverter and is not controlled by Predbat. To provide Predbat with the unclipped solar forecast data required for this modeling, the [Solcast Clearsky Integration](https://github.com/autoSteve/ha-solcast-clearsky) can be used. ### **pv_ac_limit** @@ -1010,7 +1010,7 @@ Example: pv_ac_limit: 3600 ``` -NB: pv_ac_limit is ONLY used by Predbat to improve the quality of the plan, any solar clipping is done by the inverter and is not controlled by Predbat. +NB: pv_ac_limit is ONLY used by Predbat to improve the quality of the plan, any solar clipping is done by the inverter and is not controlled by Predbat. To provide Predbat with the unclipped solar forecast data required for this modeling, the [Solcast Clearsky Integration](https://github.com/autoSteve/ha-solcast-clearsky) can be used. ### **export_limit** diff --git a/docs/customisation.md b/docs/customisation.md index ba6029050..aca325ec7 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -715,6 +715,24 @@ The manual SoC target works in conjunction with the [weather alert system](apps- This is described in detail in [Manual API](manual-api.md) and is mentioned here just for completeness. +## Clipping Configuration + +Predbat includes advanced controls to manage solar clipping scenarios and ensure accurate simulation of your inverter's maximum capabilities. These configurations determine how clipping is modeled and how Predbat should buffer energy. + +- **switch.predbat_clipping_buffer_enable** - Enables or disables the use of a clipping buffer in Predbat's modeling (Default is True). +- **select.predbat_clipping_clearsky_source** - Determines the source used for the clearsky solar forecast (e.g., `ha_solcast_clearsky`), used to model unclipped generation capabilities. +- **switch.predbat_clipping_auto_tune** - When enabled, Predbat will automatically tune the clipping parameters for better plan optimization. +- **input_number.predbat_clipping_cost_weight** - Multiplier used to weight the cost of clipped energy in the optimization algorithm. +- **input_number.predbat_clipping_amplification** - Amplification factor applied to the clipping buffer. +- **input_number.predbat_clipping_buffer_start_offset** - Allows you to offset the start of the clipping buffer window (in minutes). +- **input_number.predbat_clipping_buffer_end_offset** - Allows you to offset the end of the clipping buffer window (in minutes). +- **input_number.predbat_clipping_limit_override** - Can be used to manually override the inverter limit for clipping purposes (in Watts). Set to 0 to disable override. +- **input_number.predbat_clipping_buffer_max_kwh** - Manual override for the maximum size of the clipping buffer in kWh. +- **select.predbat_clipping_buffer_start_time** - Specific start time to define the clipping buffer window. +- **select.predbat_clipping_buffer_end_time** - Specific end time to define the clipping buffer window. + +You can view the clipping plan by setting up the appropriate Apex Chart — see [creating the Predbat charts](creating-charts.md) or viewing the clipping chart in the [Predbat web interface](web-interface.md#charts-view). + ## Debug **switch.predbat_debug_enable** When turned On will create lots of debugging information to aid diagnosis of Predbat issues. By default this is turned Off and its recommended that its only switched on when debug logs are requested. With the switch on: diff --git a/docs/install.md b/docs/install.md index 9db04dcac..9bca34524 100644 --- a/docs/install.md +++ b/docs/install.md @@ -270,6 +270,10 @@ mode: single Manually run the automation and then make sure the Solcast integration is working in Home Assistant by going to 'Settings' / 'Developer Tools' / 'States', filtering on 'solcast', and check that you can see the half-hourly solar forecasts in the Solcast entities. +### Solcast Clearsky Install + +If you want Predbat to model inverter clipping more accurately, you can use the [Solcast Clearsky Integration](https://github.com/autoSteve/ha-solcast-clearsky). This integration piggybacks from the base Solcast integration and automatically calculates a clearsky forecast using the Solcast array configuration, providing unclipped forecast data to Predbat. + ### No solar If you don't have any solar generation then use a file editor to comment out the following lines from the Solar forecast part of the `apps.yaml` configuration: