Files
notify-bridge/packages/server/tests/test_time_list.py
T
alexei.dolgolyov 8065e6effa feat(immich): multi-time-point scheduling for scheduled/periodic/memory
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.
2026-05-29 14:57:41 +03:00

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)]