Arrival zpráva má dva persistent Selecty (custom_id ev:<site>:<charger>:dep a :tgt, obsluha on_interaction + regex → přežijí restart): „Kdy odjíždíš?" za 2 h | za 4 h | dnes večer 18:00 | zítra ráno 7:00 | zítra poledne 12:00 | pondělí ráno 7:00; „Kolik potřebuješ?" 30/50/70/100 % | Nenabíjet. Každý výběr okamžitě PATCHne session přes fn_ev_session_apply_patch jen ve své dimenzi (absolutní deadline Europe/Prague, nejbližší budoucí výskyt; pevná volba smí přes 48 h), druhý rozměr zůstává z fn_ev_session_defaults. Pak replan + export a edit zprávy přepočteným plánem (build_ev_plan_summary) + potvrzením. Whitelist DISCORD_ALLOWED_USER_IDS i bot-first/webhook fallback beze změny; legacy tlačítka h2/h4/morning/full/stop starších zpráv dál obsloužená. Testy: mapování výběr→patch, absolutní deadline z voleb (půlnoc, pondělí z pátku >48 h, pondělí ráno v pondělí), parse, legacy akce — bez DB/sítě. Docs: discord-ev-interaction.md (nové UI, no-click = pohotovostní režim 30 % + oportunismus). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
165 lines
6.4 KiB
Python
165 lines
6.4 KiB
Python
"""Discord bot — čisté helpery (custom_id, výběry → patch, deadline z voleb),
|
|
bez sítě/discord lib."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from services.discord_bot import (
|
|
action_to_patch,
|
|
choice_label,
|
|
departure_choice_to_deadline,
|
|
parse_custom_id,
|
|
select_to_patch,
|
|
)
|
|
|
|
_PRAGUE = ZoneInfo("Europe/Prague")
|
|
# 2026-06-12 je pátek; 10:00 UTC = 12:00 Europe/Prague (CEST)
|
|
_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def _prague(dt: datetime) -> str:
|
|
return dt.astimezone(_PRAGUE).strftime("%Y-%m-%d %H:%M")
|
|
|
|
|
|
class ParseCustomIdTests(unittest.TestCase):
|
|
def test_valid_selects(self) -> None:
|
|
self.assertEqual(
|
|
parse_custom_id("ev:2:ev-charger-1:dep"), (2, "ev-charger-1", "dep")
|
|
)
|
|
self.assertEqual(
|
|
parse_custom_id("ev:2:ev-charger-1:tgt"), (2, "ev-charger-1", "tgt")
|
|
)
|
|
|
|
def test_valid_legacy_buttons(self) -> None:
|
|
self.assertEqual(
|
|
parse_custom_id("ev:2:ev-charger-1:h2"), (2, "ev-charger-1", "h2")
|
|
)
|
|
|
|
def test_invalid(self) -> None:
|
|
for bad in ("", "ev:2:x:jump", "foo:1:c:h2", "ev:abc:c:h2", "ev:1:c:dep:x"):
|
|
self.assertIsNone(parse_custom_id(bad))
|
|
|
|
|
|
class DepartureChoiceTests(unittest.TestCase):
|
|
"""Absolutní deadline z výběru „Kdy odjíždíš?" (Europe/Prague)."""
|
|
|
|
def test_h2(self) -> None:
|
|
dl = departure_choice_to_deadline("h2", now=_NOW)
|
|
self.assertEqual(_prague(dl), "2026-06-12 14:00")
|
|
|
|
def test_h4(self) -> None:
|
|
dl = departure_choice_to_deadline("h4", now=_NOW)
|
|
self.assertEqual(_prague(dl), "2026-06-12 16:00")
|
|
|
|
def test_today18_before_18(self) -> None:
|
|
dl = departure_choice_to_deadline("today18", now=_NOW) # 12:00 Prague
|
|
self.assertEqual(_prague(dl), "2026-06-12 18:00")
|
|
|
|
def test_today18_after_18_rolls_to_next_day(self) -> None:
|
|
late = datetime(2026, 6, 12, 17, 30, tzinfo=timezone.utc) # 19:30 Prague
|
|
dl = departure_choice_to_deadline("today18", now=late)
|
|
self.assertEqual(_prague(dl), "2026-06-13 18:00")
|
|
|
|
def test_tomorrow7_crosses_midnight(self) -> None:
|
|
# 23:30 Prague v pátek → zítra (sobota) 07:00, tj. +7,5 h
|
|
late = datetime(2026, 6, 12, 21, 30, tzinfo=timezone.utc)
|
|
dl = departure_choice_to_deadline("tomorrow7", now=late)
|
|
self.assertEqual(_prague(dl), "2026-06-13 07:00")
|
|
|
|
def test_tomorrow12(self) -> None:
|
|
dl = departure_choice_to_deadline("tomorrow12", now=_NOW)
|
|
self.assertEqual(_prague(dl), "2026-06-13 12:00")
|
|
|
|
def test_monday7_from_friday_allows_over_48h(self) -> None:
|
|
# explicitní volba smí přes 48h limit fn_ev_session_defaults
|
|
dl = departure_choice_to_deadline("monday7", now=_NOW) # pátek 12:00
|
|
self.assertEqual(_prague(dl), "2026-06-15 07:00")
|
|
self.assertGreater((dl - _NOW).total_seconds(), 48 * 3600)
|
|
|
|
def test_monday7_on_monday_before_7_is_today(self) -> None:
|
|
mon_early = datetime(2026, 6, 15, 3, 0, tzinfo=timezone.utc) # po 05:00
|
|
dl = departure_choice_to_deadline("monday7", now=mon_early)
|
|
self.assertEqual(_prague(dl), "2026-06-15 07:00")
|
|
|
|
def test_monday7_on_monday_after_7_is_next_week(self) -> None:
|
|
mon_late = datetime(2026, 6, 15, 8, 0, tzinfo=timezone.utc) # po 10:00
|
|
dl = departure_choice_to_deadline("monday7", now=mon_late)
|
|
self.assertEqual(_prague(dl), "2026-06-22 07:00")
|
|
|
|
def test_unknown_choice_raises(self) -> None:
|
|
with self.assertRaises(ValueError):
|
|
departure_choice_to_deadline("never", now=_NOW)
|
|
|
|
|
|
class SelectPatchTests(unittest.TestCase):
|
|
"""Mapování výběrů na patch payload pro fn_ev_session_apply_patch."""
|
|
|
|
def test_dep_patches_only_deadline(self) -> None:
|
|
p = select_to_patch("dep", "today18", now=_NOW, soc_at_connect=55.0)
|
|
self.assertEqual(set(p), {"target_deadline"})
|
|
self.assertIn("2026-06-12T18:00", p["target_deadline"])
|
|
self.assertIn("+02:00", p["target_deadline"]) # Europe/Prague (CEST)
|
|
|
|
def test_tgt_patches_only_target(self) -> None:
|
|
for value, expected in (("30", 30.0), ("50", 50.0), ("70", 70.0), ("100", 100.0)):
|
|
p = select_to_patch("tgt", value, now=_NOW, soc_at_connect=55.0)
|
|
self.assertEqual(p, {"target_soc_pct": expected})
|
|
|
|
def test_tgt_stop_targets_connect_soc(self) -> None:
|
|
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=42.5)
|
|
self.assertEqual(p, {"target_soc_pct": 42.5})
|
|
|
|
def test_tgt_stop_without_soc(self) -> None:
|
|
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=None)
|
|
self.assertEqual(p, {"target_soc_pct": 0.0})
|
|
|
|
def test_unknown_kind_raises(self) -> None:
|
|
with self.assertRaises(ValueError):
|
|
select_to_patch("foo", "30", now=_NOW, soc_at_connect=None)
|
|
|
|
def test_labels(self) -> None:
|
|
self.assertEqual(choice_label("dep", "monday7"), "odjezd pondělí ráno 7:00")
|
|
self.assertEqual(choice_label("tgt", "70"), "cíl 70 %")
|
|
self.assertEqual(choice_label("tgt", "stop"), "nenabíjet")
|
|
|
|
|
|
class LegacyActionPatchTests(unittest.TestCase):
|
|
"""Legacy tlačítka starších zpráv (h2/h4/morning/full/stop)."""
|
|
|
|
def _patch(self, action: str, **kw):
|
|
return action_to_patch(
|
|
action,
|
|
now=_NOW,
|
|
soc_at_connect=kw.get("soc", 55.0),
|
|
default_deadline_hour=kw.get("hour", 7),
|
|
)
|
|
|
|
def test_h2_deadline(self) -> None:
|
|
p = self._patch("h2")
|
|
self.assertIn("2026-06-12T12:00", p["target_deadline"])
|
|
|
|
def test_morning_next_occurrence(self) -> None:
|
|
p = self._patch("morning", hour=7)
|
|
# 12:00 Prague > 7:00 → zítra 7:00 Prague
|
|
self.assertIn("2026-06-13T07:00", p["target_deadline"])
|
|
|
|
def test_morning_today_if_before(self) -> None:
|
|
early = datetime(2026, 6, 12, 2, 0, tzinfo=timezone.utc) # 4:00 Prague
|
|
p = action_to_patch("morning", now=early, soc_at_connect=50, default_deadline_hour=7)
|
|
self.assertIn("2026-06-12T07:00", p["target_deadline"])
|
|
|
|
def test_full(self) -> None:
|
|
p = self._patch("full")
|
|
self.assertEqual(p["target_soc_pct"], 100)
|
|
|
|
def test_stop_targets_connect_soc(self) -> None:
|
|
p = self._patch("stop", soc=42.5)
|
|
self.assertEqual(p["target_soc_pct"], 42.5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|