[time] Add tests for on_time schedules and time jump handling (#21793)

This commit is contained in:
J. Nick Koston authored and GitHub committed 2026-10-29 11:06:39 -04:00
1 parent 5d2772c216
commit 2d41bd5cea
5 files changed
+471 -1

No files matched your search

@@ -0,0 +1,54 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
logger:
time:
- platform: sntp
id: sntp_time
timezone: UTC0
on_time:
- seconds: 0
minutes: /5
then:
- logger.log: every_five_minutes
- cron: "0 30 7 * * MON-FRI"
then:
- logger.log: weekday_morning
- at: "07:30"
then:
- logger.log: at_time
- seconds: 0
minutes: 0
hours: 8-17
then:
- logger.log: working_hours
- seconds: 0
minutes: 0
hours: 12
days_of_month: 1,15
months: JAN,JUL
then:
- logger.log: twice_a_year
- seconds: 0
minutes: 0
hours: 0
days_of_month: "*/7"
then:
- logger.log: weekly_by_date
- seconds: /15
then:
- logger.log: quarter_minute
- seconds: 0
days_of_week: SAT,SUN
then:
- logger.log: weekend
+211
View File
@@ -0,0 +1,211 @@
"""on_time: the values each schedule key accepts and the code generated for them."""
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome.components.time import CRON_KEYS, TIME_SCHEMA
import esphome.config_validation as cv
from esphome.const import (
CONF_AT,
CONF_CRON,
CONF_DAYS_OF_MONTH,
CONF_DAYS_OF_WEEK,
CONF_HOURS,
CONF_MINUTES,
CONF_MONTHS,
CONF_ON_TIME,
CONF_SECONDS,
CONF_THEN,
CONF_TIMEZONE,
PlatformFramework,
)
from tests.component_tests.types import SetCoreConfigCallable
ALL_DAYS_OF_MONTH = list(range(1, 32))
ALL_MONTHS = list(range(1, 13))
ALL_DAYS_OF_WEEK = list(range(1, 8))
@pytest.mark.parametrize(
("entry", "expected"),
[
({CONF_SECONDS: 0}, {CONF_SECONDS: [0]}),
({CONF_SECONDS: "/15"}, {CONF_SECONDS: [0, 15, 30, 45, 60]}),
({CONF_SECONDS: "0,10,20"}, {CONF_SECONDS: [0, 10, 20]}),
({CONF_SECONDS: [30, 10]}, {CONF_SECONDS: [10, 30]}),
({CONF_MINUTES: "/5"}, {CONF_MINUTES: list(range(0, 60, 5))}),
({CONF_MINUTES: "3/20"}, {CONF_MINUTES: [3, 23, 43]}),
({CONF_HOURS: "8-17"}, {CONF_HOURS: list(range(8, 18))}),
({CONF_HOURS: "*"}, {CONF_HOURS: list(range(24))}),
({CONF_DAYS_OF_MONTH: "*/7"}, {CONF_DAYS_OF_MONTH: [1, 8, 15, 22, 29]}),
({CONF_DAYS_OF_MONTH: "1,15"}, {CONF_DAYS_OF_MONTH: [1, 15]}),
({CONF_MONTHS: "JAN-MAR"}, {CONF_MONTHS: [1, 2, 3]}),
({CONF_MONTHS: "JAN,JUL"}, {CONF_MONTHS: [1, 7]}),
({CONF_DAYS_OF_WEEK: "MON-FRI"}, {CONF_DAYS_OF_WEEK: [2, 3, 4, 5, 6]}),
({CONF_DAYS_OF_WEEK: "SAT,SUN"}, {CONF_DAYS_OF_WEEK: [1, 7]}),
(
{CONF_CRON: "0 30 7 * * MON-FRI"},
{
CONF_SECONDS: [0],
CONF_MINUTES: [30],
CONF_HOURS: [7],
CONF_DAYS_OF_MONTH: ALL_DAYS_OF_MONTH,
CONF_MONTHS: ALL_MONTHS,
CONF_DAYS_OF_WEEK: [2, 3, 4, 5, 6],
},
),
(
{CONF_CRON: "*/10 0 12 1,15 JAN,JUL ?"},
{
CONF_SECONDS: [0, 10, 20, 30, 40, 50, 60],
CONF_MINUTES: [0],
CONF_HOURS: [12],
CONF_DAYS_OF_MONTH: [1, 15],
CONF_MONTHS: [1, 7],
CONF_DAYS_OF_WEEK: ALL_DAYS_OF_WEEK,
},
),
(
{CONF_AT: "07:30"},
{
CONF_SECONDS: [0],
CONF_MINUTES: [30],
CONF_HOURS: [7],
CONF_DAYS_OF_MONTH: ALL_DAYS_OF_MONTH,
CONF_MONTHS: ALL_MONTHS,
CONF_DAYS_OF_WEEK: ALL_DAYS_OF_WEEK,
},
),
(
{CONF_AT: "23:59:15"},
{
CONF_SECONDS: [15],
CONF_MINUTES: [59],
CONF_HOURS: [23],
CONF_DAYS_OF_MONTH: ALL_DAYS_OF_MONTH,
CONF_MONTHS: ALL_MONTHS,
CONF_DAYS_OF_WEEK: ALL_DAYS_OF_WEEK,
},
),
],
)
def test_on_time_schedule_values(
entry: dict[str, Any],
expected: dict[str, list[int]],
set_core_config: SetCoreConfigCallable,
) -> None:
"""Each key validates to the sorted list of values it matches; unset keys stay unset."""
set_core_config(PlatformFramework.ESP32_IDF)
config = TIME_SCHEMA(
{CONF_TIMEZONE: "UTC0", CONF_ON_TIME: [{**entry, CONF_THEN: []}]}
)
conf = config[CONF_ON_TIME][0]
assert {key: conf.get(key) for key in CRON_KEYS} == {
key: expected.get(key) for key in CRON_KEYS
}
@pytest.mark.parametrize(
("entry", "message"),
[
({CONF_CRON: "0 30 7 * *"}, "exactly 6 space-separated parts, not 5"),
(
{CONF_CRON: "0 30 7 * * MON", CONF_SECONDS: 0},
"Cannot use option seconds when cron: is specified",
),
(
{CONF_AT: "07:30", CONF_HOURS: 7},
"Cannot use option hours when at: is specified",
),
(
{CONF_AT: "07:30", CONF_CRON: "0 30 7 * * *"},
"Cannot use option at with cron",
),
({CONF_SECONDS: 61}, r"seconds 61 is out of range \(min=0 max=60\)"),
({CONF_DAYS_OF_MONTH: 0}, r"days of month 0 is out of range \(min=1 max=31\)"),
({CONF_MONTHS: "FOO"}, "must be an integer, got FOO"),
({}, "Must contain at least one of seconds"),
],
)
def test_on_time_schedule_rejected(
entry: dict[str, Any], message: str, set_core_config: SetCoreConfigCallable
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match=message):
TIME_SCHEMA({CONF_TIMEZONE: "UTC0", CONF_ON_TIME: [{**entry, CONF_THEN: []}]})
ALL_MINUTES = ", ".join(map(str, range(60)))
ALL_HOURS = ", ".join(map(str, range(24)))
ALL_DOM = ", ".join(map(str, range(1, 32)))
ALL_MONTHS_CPP = ", ".join(map(str, range(1, 13)))
def _cron_lines(
var: str,
seconds: str = "0",
minutes: str = ALL_MINUTES,
hours: str = ALL_HOURS,
days_of_month: str = ALL_DOM,
months: str = ALL_MONTHS_CPP,
days_of_week: str = "1, 2, 3, 4, 5, 6, 7",
) -> str:
return "\n".join(
[
f"new({var}) time::CronTrigger(sntp_time);",
f"{var}->add_seconds({{{seconds}}});",
f"{var}->add_minutes({{{minutes}}});",
f"{var}->add_hours({{{hours}}});",
f"{var}->add_days_of_month({{{days_of_month}}});",
f"{var}->add_months({{{months}}});",
f"{var}->add_days_of_week({{{days_of_week}}});",
]
)
# One entry per on_time automation in config/on_time_fields.yaml, in order.
EXPECTED_ON_TIME_CODE = [
_cron_lines(
"time_crontrigger_id", minutes="0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55"
),
_cron_lines(
"time_crontrigger_id_2", minutes="30", hours="7", days_of_week="2, 3, 4, 5, 6"
),
_cron_lines("time_crontrigger_id_3", minutes="30", hours="7"),
_cron_lines(
"time_crontrigger_id_4",
minutes="0",
hours="8, 9, 10, 11, 12, 13, 14, 15, 16, 17",
),
_cron_lines(
"time_crontrigger_id_5",
minutes="0",
hours="12",
days_of_month="1, 15",
months="1, 7",
),
_cron_lines(
"time_crontrigger_id_6",
minutes="0",
hours="0",
days_of_month="1, 8, 15, 22, 29",
),
_cron_lines("time_crontrigger_id_7", seconds="0, 15, 30, 45, 60"),
_cron_lines("time_crontrigger_id_8", days_of_week="1, 7"),
]
def test_on_time_generated_code(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Each on_time automation generates its schedule check, in config order."""
main_cpp = generate_main(component_config_path("on_time_fields.yaml"))
position = 0
for expected in EXPECTED_ON_TIME_CODE:
found = main_cpp.find(expected, position)
assert found != -1, f"missing or out of order:\n{expected}"
position = found + len(expected)