work: Добваил нагрев в автоматизацию
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import asyncio
|
||||
|
||||
from app.auto.controller import AutoController
|
||||
|
||||
|
||||
class FakeTionController:
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def heater_on(self):
|
||||
self.calls.append(
|
||||
("heater_on", None)
|
||||
)
|
||||
|
||||
async def heater_off(self):
|
||||
self.calls.append(
|
||||
("heater_off", None)
|
||||
)
|
||||
|
||||
|
||||
class FakeTionService:
|
||||
|
||||
def __init__(self):
|
||||
self.controller = (
|
||||
FakeTionController()
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
operation,
|
||||
):
|
||||
return await operation(
|
||||
self.controller
|
||||
)
|
||||
|
||||
|
||||
def make_controller():
|
||||
|
||||
tion = FakeTionService()
|
||||
|
||||
controller = AutoController(
|
||||
schedule_service=None,
|
||||
qingping_service=None,
|
||||
tion_service=tion,
|
||||
)
|
||||
|
||||
return controller, tion
|
||||
|
||||
|
||||
async def test_heater_on():
|
||||
|
||||
controller, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
await controller._set_heater(
|
||||
True
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
("heater_on", None)
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._target_heater
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
async def test_duplicate_heater_on():
|
||||
|
||||
controller, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
await controller._set_heater(
|
||||
True
|
||||
)
|
||||
|
||||
await controller._set_heater(
|
||||
True
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
("heater_on", None)
|
||||
]
|
||||
|
||||
|
||||
async def test_heater_off_after_on():
|
||||
|
||||
controller, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
await controller._set_heater(
|
||||
True
|
||||
)
|
||||
|
||||
await controller._set_heater(
|
||||
False
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
("heater_on", None),
|
||||
("heater_off", None),
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._target_heater
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
async def test_duplicate_heater_off():
|
||||
|
||||
controller, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
await controller._set_heater(
|
||||
False
|
||||
)
|
||||
|
||||
await controller._set_heater(
|
||||
False
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
("heater_off", None)
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
await test_heater_on()
|
||||
|
||||
await test_duplicate_heater_on()
|
||||
|
||||
await test_heater_off_after_on()
|
||||
|
||||
await test_duplicate_heater_off()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO HEATER COMMAND "
|
||||
"TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,304 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.auto.config import (
|
||||
TemperatureConfig,
|
||||
)
|
||||
from app.auto.controller import (
|
||||
AutoController,
|
||||
)
|
||||
from app.auto.temperature_policy import (
|
||||
TemperaturePolicy,
|
||||
)
|
||||
|
||||
|
||||
class FakeQingpingService:
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self.online = True
|
||||
|
||||
self.state = SimpleNamespace(
|
||||
temperature=19.4,
|
||||
)
|
||||
|
||||
|
||||
class FakeTionController:
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def heater_on(self):
|
||||
self.calls.append(
|
||||
("heater_on", None)
|
||||
)
|
||||
|
||||
async def heater_off(self):
|
||||
self.calls.append(
|
||||
("heater_off", None)
|
||||
)
|
||||
|
||||
|
||||
class FakeTionService:
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self.online = True
|
||||
|
||||
self.state = SimpleNamespace(
|
||||
in_temp=18,
|
||||
)
|
||||
|
||||
self.controller = (
|
||||
FakeTionController()
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
operation,
|
||||
):
|
||||
return await operation(
|
||||
self.controller
|
||||
)
|
||||
|
||||
|
||||
def make_resolution(
|
||||
target_temp=20.0,
|
||||
):
|
||||
|
||||
return SimpleNamespace(
|
||||
scheduled_settings=(
|
||||
SimpleNamespace(
|
||||
target_temp=target_temp,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def make_controller():
|
||||
|
||||
qingping = (
|
||||
FakeQingpingService()
|
||||
)
|
||||
|
||||
tion = FakeTionService()
|
||||
|
||||
policy = TemperaturePolicy(
|
||||
TemperatureConfig(
|
||||
hysteresis=0.5,
|
||||
)
|
||||
)
|
||||
|
||||
controller = AutoController(
|
||||
schedule_service=None,
|
||||
qingping_service=qingping,
|
||||
tion_service=tion,
|
||||
temperature_policy=policy,
|
||||
)
|
||||
|
||||
return (
|
||||
controller,
|
||||
qingping,
|
||||
tion,
|
||||
)
|
||||
|
||||
|
||||
async def test_qingping_heating():
|
||||
|
||||
controller, qingping, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
resolution = make_resolution()
|
||||
|
||||
# 19.4 < 19.5
|
||||
# Heater должен включиться.
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
("heater_on", None)
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._auto_heater
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._temperature
|
||||
== 19.4
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._temperature_source
|
||||
== "qingping"
|
||||
)
|
||||
|
||||
tion.controller.calls.clear()
|
||||
|
||||
# ----------------------------------------------
|
||||
# 19.8
|
||||
#
|
||||
# Heater уже ON.
|
||||
# До 20 градусов ещё не дошли.
|
||||
# Продолжаем греть.
|
||||
#
|
||||
# Новую команду отправлять не нужно.
|
||||
# ----------------------------------------------
|
||||
|
||||
qingping.state.temperature = 19.8
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
assert tion.controller.calls == []
|
||||
|
||||
assert (
|
||||
controller._auto_heater
|
||||
is True
|
||||
)
|
||||
|
||||
# ----------------------------------------------
|
||||
# 20.0
|
||||
#
|
||||
# Цель достигнута.
|
||||
# ----------------------------------------------
|
||||
|
||||
qingping.state.temperature = 20.0
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
("heater_off", None)
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._auto_heater
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
async def test_tion_temperature_fallback():
|
||||
|
||||
controller, qingping, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
resolution = make_resolution()
|
||||
|
||||
# Qingping отключился.
|
||||
qingping.online = False
|
||||
|
||||
# Используем Tion.
|
||||
tion.state.in_temp = 19
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
("heater_on", None)
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._temperature
|
||||
== 19
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._temperature_source
|
||||
== "tion"
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._auto_heater
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
async def test_no_temperature():
|
||||
|
||||
controller, qingping, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
resolution = make_resolution()
|
||||
|
||||
qingping.online = False
|
||||
tion.online = False
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
("heater_off", None)
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._temperature
|
||||
is None
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._temperature_source
|
||||
is None
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._auto_heater
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
async def test_target_temperature_missing():
|
||||
|
||||
controller, qingping, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
resolution = make_resolution(
|
||||
target_temp=None
|
||||
)
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
("heater_off", None)
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._auto_heater
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
await test_qingping_heating()
|
||||
|
||||
await test_tion_temperature_fallback()
|
||||
|
||||
await test_no_temperature()
|
||||
|
||||
await test_target_temperature_missing()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO HEATER PROCESS "
|
||||
"TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,138 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.auto.config import (
|
||||
TemperatureConfig,
|
||||
)
|
||||
from app.auto.controller import (
|
||||
AutoController,
|
||||
)
|
||||
from app.auto.temperature_policy import (
|
||||
TemperaturePolicy,
|
||||
)
|
||||
|
||||
|
||||
class FakeQingpingService:
|
||||
|
||||
def __init__(self):
|
||||
self.online = True
|
||||
|
||||
self.state = SimpleNamespace(
|
||||
temperature=19.5,
|
||||
)
|
||||
|
||||
|
||||
class FakeTionController:
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def heater_on(self):
|
||||
self.calls.append(
|
||||
("heater_on", None)
|
||||
)
|
||||
|
||||
async def heater_off(self):
|
||||
self.calls.append(
|
||||
("heater_off", None)
|
||||
)
|
||||
|
||||
|
||||
class FakeTionService:
|
||||
|
||||
def __init__(self):
|
||||
self.online = True
|
||||
|
||||
self.state = SimpleNamespace(
|
||||
in_temp=19,
|
||||
)
|
||||
|
||||
self.controller = (
|
||||
FakeTionController()
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
operation,
|
||||
):
|
||||
return await operation(
|
||||
self.controller
|
||||
)
|
||||
|
||||
|
||||
async def test_auto_uses_auto_target_temperature():
|
||||
|
||||
qingping = FakeQingpingService()
|
||||
tion = FakeTionService()
|
||||
|
||||
policy = TemperaturePolicy(
|
||||
TemperatureConfig(
|
||||
hysteresis=0.5,
|
||||
)
|
||||
)
|
||||
|
||||
controller = AutoController(
|
||||
schedule_service=None,
|
||||
qingping_service=qingping,
|
||||
tion_service=tion,
|
||||
temperature_policy=policy,
|
||||
)
|
||||
|
||||
resolution = SimpleNamespace(
|
||||
# Последний SET хотел 23°C.
|
||||
scheduled_settings=SimpleNamespace(
|
||||
target_temp=23,
|
||||
),
|
||||
|
||||
# Но текущий AUTO явно хочет 20°C.
|
||||
auto_target_temp=20,
|
||||
)
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
# При 19.5°C и target=20°C:
|
||||
#
|
||||
# heater был OFF,
|
||||
# нижняя граница = 19.5°C.
|
||||
#
|
||||
# Поэтому heater должен остаться OFF.
|
||||
#
|
||||
# Если бы контроллер ошибочно использовал
|
||||
# SET target_temp=23, он бы включил heater.
|
||||
assert tion.controller.calls == [
|
||||
("heater_off", None)
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._auto_heater
|
||||
is False
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._temperature
|
||||
== 19.5
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._temperature_source
|
||||
== "qingping"
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
await test_auto_uses_auto_target_temperature()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO HEATER TARGET "
|
||||
"TEMPERATURE TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,245 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.auto.config import TemperatureConfig
|
||||
from app.auto.controller import AutoController
|
||||
from app.auto.temperature_policy import TemperaturePolicy
|
||||
from app.my_dataclasses import MAX_TARGET_TEMP
|
||||
|
||||
|
||||
class FakeQingpingService:
|
||||
|
||||
def __init__(self):
|
||||
self.online = True
|
||||
|
||||
self.state = SimpleNamespace(
|
||||
temperature=19.0,
|
||||
)
|
||||
|
||||
|
||||
class FakeTionController:
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def set_target_temperature(
|
||||
self,
|
||||
temperature: int,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"set_target_temperature",
|
||||
temperature,
|
||||
)
|
||||
)
|
||||
|
||||
async def heater_on(self):
|
||||
self.calls.append(
|
||||
(
|
||||
"heater_on",
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
async def heater_off(self):
|
||||
self.calls.append(
|
||||
(
|
||||
"heater_off",
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class FakeTionService:
|
||||
|
||||
def __init__(self):
|
||||
self.online = True
|
||||
|
||||
self.state = SimpleNamespace(
|
||||
in_temp=22,
|
||||
)
|
||||
|
||||
self.controller = FakeTionController()
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
operation,
|
||||
):
|
||||
return await operation(
|
||||
self.controller
|
||||
)
|
||||
|
||||
|
||||
def make_controller():
|
||||
|
||||
qingping = FakeQingpingService()
|
||||
tion = FakeTionService()
|
||||
|
||||
temperature_policy = TemperaturePolicy(
|
||||
TemperatureConfig(
|
||||
hysteresis=0.5,
|
||||
)
|
||||
)
|
||||
|
||||
controller = AutoController(
|
||||
schedule_service=None,
|
||||
qingping_service=qingping,
|
||||
tion_service=tion,
|
||||
temperature_policy=temperature_policy,
|
||||
)
|
||||
|
||||
return (
|
||||
controller,
|
||||
qingping,
|
||||
tion,
|
||||
)
|
||||
|
||||
|
||||
def make_resolution(
|
||||
target_temp: int,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
auto_target_temp=target_temp,
|
||||
)
|
||||
|
||||
|
||||
async def test_heating_sets_tion_target_first():
|
||||
|
||||
controller, qingping, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
resolution = make_resolution(
|
||||
target_temp=20
|
||||
)
|
||||
|
||||
# Qingping = 19.0
|
||||
# AUTO target = 20
|
||||
#
|
||||
# Нужно греть.
|
||||
#
|
||||
# Сначала Tion.target_temp поднимается
|
||||
# до технического максимума,
|
||||
# затем включается heater.
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
assert tion.controller.calls == [
|
||||
(
|
||||
"set_target_temperature",
|
||||
MAX_TARGET_TEMP,
|
||||
),
|
||||
(
|
||||
"heater_on",
|
||||
None,
|
||||
),
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._target_temperature
|
||||
== MAX_TARGET_TEMP
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._target_heater
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._auto_heater
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
async def test_repeated_heating_does_not_repeat_commands():
|
||||
|
||||
controller, qingping, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
resolution = make_resolution(
|
||||
target_temp=20
|
||||
)
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
tion.controller.calls.clear()
|
||||
|
||||
# Температура всё ещё ниже цели.
|
||||
# AUTO продолжает хотеть нагрев,
|
||||
# но повторно слать команды Tion не нужно.
|
||||
qingping.state.temperature = 19.2
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
assert tion.controller.calls == []
|
||||
|
||||
|
||||
async def test_target_reached_turns_heater_off():
|
||||
|
||||
controller, qingping, tion = (
|
||||
make_controller()
|
||||
)
|
||||
|
||||
resolution = make_resolution(
|
||||
target_temp=20
|
||||
)
|
||||
|
||||
# Сначала включаем нагрев.
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
tion.controller.calls.clear()
|
||||
|
||||
# Цель достигнута.
|
||||
qingping.state.temperature = 20.0
|
||||
|
||||
await controller._process_heater(
|
||||
resolution
|
||||
)
|
||||
|
||||
# MAX_TARGET_TEMP обратно сейчас не меняем.
|
||||
# Достаточно запретить нагрев.
|
||||
assert tion.controller.calls == [
|
||||
(
|
||||
"heater_off",
|
||||
None,
|
||||
)
|
||||
]
|
||||
|
||||
assert (
|
||||
controller._auto_heater
|
||||
is False
|
||||
)
|
||||
|
||||
assert (
|
||||
controller._target_heater
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
await test_heating_sets_tion_target_first()
|
||||
|
||||
await test_repeated_heating_does_not_repeat_commands()
|
||||
|
||||
await test_target_reached_turns_heater_off()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO HEATER TION TARGET "
|
||||
"TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,359 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from app.auto.config import (
|
||||
load_auto_config,
|
||||
)
|
||||
|
||||
|
||||
BASE_CONFIG = """
|
||||
version: 1
|
||||
|
||||
check_interval: 5.0
|
||||
|
||||
co2:
|
||||
base_speed: 1
|
||||
hysteresis: 100
|
||||
|
||||
thresholds:
|
||||
- ppm: 800
|
||||
speed: 2
|
||||
|
||||
temperature:
|
||||
hysteresis: 0.5
|
||||
"""
|
||||
|
||||
|
||||
def load_config(text: str):
|
||||
temp_dir = TemporaryDirectory()
|
||||
|
||||
path = (
|
||||
Path(temp_dir.name)
|
||||
/ "auto.yaml"
|
||||
)
|
||||
|
||||
path.write_text(
|
||||
text,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_auto_config(path)
|
||||
|
||||
return temp_dir, config
|
||||
|
||||
|
||||
def test_valid_temperature_config():
|
||||
|
||||
temp_dir, config = load_config(
|
||||
BASE_CONFIG
|
||||
)
|
||||
|
||||
try:
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("VALID TEMPERATURE CONFIG")
|
||||
print("=" * 70)
|
||||
|
||||
print(
|
||||
"hysteresis:",
|
||||
config.temperature.hysteresis,
|
||||
)
|
||||
|
||||
assert (
|
||||
config.temperature.hysteresis
|
||||
== 0.5
|
||||
)
|
||||
|
||||
finally:
|
||||
temp_dir.cleanup()
|
||||
|
||||
|
||||
def test_temperature_config_required():
|
||||
|
||||
config_text = """
|
||||
version: 1
|
||||
|
||||
check_interval: 5.0
|
||||
|
||||
co2:
|
||||
base_speed: 1
|
||||
hysteresis: 100
|
||||
|
||||
thresholds:
|
||||
- ppm: 800
|
||||
speed: 2
|
||||
"""
|
||||
|
||||
try:
|
||||
temp_dir, _ = load_config(
|
||||
config_text
|
||||
)
|
||||
|
||||
except ValueError as exc:
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("MISSING TEMPERATURE CONFIG")
|
||||
print("=" * 70)
|
||||
print(exc)
|
||||
|
||||
assert (
|
||||
str(exc)
|
||||
== "temperature config is required"
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
else:
|
||||
temp_dir.cleanup()
|
||||
|
||||
raise AssertionError(
|
||||
"Missing temperature config "
|
||||
"must raise ValueError"
|
||||
)
|
||||
|
||||
|
||||
def test_temperature_must_be_mapping():
|
||||
|
||||
config_text = """
|
||||
version: 1
|
||||
|
||||
check_interval: 5.0
|
||||
|
||||
co2:
|
||||
base_speed: 1
|
||||
hysteresis: 100
|
||||
|
||||
thresholds:
|
||||
- ppm: 800
|
||||
speed: 2
|
||||
|
||||
temperature: 0.5
|
||||
"""
|
||||
|
||||
try:
|
||||
temp_dir, _ = load_config(
|
||||
config_text
|
||||
)
|
||||
|
||||
except ValueError as exc:
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("TEMPERATURE MUST BE MAPPING")
|
||||
print("=" * 70)
|
||||
print(exc)
|
||||
|
||||
assert (
|
||||
str(exc)
|
||||
== "temperature must be an object"
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
else:
|
||||
temp_dir.cleanup()
|
||||
|
||||
raise AssertionError(
|
||||
"Invalid temperature mapping "
|
||||
"must raise ValueError"
|
||||
)
|
||||
|
||||
|
||||
def test_temperature_hysteresis_must_be_number():
|
||||
|
||||
config_text = """
|
||||
version: 1
|
||||
|
||||
check_interval: 5.0
|
||||
|
||||
co2:
|
||||
base_speed: 1
|
||||
hysteresis: 100
|
||||
|
||||
thresholds:
|
||||
- ppm: 800
|
||||
speed: 2
|
||||
|
||||
temperature:
|
||||
hysteresis: abc
|
||||
"""
|
||||
|
||||
try:
|
||||
temp_dir, _ = load_config(
|
||||
config_text
|
||||
)
|
||||
|
||||
except ValueError as exc:
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"HYSTERESIS MUST BE NUMBER"
|
||||
)
|
||||
print("=" * 70)
|
||||
print(exc)
|
||||
|
||||
assert (
|
||||
str(exc)
|
||||
== (
|
||||
"temperature.hysteresis "
|
||||
"must be a number"
|
||||
)
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
else:
|
||||
temp_dir.cleanup()
|
||||
|
||||
raise AssertionError(
|
||||
"Non-numeric hysteresis "
|
||||
"must raise ValueError"
|
||||
)
|
||||
|
||||
|
||||
def test_temperature_hysteresis_must_not_be_negative():
|
||||
|
||||
config_text = """
|
||||
version: 1
|
||||
|
||||
check_interval: 5.0
|
||||
|
||||
co2:
|
||||
base_speed: 1
|
||||
hysteresis: 100
|
||||
|
||||
thresholds:
|
||||
- ppm: 800
|
||||
speed: 2
|
||||
|
||||
temperature:
|
||||
hysteresis: -0.1
|
||||
"""
|
||||
|
||||
try:
|
||||
temp_dir, _ = load_config(
|
||||
config_text
|
||||
)
|
||||
|
||||
except ValueError as exc:
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"HYSTERESIS MUST NOT BE NEGATIVE"
|
||||
)
|
||||
print("=" * 70)
|
||||
print(exc)
|
||||
|
||||
assert (
|
||||
str(exc)
|
||||
== (
|
||||
"temperature.hysteresis "
|
||||
"must be >= 0"
|
||||
)
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
else:
|
||||
temp_dir.cleanup()
|
||||
|
||||
raise AssertionError(
|
||||
"Negative hysteresis "
|
||||
"must raise ValueError"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_temperature_field():
|
||||
|
||||
config_text = """
|
||||
version: 1
|
||||
|
||||
check_interval: 5.0
|
||||
|
||||
co2:
|
||||
base_speed: 1
|
||||
hysteresis: 100
|
||||
|
||||
thresholds:
|
||||
- ppm: 800
|
||||
speed: 2
|
||||
|
||||
temperature:
|
||||
hysteresis: 0.5
|
||||
something_else: 123
|
||||
"""
|
||||
|
||||
try:
|
||||
temp_dir, _ = load_config(
|
||||
config_text
|
||||
)
|
||||
|
||||
except ValueError as exc:
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"UNKNOWN TEMPERATURE FIELD"
|
||||
)
|
||||
print("=" * 70)
|
||||
print(exc)
|
||||
|
||||
assert (
|
||||
str(exc)
|
||||
== (
|
||||
"Unknown temperature config fields: "
|
||||
"['something_else']"
|
||||
)
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
else:
|
||||
temp_dir.cleanup()
|
||||
|
||||
raise AssertionError(
|
||||
"Unknown temperature field "
|
||||
"must raise ValueError"
|
||||
)
|
||||
|
||||
|
||||
def test_temperature_is_allowed_top_level_field():
|
||||
|
||||
temp_dir, config = load_config(
|
||||
BASE_CONFIG
|
||||
)
|
||||
|
||||
try:
|
||||
assert (
|
||||
config.temperature.hysteresis
|
||||
== 0.5
|
||||
)
|
||||
|
||||
finally:
|
||||
temp_dir.cleanup()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
test_valid_temperature_config()
|
||||
test_temperature_config_required()
|
||||
test_temperature_must_be_mapping()
|
||||
test_temperature_hysteresis_must_be_number()
|
||||
test_temperature_hysteresis_must_not_be_negative()
|
||||
test_unknown_temperature_field()
|
||||
test_temperature_is_allowed_top_level_field()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO TEMPERATURE CONFIG "
|
||||
"TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
from app.auto.config import (
|
||||
TemperatureConfig,
|
||||
)
|
||||
from app.auto.temperature_policy import (
|
||||
TemperaturePolicy,
|
||||
)
|
||||
|
||||
|
||||
def make_policy() -> TemperaturePolicy:
|
||||
|
||||
return TemperaturePolicy(
|
||||
TemperatureConfig(
|
||||
hysteresis=0.5,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_heater_off_below_lower_limit():
|
||||
|
||||
policy = make_policy()
|
||||
|
||||
result = policy.heater_required(
|
||||
temperature=19.4,
|
||||
target_temp=20.0,
|
||||
heater_on=False,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_heater_off_at_lower_limit():
|
||||
|
||||
policy = make_policy()
|
||||
|
||||
result = policy.heater_required(
|
||||
temperature=19.5,
|
||||
target_temp=20.0,
|
||||
heater_on=False,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_heater_off_inside_hysteresis():
|
||||
|
||||
policy = make_policy()
|
||||
|
||||
result = policy.heater_required(
|
||||
temperature=19.8,
|
||||
target_temp=20.0,
|
||||
heater_on=False,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_heater_on_below_target():
|
||||
|
||||
policy = make_policy()
|
||||
|
||||
result = policy.heater_required(
|
||||
temperature=19.8,
|
||||
target_temp=20.0,
|
||||
heater_on=True,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_heater_on_at_target():
|
||||
|
||||
policy = make_policy()
|
||||
|
||||
result = policy.heater_required(
|
||||
temperature=20.0,
|
||||
target_temp=20.0,
|
||||
heater_on=True,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_heater_on_above_target():
|
||||
|
||||
policy = make_policy()
|
||||
|
||||
result = policy.heater_required(
|
||||
temperature=20.2,
|
||||
target_temp=20.0,
|
||||
heater_on=True,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
test_heater_off_below_lower_limit()
|
||||
test_heater_off_at_lower_limit()
|
||||
test_heater_off_inside_hysteresis()
|
||||
|
||||
test_heater_on_below_target()
|
||||
test_heater_on_at_target()
|
||||
test_heater_on_above_target()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO TEMPERATURE POLICY "
|
||||
"TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,169 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.auto.controller import AutoController
|
||||
|
||||
|
||||
class FakeQingpingService:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
online: bool,
|
||||
temperature,
|
||||
):
|
||||
self.online = online
|
||||
|
||||
self.state = SimpleNamespace(
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
|
||||
class FakeTionService:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
online: bool,
|
||||
in_temp,
|
||||
):
|
||||
self.online = online
|
||||
|
||||
self.state = SimpleNamespace(
|
||||
in_temp=in_temp,
|
||||
)
|
||||
|
||||
|
||||
def make_controller(
|
||||
*,
|
||||
qingping_online: bool,
|
||||
qingping_temperature,
|
||||
tion_online: bool,
|
||||
tion_temperature,
|
||||
):
|
||||
|
||||
qingping = FakeQingpingService(
|
||||
online=qingping_online,
|
||||
temperature=qingping_temperature,
|
||||
)
|
||||
|
||||
tion = FakeTionService(
|
||||
online=tion_online,
|
||||
in_temp=tion_temperature,
|
||||
)
|
||||
|
||||
return AutoController(
|
||||
schedule_service=None,
|
||||
qingping_service=qingping,
|
||||
tion_service=tion,
|
||||
)
|
||||
|
||||
|
||||
def test_qingping_priority():
|
||||
|
||||
controller = make_controller(
|
||||
qingping_online=True,
|
||||
qingping_temperature=19.7,
|
||||
tion_online=True,
|
||||
tion_temperature=18,
|
||||
)
|
||||
|
||||
temperature, source = (
|
||||
controller._get_temperature()
|
||||
)
|
||||
|
||||
assert temperature == 19.7
|
||||
assert source == "qingping"
|
||||
|
||||
|
||||
def test_tion_fallback():
|
||||
|
||||
controller = make_controller(
|
||||
qingping_online=False,
|
||||
qingping_temperature=19.7,
|
||||
tion_online=True,
|
||||
tion_temperature=18,
|
||||
)
|
||||
|
||||
temperature, source = (
|
||||
controller._get_temperature()
|
||||
)
|
||||
|
||||
assert temperature == 18
|
||||
assert source == "tion"
|
||||
|
||||
|
||||
def test_tion_fallback_when_qingping_temperature_missing():
|
||||
|
||||
controller = make_controller(
|
||||
qingping_online=True,
|
||||
qingping_temperature=None,
|
||||
tion_online=True,
|
||||
tion_temperature=18,
|
||||
)
|
||||
|
||||
temperature, source = (
|
||||
controller._get_temperature()
|
||||
)
|
||||
|
||||
assert temperature == 18
|
||||
assert source == "tion"
|
||||
|
||||
|
||||
def test_offline_tion_is_not_used():
|
||||
|
||||
controller = make_controller(
|
||||
qingping_online=False,
|
||||
qingping_temperature=None,
|
||||
tion_online=False,
|
||||
|
||||
# Значение специально оставляем.
|
||||
# Оно имитирует старый state Tion.
|
||||
tion_temperature=18,
|
||||
)
|
||||
|
||||
temperature, source = (
|
||||
controller._get_temperature()
|
||||
)
|
||||
|
||||
assert temperature is None
|
||||
assert source is None
|
||||
|
||||
|
||||
def test_missing_tion_temperature():
|
||||
|
||||
controller = make_controller(
|
||||
qingping_online=False,
|
||||
qingping_temperature=None,
|
||||
tion_online=True,
|
||||
tion_temperature=None,
|
||||
)
|
||||
|
||||
temperature, source = (
|
||||
controller._get_temperature()
|
||||
)
|
||||
|
||||
assert temperature is None
|
||||
assert source is None
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
test_qingping_priority()
|
||||
|
||||
test_tion_fallback()
|
||||
|
||||
test_tion_fallback_when_qingping_temperature_missing()
|
||||
|
||||
test_offline_tion_is_not_used()
|
||||
|
||||
test_missing_tion_temperature()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO TEMPERATURE SOURCE "
|
||||
"TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,327 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from schedule import (
|
||||
ScheduleService,
|
||||
load_schedule,
|
||||
)
|
||||
|
||||
|
||||
SCHEDULE_YAML = """
|
||||
version: 1
|
||||
|
||||
enabled: true
|
||||
timezone: local
|
||||
|
||||
templates:
|
||||
|
||||
test:
|
||||
|
||||
- time: "00:00"
|
||||
action:
|
||||
type: set
|
||||
power: off
|
||||
|
||||
- time: "09:00"
|
||||
action:
|
||||
type: set
|
||||
power: on
|
||||
speed: 1
|
||||
heater: on
|
||||
target_temp: 20
|
||||
|
||||
- time: "10:00"
|
||||
action:
|
||||
type: auto
|
||||
speed: 3
|
||||
|
||||
- time: "12:00"
|
||||
action:
|
||||
type: set
|
||||
speed: 2
|
||||
heater: off
|
||||
|
||||
days:
|
||||
mon: test
|
||||
tue: test
|
||||
wed: test
|
||||
thu: test
|
||||
fri: test
|
||||
sat: test
|
||||
sun: test
|
||||
"""
|
||||
|
||||
|
||||
class FakeTionController:
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def power_on(self):
|
||||
self.calls.append(
|
||||
("power_on", None)
|
||||
)
|
||||
|
||||
async def power_off(self):
|
||||
self.calls.append(
|
||||
("power_off", None)
|
||||
)
|
||||
|
||||
async def set_speed(self, speed: int):
|
||||
self.calls.append(
|
||||
("set_speed", speed)
|
||||
)
|
||||
|
||||
# Оставляем оба варианта управления heater,
|
||||
# чтобы fake соответствовал фактическому
|
||||
# интерфейсу TionController.
|
||||
async def heater_on(self):
|
||||
self.calls.append(
|
||||
("heater_on", None)
|
||||
)
|
||||
|
||||
async def heater_off(self):
|
||||
self.calls.append(
|
||||
("heater_off", None)
|
||||
)
|
||||
|
||||
async def set_heater(self, enabled: bool):
|
||||
self.calls.append(
|
||||
("set_heater", enabled)
|
||||
)
|
||||
|
||||
async def set_target_temperature(
|
||||
self,
|
||||
temperature: int,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"set_target_temperature",
|
||||
temperature,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class FakeTionService:
|
||||
|
||||
def __init__(self):
|
||||
self.controller = FakeTionController()
|
||||
|
||||
async def execute(self, operation):
|
||||
return await operation(
|
||||
self.controller
|
||||
)
|
||||
|
||||
|
||||
def load_test_schedule():
|
||||
temp_dir = TemporaryDirectory()
|
||||
|
||||
path = (
|
||||
Path(temp_dir.name)
|
||||
/ "schedule.yaml"
|
||||
)
|
||||
|
||||
path.write_text(
|
||||
SCHEDULE_YAML,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_schedule(path)
|
||||
|
||||
return temp_dir, config
|
||||
|
||||
|
||||
def heater_calls(calls):
|
||||
return [
|
||||
call
|
||||
for call in calls
|
||||
if call[0] in {
|
||||
"heater_on",
|
||||
"heater_off",
|
||||
"set_heater",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def process_at(
|
||||
service: ScheduleService,
|
||||
when: datetime,
|
||||
):
|
||||
with patch(
|
||||
"schedule.service.datetime",
|
||||
wraps=datetime,
|
||||
) as mocked_datetime:
|
||||
|
||||
mocked_datetime.now.return_value = when
|
||||
|
||||
await service._process()
|
||||
|
||||
|
||||
async def test_auto_does_not_control_heater():
|
||||
|
||||
temp_dir, config = (
|
||||
load_test_schedule()
|
||||
)
|
||||
|
||||
try:
|
||||
tion = FakeTionService()
|
||||
|
||||
service = ScheduleService(
|
||||
config,
|
||||
tion=tion,
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# 09:30
|
||||
#
|
||||
# Обычный SET.
|
||||
# heater=on должен принадлежать ScheduleService.
|
||||
# --------------------------------------------------
|
||||
|
||||
await process_at(
|
||||
service,
|
||||
datetime(
|
||||
2026,
|
||||
9,
|
||||
14,
|
||||
9,
|
||||
30,
|
||||
),
|
||||
)
|
||||
|
||||
print()
|
||||
print(
|
||||
"09:30 SET calls:",
|
||||
tion.controller.calls,
|
||||
)
|
||||
|
||||
calls = heater_calls(
|
||||
tion.controller.calls
|
||||
)
|
||||
|
||||
assert calls != [], (
|
||||
"SET must control heater"
|
||||
)
|
||||
|
||||
tion.controller.calls.clear()
|
||||
|
||||
# --------------------------------------------------
|
||||
# 10:30
|
||||
#
|
||||
# AUTO.
|
||||
#
|
||||
# В накопленных scheduled_settings heater всё ещё
|
||||
# должен быть True, потому что последняя SET-точка
|
||||
# включила heater.
|
||||
#
|
||||
# Но ScheduleService НЕ должен отправлять
|
||||
# никаких heater-команд.
|
||||
# --------------------------------------------------
|
||||
|
||||
resolution = service.resolve(
|
||||
datetime(
|
||||
2026,
|
||||
9,
|
||||
14,
|
||||
10,
|
||||
30,
|
||||
)
|
||||
)
|
||||
|
||||
print()
|
||||
print(
|
||||
"10:30 scheduled heater:",
|
||||
resolution.scheduled_settings.heater,
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.auto_active
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.scheduled_settings.heater
|
||||
is True
|
||||
)
|
||||
|
||||
await process_at(
|
||||
service,
|
||||
datetime(
|
||||
2026,
|
||||
9,
|
||||
14,
|
||||
10,
|
||||
30,
|
||||
),
|
||||
)
|
||||
|
||||
print(
|
||||
"10:30 AUTO calls:",
|
||||
tion.controller.calls,
|
||||
)
|
||||
|
||||
calls = heater_calls(
|
||||
tion.controller.calls
|
||||
)
|
||||
|
||||
assert calls == [], (
|
||||
"ScheduleService must not control "
|
||||
"heater during AUTO"
|
||||
)
|
||||
|
||||
tion.controller.calls.clear()
|
||||
|
||||
# --------------------------------------------------
|
||||
# 12:30
|
||||
#
|
||||
# AUTO закончился.
|
||||
# heater=off снова принадлежит ScheduleService.
|
||||
# --------------------------------------------------
|
||||
|
||||
await process_at(
|
||||
service,
|
||||
datetime(
|
||||
2026,
|
||||
9,
|
||||
14,
|
||||
12,
|
||||
30,
|
||||
),
|
||||
)
|
||||
|
||||
print()
|
||||
print(
|
||||
"12:30 SET calls:",
|
||||
tion.controller.calls,
|
||||
)
|
||||
|
||||
calls = heater_calls(
|
||||
tion.controller.calls
|
||||
)
|
||||
|
||||
assert calls != [], (
|
||||
"ScheduleService must regain heater "
|
||||
"control after AUTO"
|
||||
)
|
||||
|
||||
finally:
|
||||
temp_dir.cleanup()
|
||||
|
||||
|
||||
async def main():
|
||||
|
||||
await test_auto_does_not_control_heater()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO HEATER OWNERSHIP TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,217 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from schedule import (
|
||||
ScheduleActionType,
|
||||
load_schedule,
|
||||
)
|
||||
|
||||
|
||||
VALID_YAML = """
|
||||
version: 1
|
||||
|
||||
enabled: true
|
||||
timezone: local
|
||||
|
||||
templates:
|
||||
|
||||
test:
|
||||
|
||||
- time: "00:00"
|
||||
action:
|
||||
type: auto
|
||||
speed: 3
|
||||
target_temp: 20
|
||||
|
||||
days:
|
||||
mon: test
|
||||
tue: test
|
||||
wed: test
|
||||
thu: test
|
||||
fri: test
|
||||
sat: test
|
||||
sun: test
|
||||
"""
|
||||
|
||||
|
||||
def load_config(text: str):
|
||||
|
||||
temp_dir = TemporaryDirectory()
|
||||
|
||||
path = (
|
||||
Path(temp_dir.name)
|
||||
/ "schedule.yaml"
|
||||
)
|
||||
|
||||
path.write_text(
|
||||
text,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_schedule(path)
|
||||
|
||||
return temp_dir, config
|
||||
|
||||
|
||||
def test_auto_target_temperature():
|
||||
|
||||
temp_dir, config = load_config(
|
||||
VALID_YAML
|
||||
)
|
||||
|
||||
try:
|
||||
point = (
|
||||
config.templates["test"][0]
|
||||
)
|
||||
|
||||
action = point.action
|
||||
|
||||
assert (
|
||||
action.type
|
||||
== ScheduleActionType.AUTO
|
||||
)
|
||||
|
||||
assert (
|
||||
action.settings.speed
|
||||
== 3
|
||||
)
|
||||
|
||||
assert (
|
||||
action.settings.target_temp
|
||||
== 20
|
||||
)
|
||||
|
||||
finally:
|
||||
temp_dir.cleanup()
|
||||
|
||||
|
||||
def test_auto_rejects_heater():
|
||||
|
||||
config_text = """
|
||||
version: 1
|
||||
|
||||
enabled: true
|
||||
timezone: local
|
||||
|
||||
templates:
|
||||
|
||||
test:
|
||||
|
||||
- time: "00:00"
|
||||
action:
|
||||
type: auto
|
||||
speed: 3
|
||||
target_temp: 20
|
||||
heater: on
|
||||
|
||||
days:
|
||||
mon: test
|
||||
tue: test
|
||||
wed: test
|
||||
thu: test
|
||||
fri: test
|
||||
sat: test
|
||||
sun: test
|
||||
"""
|
||||
|
||||
try:
|
||||
temp_dir, _ = load_config(
|
||||
config_text
|
||||
)
|
||||
|
||||
except ValueError as exc:
|
||||
|
||||
print()
|
||||
print(
|
||||
"Expected error:",
|
||||
exc,
|
||||
)
|
||||
|
||||
assert "heater" in str(exc)
|
||||
|
||||
return
|
||||
|
||||
else:
|
||||
temp_dir.cleanup()
|
||||
|
||||
raise AssertionError(
|
||||
"AUTO heater field must "
|
||||
"raise ValueError"
|
||||
)
|
||||
|
||||
|
||||
def test_auto_speed_is_required():
|
||||
|
||||
config_text = """
|
||||
version: 1
|
||||
|
||||
enabled: true
|
||||
timezone: local
|
||||
|
||||
templates:
|
||||
|
||||
test:
|
||||
|
||||
- time: "00:00"
|
||||
action:
|
||||
type: auto
|
||||
target_temp: 20
|
||||
|
||||
days:
|
||||
mon: test
|
||||
tue: test
|
||||
wed: test
|
||||
thu: test
|
||||
fri: test
|
||||
sat: test
|
||||
sun: test
|
||||
"""
|
||||
|
||||
try:
|
||||
temp_dir, _ = load_config(
|
||||
config_text
|
||||
)
|
||||
|
||||
except ValueError as exc:
|
||||
|
||||
print()
|
||||
print(
|
||||
"Expected error:",
|
||||
exc,
|
||||
)
|
||||
|
||||
assert (
|
||||
str(exc)
|
||||
== "AUTO action requires 'speed'"
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
else:
|
||||
temp_dir.cleanup()
|
||||
|
||||
raise AssertionError(
|
||||
"AUTO without speed must "
|
||||
"raise ValueError"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
test_auto_target_temperature()
|
||||
|
||||
test_auto_rejects_heater()
|
||||
|
||||
test_auto_speed_is_required()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO TEMPERATURE PARSER "
|
||||
"TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,196 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from schedule import (
|
||||
ScheduleService,
|
||||
load_schedule,
|
||||
)
|
||||
|
||||
|
||||
SCHEDULE_YAML = """
|
||||
version: 1
|
||||
|
||||
enabled: true
|
||||
timezone: local
|
||||
|
||||
templates:
|
||||
|
||||
test:
|
||||
|
||||
- time: "09:00"
|
||||
action:
|
||||
type: set
|
||||
power: on
|
||||
speed: 1
|
||||
target_temp: 23
|
||||
|
||||
- time: "10:00"
|
||||
action:
|
||||
type: auto
|
||||
speed: 3
|
||||
target_temp: 20
|
||||
|
||||
- time: "12:00"
|
||||
action:
|
||||
type: set
|
||||
speed: 2
|
||||
target_temp: 22
|
||||
|
||||
days:
|
||||
mon: test
|
||||
tue: test
|
||||
wed: test
|
||||
thu: test
|
||||
fri: test
|
||||
sat: test
|
||||
sun: test
|
||||
"""
|
||||
|
||||
|
||||
def load_test_schedule():
|
||||
|
||||
temp_dir = TemporaryDirectory()
|
||||
|
||||
path = (
|
||||
Path(temp_dir.name)
|
||||
/ "schedule.yaml"
|
||||
)
|
||||
|
||||
path.write_text(
|
||||
SCHEDULE_YAML,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_schedule(path)
|
||||
|
||||
return temp_dir, config
|
||||
|
||||
|
||||
def test_auto_temperature_resolution():
|
||||
|
||||
temp_dir, config = (
|
||||
load_test_schedule()
|
||||
)
|
||||
|
||||
try:
|
||||
service = ScheduleService(
|
||||
config
|
||||
)
|
||||
|
||||
# ----------------------------------------------
|
||||
# 09:30 — обычный SET
|
||||
# ----------------------------------------------
|
||||
|
||||
resolution = service.resolve(
|
||||
datetime(
|
||||
2026,
|
||||
9,
|
||||
14,
|
||||
9,
|
||||
30,
|
||||
)
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.auto_active
|
||||
is False
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.scheduled_settings.target_temp
|
||||
== 23
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.auto_target_temp
|
||||
is None
|
||||
)
|
||||
|
||||
# ----------------------------------------------
|
||||
# 10:30 — AUTO
|
||||
#
|
||||
# Последний SET по-прежнему хранит 23,
|
||||
# но AUTO явно требует 20.
|
||||
# ----------------------------------------------
|
||||
|
||||
resolution = service.resolve(
|
||||
datetime(
|
||||
2026,
|
||||
9,
|
||||
14,
|
||||
10,
|
||||
30,
|
||||
)
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.auto_active
|
||||
is True
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.auto_fallback_speed
|
||||
== 3
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.scheduled_settings.target_temp
|
||||
== 23
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.auto_target_temp
|
||||
== 20
|
||||
)
|
||||
|
||||
# ----------------------------------------------
|
||||
# 12:30 — снова SET
|
||||
#
|
||||
# AUTO закончился.
|
||||
# ----------------------------------------------
|
||||
|
||||
resolution = service.resolve(
|
||||
datetime(
|
||||
2026,
|
||||
9,
|
||||
14,
|
||||
12,
|
||||
30,
|
||||
)
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.auto_active
|
||||
is False
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.scheduled_settings.target_temp
|
||||
== 22
|
||||
)
|
||||
|
||||
assert (
|
||||
resolution.auto_target_temp
|
||||
is None
|
||||
)
|
||||
|
||||
finally:
|
||||
temp_dir.cleanup()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
test_auto_temperature_resolution()
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(
|
||||
"ALL AUTO TEMPERATURE RESOLUTION "
|
||||
"TESTS PASSED"
|
||||
)
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user