Configure now opens a menu: Settings (the previous form) or Reset learned model. After an explicit confirmation the entry is unloaded, the learned parts of the stored model (zone, extended and ventilation coefficients, models kept for removed zones, Model Error and Forecast Error statistics, fingerprints) are removed under the storage lock, and the entry is set up again. Configuration, heat-source baselines and the window event log are kept, and model_changes records the reset. Unloading first means no update still running can write the old model back. English and Romanian strings; the gust hint now refers to Model Error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
4.8 KiB
Python
82 lines
4.8 KiB
Python
"""Timestamp boundaries and UI translation coverage without HA dependencies."""
|
|
import importlib.util
|
|
import json
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1] / 'custom_components' / 'heating_mpc'
|
|
spec = importlib.util.spec_from_file_location('quality', ROOT / 'data_quality.py')
|
|
quality = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(quality)
|
|
spec = importlib.util.spec_from_file_location('heating_mpc_const', ROOT / 'const.py')
|
|
const = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(const)
|
|
|
|
|
|
class FreshnessTests(unittest.TestCase):
|
|
def test_age_boundaries(self):
|
|
now = datetime(2026, 10, 26, tzinfo=timezone.utc)
|
|
for seconds, expected in ((0, 'valid'), (1800, 'valid'), (1801, 'stale'), (-1, 'future_timestamp')):
|
|
with self.subTest(seconds=seconds):
|
|
result = quality.freshness(now - timedelta(seconds=seconds), now, 1800)
|
|
self.assertEqual(result['status'], expected)
|
|
self.assertEqual(result['age_seconds'], seconds)
|
|
|
|
def test_missing_naive_and_malformed_times(self):
|
|
now = datetime.now(timezone.utc)
|
|
for timestamp in (None, 'bad', datetime.now()):
|
|
self.assertEqual(quality.freshness(timestamp, now, 1800)['status'], 'invalid_timestamp')
|
|
|
|
def test_invalid_limits_rejected(self):
|
|
now = datetime.now(timezone.utc)
|
|
for limit in (0, -1, float('inf'), float('nan')):
|
|
with self.assertRaises(ValueError):
|
|
quality.freshness(now, now, limit)
|
|
|
|
def test_report_age_history_keeps_24_hour_maximum(self):
|
|
history = quality.ReportAgeHistory()
|
|
start = datetime(2026, 10, 26, tzinfo=timezone.utc)
|
|
self.assertEqual(history.observe('sensor.a', start, 2400), 2400)
|
|
self.assertEqual(history.observe('sensor.b', start, 60), 60)
|
|
for minutes in range(5, 24 * 60, 5):
|
|
result = history.observe('sensor.a', start + timedelta(minutes=minutes), 300)
|
|
self.assertEqual(result, 2400) # still inside the 24-hour window
|
|
later = start + timedelta(hours=24, minutes=5)
|
|
self.assertEqual(history.observe('sensor.a', later, 300), 300)
|
|
self.assertEqual(history.observe('sensor.b', later, 30), 30)
|
|
|
|
def test_inputs_differ_ignores_noise_but_not_meaningful_changes(self):
|
|
before = {('temperature', 'sensor.room'): 20.0, ('power', 'sensor.ups'): 210.0,
|
|
('wind_direction', 'sensor.dir'): 355.0, ('wind', 'sensor.wind'): 4.0,
|
|
('window', 'binary_sensor.w'): False, ('thermostat', 'climate.t'): ('heat', 22, None)}
|
|
noise = {**before, ('temperature', 'sensor.room'): 20.1, ('power', 'sensor.ups'): 240.0,
|
|
('wind_direction', 'sensor.dir'): 5.0, ('wind', 'sensor.wind'): 4.6}
|
|
self.assertEqual(quality.inputs_differ(before, noise), []) # 10° across north is small
|
|
for key, value in ((('temperature', 'sensor.room'), 20.5), (('power', 'sensor.ups'), 610.0),
|
|
(('wind_direction', 'sensor.dir'), 40.0), (('window', 'binary_sensor.w'), True),
|
|
(('window', 'binary_sensor.w'), None), (('thermostat', 'climate.t'), ('heat', 21, None)),
|
|
(('temperature', 'sensor.room'), None)):
|
|
with self.subTest(key=key, value=value):
|
|
self.assertEqual(quality.inputs_differ(before, {**before, key: value}), [key])
|
|
self.assertEqual(quality.inputs_differ(before, {**before, ('power', 'sensor.new'): 5.0}),
|
|
[('power', 'sensor.new')])
|
|
|
|
def test_freshness_settings_translated(self):
|
|
for name in ('strings.json', 'translations/en.json', 'translations/ro.json'):
|
|
data = json.loads((ROOT / name).read_text())
|
|
# Configure opens a menu: settings, or resetting the learned model.
|
|
options = data['options']
|
|
self.assertEqual(set(options['step']['init']['menu_options']), {'settings', 'reset_model'})
|
|
self.assertTrue(options['step']['reset_model']['data']['confirm_reset'])
|
|
self.assertEqual(set(options['abort']), {'model_reset', 'model_not_reset'})
|
|
for section, step in (('config', 'user'), ('options', 'settings')):
|
|
labels = data[section]['step'][step]['data']
|
|
for key in ('room_max_age_minutes', 'weather_max_age_minutes', 'room_stale_use_last_value'):
|
|
self.assertTrue(labels[key])
|
|
help_text = data[section]['step'][step]['data_description']
|
|
self.assertIn('24', help_text['room_stale_use_last_value'])
|
|
# Every poll outcome has a translated state name.
|
|
states = data['entity']['sensor']['last_sample']['state']
|
|
self.assertEqual(set(states), set(const.SAMPLE_RESULTS))
|