8065e6effa
Replace the single comma-separated text box with an add/remove list of native HH:MM pickers for the Immich scheduled-assets, periodic-summary, and memory slots. The backend already stored comma-separated *_times and scheduled one cron job per time; this makes entering several times per day discoverable and hardens the read/write path. Backend: - services/time_list.py: normalize_time_list (validate / dedup / sort / cap at 24, raising TimeListError) + lenient parse_hhmm_list; the scheduler now uses the shared parser (drops its private copy). - tracking_configs API normalizes *_times on every write (422 on bad input) and rejects enabling a slot whose times list is empty. - scheduler warns when an enabled slot has zero or dropped fire times, restoring the observability lost with the old per-call warning. Frontend: - TimeListEditor.svelte: add/remove native time rows, dedupe + sort on emit, per-day cap, collapses on-screen duplicates, aria-labelled rows; syncs from the value prop only on external changes (untrack guard) so keyboard entry isn't clobbered mid-edit. - Descriptor-driven save guard: an enabled feature section must have at least one time. - i18n (en/ru) keys; refreshed help text; removed dead invalidTimeList. Tests: time_list normalization/parsing (incl. non-ASCII/odd shapes) and the enabled-implies-times validation.
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
"""Unit tests for the shared HH:MM dispatch time-list parser/normalizer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from notify_bridge_server.services.time_list import (
|
|
MAX_DISPATCH_TIMES,
|
|
TimeListError,
|
|
normalize_time_list,
|
|
parse_hhmm_list,
|
|
)
|
|
|
|
|
|
class TestNormalizeTimeList:
|
|
def test_single_time_passthrough(self):
|
|
assert normalize_time_list("09:00") == "09:00"
|
|
|
|
def test_zero_pads_short_parts(self):
|
|
assert normalize_time_list("9:0") == "09:00"
|
|
|
|
def test_trims_surrounding_and_inner_whitespace(self):
|
|
assert normalize_time_list(" 09:00 , 18:30 ") == "09:00,18:30"
|
|
|
|
def test_sorts_ascending(self):
|
|
assert normalize_time_list("18:30,09:00,12:15") == "09:00,12:15,18:30"
|
|
|
|
def test_deduplicates(self):
|
|
# Duplicates collapse even when written with different padding.
|
|
assert normalize_time_list("09:00,9:00,09:00") == "09:00"
|
|
|
|
def test_empty_string_returns_empty(self):
|
|
assert normalize_time_list("") == ""
|
|
|
|
def test_whitespace_only_returns_empty(self):
|
|
assert normalize_time_list(" ") == ""
|
|
|
|
def test_trailing_comma_ignored(self):
|
|
assert normalize_time_list("09:00,") == "09:00"
|
|
|
|
def test_midnight_and_last_minute_are_valid(self):
|
|
assert normalize_time_list("00:00,23:59") == "00:00,23:59"
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
["24:00", "09:60", "noon", "9", "09-00", "-1:00", "09:5a", "1:2:3"],
|
|
)
|
|
def test_invalid_entry_raises(self, bad):
|
|
with pytest.raises(TimeListError):
|
|
normalize_time_list(bad)
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
[
|
|
"+9:00", # sign not allowed
|
|
"1_0:00", # PEP 515 underscore not allowed
|
|
"09:0_0", # underscore in minutes
|
|
"٩:00", # Arabic-Indic digit (non-ASCII)
|
|
"٠٩:٠٠", # Arabic-Indic "09:00"
|
|
"09: 00", # inner whitespace in part
|
|
"9 :00", # inner whitespace in part
|
|
"009:00", # 3-digit hour part
|
|
],
|
|
)
|
|
def test_rejects_non_ascii_or_oddly_shaped_digits(self, bad):
|
|
# int() alone would accept these; the parser must not.
|
|
with pytest.raises(TimeListError):
|
|
normalize_time_list(bad)
|
|
|
|
def test_one_bad_entry_rejects_whole_list(self):
|
|
# Strict on writes: a single bad slot fails the request rather than
|
|
# being silently dropped.
|
|
with pytest.raises(TimeListError):
|
|
normalize_time_list("09:00,bad,18:30")
|
|
|
|
def test_at_cap_is_allowed(self):
|
|
times = ",".join(f"{h:02d}:00" for h in range(MAX_DISPATCH_TIMES))
|
|
assert normalize_time_list(times) == times
|
|
|
|
def test_over_cap_raises(self):
|
|
# 25 distinct times (minute-granular) exceeds the default cap of 24.
|
|
times = ",".join(f"00:{m:02d}" for m in range(MAX_DISPATCH_TIMES + 1))
|
|
with pytest.raises(TimeListError):
|
|
normalize_time_list(times)
|
|
|
|
def test_custom_max_count(self):
|
|
with pytest.raises(TimeListError):
|
|
normalize_time_list("09:00,10:00,11:00", max_count=2)
|
|
|
|
def test_duplicates_do_not_count_against_cap(self):
|
|
# Three entries but one distinct → fits under a cap of 1.
|
|
assert normalize_time_list("09:00,09:00,9:00", max_count=1) == "09:00"
|
|
|
|
|
|
class TestParseHhmmList:
|
|
def test_basic(self):
|
|
assert parse_hhmm_list("09:00,18:30") == [(9, 0), (18, 30)]
|
|
|
|
def test_preserves_order_and_duplicates(self):
|
|
# Lenient parser keeps input order and does not collapse duplicates —
|
|
# the scheduler keys jobs by time so a dup just replaces its own id.
|
|
assert parse_hhmm_list("18:30,09:00,18:30") == [(18, 30), (9, 0), (18, 30)]
|
|
|
|
def test_skips_invalid_entries(self):
|
|
assert parse_hhmm_list("09:00,bad,18:30") == [(9, 0), (18, 30)]
|
|
|
|
def test_empty_returns_empty_list(self):
|
|
assert parse_hhmm_list("") == []
|
|
assert parse_hhmm_list(" ") == []
|
|
|
|
def test_skips_out_of_range(self):
|
|
assert parse_hhmm_list("24:00,09:00") == [(9, 0)]
|
|
|
|
def test_skips_non_ascii_and_oddly_shaped(self):
|
|
# Lenient parser drops the odd shapes but keeps the valid neighbour.
|
|
assert parse_hhmm_list("+9:00,1_0:00,٠٩:٠٠,18:30") == [(18, 30)]
|