Discord EV: dva výběry (odjezd × cíl) místo řady tlačítek

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>
This commit is contained in:
Dusan Vojacek
2026-06-12 19:14:56 +02:00
parent 60eda46dd7
commit 48f5a6b00b
3 changed files with 338 additions and 80 deletions

View File

@@ -1,27 +1,134 @@
"""Discord bot — čisté helpery (custom_id, patch akcí), bez sítě/discord lib."""
"""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, parse_custom_id
from services.discord_bot import (
action_to_patch,
choice_label,
departure_choice_to_deadline,
parse_custom_id,
select_to_patch,
)
_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc) # 12:00 Prague
_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(self) -> None:
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"):
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 ActionPatchTests(unittest.TestCase):
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,