From 58ec87a74a9fd314820597eac0f02645803d6f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:17:49 +0000 Subject: [PATCH 1/9] Clearer sound buttons: Enable/Disable sound for notification Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 2 +- tests/test_alert_menu.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 0494c28..ce72312 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -58,7 +58,7 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par if open == "set" and loud is not None: for k in ([parent] if parent else available): on = k in loud - rows.append([_button(f"๐Ÿ”• Disable {LABELS[k]} sound" if on else f"๐Ÿ”” Enable {LABELS[k]} sound", f"al:snd:{k}:{'off' if on else 'on'}{tail}")]) + rows.append([_button(f"๐Ÿ”• Disable sound for {LABELS[k]} notification" if on else f"๐Ÿ”” Enable sound for {LABELS[k]} notification", f"al:snd:{k}:{'off' if on else 'on'}{tail}")]) if open in ("sub", "unsub"): verb = "on" if open == "sub" else "off" listed = options(muted, available, open) diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index d1ff551..61da112 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -326,17 +326,17 @@ async def test_settings_turn_the_notification_sound_on_and_off_per_type_in_priva q = Query("al:open:set") # /alerts: one button per type await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][2] == ("โ–พ โš™๏ธ Settings", "al:close") and ("๐Ÿ”” Enable rain sound", "al:snd:rain:on") in [b for r in drawn[1:] for b in r] + assert drawn[0][2] == ("โ–พ โš™๏ธ Settings", "al:close") and ("๐Ÿ”” Enable sound for rain notification", "al:snd:rain:on") in [b for r in drawn[1:] for b in r] q = Query("al:snd:rain:on") await press(bot, q) assert state.loud(1) == {"rain"} and "make a sound" in q.toast - assert ("๐Ÿ”• Disable rain sound", "al:snd:rain:off") in [b for r in rows(q.edits[0][1]) for b in r] # stays open, now offering to turn it off + assert ("๐Ÿ”• Disable sound for rain notification", "al:snd:rain:off") in [b for r in rows(q.edits[0][1]) for b in r] # stays open, now offering to turn it off q = Query("al:open:set:uv", text="โ˜€๏ธ UV 10") # under an alert: just that type await press(bot, q) - assert rows(q.edits[0][1])[1:] == [[("๐Ÿ”” Enable UV sound", "al:snd:uv:on:uv")]] + assert rows(q.edits[0][1])[1:] == [[("๐Ÿ”” Enable sound for UV notification", "al:snd:uv:on:uv")]] q = Query("al:snd:uv:on:uv", text="โ˜€๏ธ UV 10") await press(bot, q) - assert state.loud(1) == {"rain", "uv"} and rows(q.edits[0][1])[1:] == [[("๐Ÿ”• Disable UV sound", "al:snd:uv:off:uv")]] + assert state.loud(1) == {"rain", "uv"} and rows(q.edits[0][1])[1:] == [[("๐Ÿ”• Disable sound for UV notification", "al:snd:uv:off:uv")]] await press(bot, Query("al:snd:rain:off")) assert state.loud(1) == {"uv"} for data in ("al:snd:bogus:on", "al:snd:rain:maybe", "al:snd:rain"): # nothing from the data reaches the state From 0b686ea768bec09dffc52f78ba0f33b52775c9f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:19:46 +0000 Subject: [PATCH 2/9] Settings on /alerts list only the subscribed alert types Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 4 ++-- lib/bot.py | 3 +++ tests/test_alert_menu.py | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index ce72312..00ee7b6 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -49,14 +49,14 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par alert the buttons sit under and is carried in every button. Under an alert the Unsubscribe list is that type (marked) and "Unsubscribe from all"; on the /alerts message it lists every type that is on. "... all" is there only when it does something different from the one type listed. `loud`: the types whose alerts make a notification sound; None (a group) leaves out the Settings button, - which opens one button per type (just the alert's own under an alert) that turns that sound on or off.""" + which opens one button per subscribed type (just the alert's own under an alert) that turns that sound on or off.""" tail = f":{parent}" if parent else "" rows = [[_button(("โ–พ " if open == "sub" else "") + "โž• Subscribe", f"al:close{tail}" if open == "sub" else f"al:open:sub{tail}"), _button(("โ–พ " if open == "unsub" else "") + "โž– Unsubscribe", f"al:close{tail}" if open == "unsub" else f"al:open:unsub{tail}")]] if loud is not None: rows[0].append(_button(("โ–พ " if open == "set" else "") + "โš™๏ธ Settings", f"al:close{tail}" if open == "set" else f"al:open:set{tail}")) if open == "set" and loud is not None: - for k in ([parent] if parent else available): + for k in ([parent] if parent else [k for k in available if k not in muted]): # only the types this chat gets on = k in loud rows.append([_button(f"๐Ÿ”• Disable sound for {LABELS[k]} notification" if on else f"๐Ÿ”” Enable sound for {LABELS[k]} notification", f"al:snd:{k}:{'off' if on else 'on'}{tail}")]) if open in ("sub", "unsub"): diff --git a/lib/bot.py b/lib/bot.py index e080cb5..ed7f803 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -401,6 +401,9 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY toast, opened = None, section or None if action in ("open", "close"): opened = arg if action == "open" else None + if opened == "set" and not parent and not menu.options(self.state.muted(chat.id), kinds, "unsub"): + await query.answer("No alerts are on") # no subscribed type to set a sound for + return if opened in ("sub", "unsub") and not menu.has_options(self.state.muted(chat.id), kinds, opened, parent): await query.answer("You're subscribed to everything" if opened == "sub" else "No alerts are on") # nothing to list return diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 61da112..4ca4f13 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -388,3 +388,16 @@ async def moved(*a, **k): bot2._is_group_admin = moved await bot2.on_button(NS(callback_query=q, effective_user=NS(id=7), effective_message=q.message, effective_chat=q.message.chat), ctx()) assert -1007 in state.chats and -7 not in state.chats and "upgraded" in q.toast + + +async def test_the_settings_list_only_the_subscribed_types(tmp_path): + bot, state = make_bot(tmp_path) + state.set_kind(1, "rain", False) + q = Query("al:open:set") + await press(bot, q) + listed = [d for r in rows(q.edits[0][1])[1:] for _, d in r] + assert "al:snd:rain:on" not in listed and "al:snd:uv:on" in listed + state.set_kind(1, ALL, False) + q = Query("al:open:set") + await press(bot, q) + assert q.toast == "No alerts are on" and q.edits == [] From 473e8bc94d458b0b4854ac76617abb4088fca843 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:23:18 +0000 Subject: [PATCH 3/9] =?UTF-8?q?Settings:=20Enable/Disable=20notification?= =?UTF-8?q?=20sounds=20sub-menus,=20with=20a=20=E2=96=B8=20on=20menus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/menu.py | 23 ++++++++++++++++------- lib/bot.py | 4 ++-- tests/test_alert_menu.py | 32 +++++++++++++++++++++----------- tests/test_irrigation.py | 2 +- 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6decc26..8d4dca6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every chat it is in: rain starting/stopping, rain likely soon, gusts over 40 km/h, UV index of 10 or more, indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands), -and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a โš™๏ธ Settings button there and under each alert turns the notification sound on or off per alert type; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a โš™๏ธ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 00ee7b6..2d0177c 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -3,7 +3,7 @@ the title from what a chat has muted (the same menu goes under every alert and under /alerts). Callback data (self-contained and short, so an old message's buttons still work after a restart): - al:open:sub | al:open:unsub | al:open:set | al:close | al:noop (the heading rows of messages sent by an earlier version) + al:open:sub | al:open:unsub | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version) al:snd::[:parent] (notification sound for one type; Settings is in private chats only) al:on::
| al:off::
(section: sub or unsub, the part left open) An alert's buttons end with the alert's own type (parent): under an alert the Unsubscribe list is just that type (marked) and "all": al:open:unsub:rain, al:close:rain, @@ -24,6 +24,7 @@ ALL = "all" TITLE = "๐Ÿ”” Alerts in this chat" PER_ROW = 2 +SETTINGS = ("set", "snd_on", "snd_off") # the open sections under the Settings button def available_kinds(sources: set[str]) -> list[str]: @@ -49,16 +50,24 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par alert the buttons sit under and is carried in every button. Under an alert the Unsubscribe list is that type (marked) and "Unsubscribe from all"; on the /alerts message it lists every type that is on. "... all" is there only when it does something different from the one type listed. `loud`: the types whose alerts make a notification sound; None (a group) leaves out the Settings button, - which opens one button per subscribed type (just the alert's own under an alert) that turns that sound on or off.""" + which opens two sub-menus, enable and disable notification sounds, each listing the subscribed types (just the alert's own under an alert) it applies to.""" tail = f":{parent}" if parent else "" rows = [[_button(("โ–พ " if open == "sub" else "") + "โž• Subscribe", f"al:close{tail}" if open == "sub" else f"al:open:sub{tail}"), _button(("โ–พ " if open == "unsub" else "") + "โž– Unsubscribe", f"al:close{tail}" if open == "unsub" else f"al:open:unsub{tail}")]] if loud is not None: - rows[0].append(_button(("โ–พ " if open == "set" else "") + "โš™๏ธ Settings", f"al:close{tail}" if open == "set" else f"al:open:set{tail}")) - if open == "set" and loud is not None: - for k in ([parent] if parent else [k for k in available if k not in muted]): # only the types this chat gets - on = k in loud - rows.append([_button(f"๐Ÿ”• Disable sound for {LABELS[k]} notification" if on else f"๐Ÿ”” Enable sound for {LABELS[k]} notification", f"al:snd:{k}:{'off' if on else 'on'}{tail}")]) + in_settings = open in SETTINGS + rows[0].append(_button(("โ–พ " if in_settings else "") + "โš™๏ธ Settings" + ("" if in_settings else " โ–ธ"), + f"al:close{tail}" if in_settings else f"al:open:set{tail}")) + if open in SETTINGS and loud is not None: + pool = [parent] if parent else [k for k in available if k not in muted] # only the types this chat gets + for section, heading, items in (("snd_on", "๐Ÿ”” Enable notification sounds", [k for k in pool if k not in loud]), + ("snd_off", "๐Ÿ”• Disable notification sounds", [k for k in pool if k in loud])): + if not items: + continue + rows.append([_button(f"โ–พ {heading}" if open == section else f"{heading} โ–ธ", + f"al:open:set{tail}" if open == section else f"al:open:{section}{tail}")]) # โ–ธ marks a menu, not an action + if open == section: + rows += map(list, batched([_button(label(k), f"al:snd:{k}:{'on' if section == 'snd_on' else 'off'}{tail}") for k in items], PER_ROW)) if open in ("sub", "unsub"): verb = "on" if open == "sub" else "off" listed = options(muted, available, open) diff --git a/lib/bot.py b/lib/bot.py index ed7f803..3c2a1bb 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -410,7 +410,7 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY elif action == "snd" and chat.type == ChatType.PRIVATE and arg in kinds and section in ("on", "off") and chat.id in self.state.chats: self.state.set_sound(chat.id, arg, section == "on") parent = tail if tail in kinds else None - opened = "set" + opened = "snd_on" if section == "on" else "snd_off" # stay in the list; the keyboard closes it when it empties toast = f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts {'now make' if section == 'on' else 'no longer make'} a sound" elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: self.state.set_kind(chat.id, arg, action == "on") @@ -423,7 +423,7 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY if opened in ("sub", "unsub") and not menu.has_options(muted, kinds, opened, parent): opened = None # the last one was just turned on or off: close the section private = chat.type == ChatType.PRIVATE - markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", "set") else None, parent, + markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", *menu.SETTINGS) else None, parent, self.state.loud(chat.id) if private else None) await query.answer(toast) try: diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 4ca4f13..be50220 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -86,7 +86,7 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] assert rows(markup) == [[("โž• Subscribe", "al:open:sub:rain"), ("โž– Unsubscribe", "al:open:unsub:rain"), - ("โš™๏ธ Settings", "al:open:set:rain")]] # the alert's own type rides along + ("โš™๏ธ Settings โ–ธ", "al:open:set:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -205,7 +205,7 @@ async def reply_text(body, **kw): update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) assert replies[0][0].startswith(TITLE) and "โœ… On: rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] - assert rows(replies[0][1]["reply_markup"])[0] == [("โž• Subscribe", "al:open:sub"), ("โž– Unsubscribe", "al:open:unsub"), ("โš™๏ธ Settings", "al:open:set")] + assert rows(replies[0][1]["reply_markup"])[0] == [("โž• Subscribe", "al:open:sub"), ("โž– Unsubscribe", "al:open:unsub"), ("โš™๏ธ Settings โ–ธ", "al:open:set")] await bot.on_alerts(update, NS(args=["off"])) assert not state.alerts_on(1) and "โœ… On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) @@ -292,7 +292,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th q = Query("al:off:all:unsub:uv", text="โ˜€๏ธ UV 10") # and "all" turns the rest off, closing the empty section await press(bot, q) assert rows(q.edits[0][1]) == [[("โž• Subscribe", "al:open:sub:uv"), ("โž– Unsubscribe", "al:open:unsub:uv"), - ("โš™๏ธ Settings", "al:open:set:uv")]] + ("โš™๏ธ Settings โ–ธ", "al:open:set:uv")]] q = Query("al:open:unsub:uv", text="โ˜€๏ธ UV 10") # nothing is on now: a toast, no menu await press(bot, q) assert q.toast == "No alerts are on" and not q.edits @@ -321,24 +321,32 @@ def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_wit assert title(set(), ["rain"]) == f"{TITLE}\nโœ… On: rain\n๐Ÿ”• Off: nothing" # the on/off summary and the toasts stay plain words -async def test_settings_turn_the_notification_sound_on_and_off_per_type_in_private_chats_only(tmp_path): +async def test_settings_hold_enable_and_disable_sub_menus_of_notification_sounds_in_private_chats_only(tmp_path): bot, state = make_bot(tmp_path) - q = Query("al:open:set") # /alerts: one button per type + q = Query("al:open:set") # /alerts: Settings opens the two sub-menus (โ–ธ marks a menu) await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][2] == ("โ–พ โš™๏ธ Settings", "al:close") and ("๐Ÿ”” Enable sound for rain notification", "al:snd:rain:on") in [b for r in drawn[1:] for b in r] + assert drawn[0][2] == ("โ–พ โš™๏ธ Settings", "al:close") and drawn[1:] == [[("๐Ÿ”” Enable notification sounds โ–ธ", "al:open:snd_on")]] + q = Query("al:open:snd_on") # only the types with the sound off, as buttons + await press(bot, q) + drawn = rows(q.edits[0][1]) + assert drawn[1] == [("โ–พ ๐Ÿ”” Enable notification sounds", "al:open:set")] and ("๐ŸŒง๏ธ rain", "al:snd:rain:on") in [b for r in drawn[2:] for b in r] q = Query("al:snd:rain:on") await press(bot, q) assert state.loud(1) == {"rain"} and "make a sound" in q.toast - assert ("๐Ÿ”• Disable sound for rain notification", "al:snd:rain:off") in [b for r in rows(q.edits[0][1]) for b in r] # stays open, now offering to turn it off + drawn = rows(q.edits[0][1]) # stays in the list, rain gone from it; Disable appears + assert "al:snd:rain:on" not in [d for r in drawn for _, d in r] and ("๐Ÿ”• Disable notification sounds โ–ธ", "al:open:snd_off") in [b for r in drawn for b in r] + q = Query("al:open:snd_off") + await press(bot, q) + assert [b for r in rows(q.edits[0][1]) for b in r if b[1].startswith("al:snd")] == [("๐ŸŒง๏ธ rain", "al:snd:rain:off")] + await press(bot, Query("al:snd:rain:off")) + assert state.loud(1) == set() q = Query("al:open:set:uv", text="โ˜€๏ธ UV 10") # under an alert: just that type await press(bot, q) - assert rows(q.edits[0][1])[1:] == [[("๐Ÿ”” Enable sound for UV notification", "al:snd:uv:on:uv")]] + assert rows(q.edits[0][1])[1:] == [[("๐Ÿ”” Enable notification sounds โ–ธ", "al:open:snd_on:uv")]] q = Query("al:snd:uv:on:uv", text="โ˜€๏ธ UV 10") await press(bot, q) - assert state.loud(1) == {"rain", "uv"} and rows(q.edits[0][1])[1:] == [[("๐Ÿ”• Disable sound for UV notification", "al:snd:uv:off:uv")]] - await press(bot, Query("al:snd:rain:off")) - assert state.loud(1) == {"uv"} + assert state.loud(1) == {"uv"} and rows(q.edits[0][1])[1:] == [[("๐Ÿ”• Disable notification sounds โ–ธ", "al:open:snd_off:uv")]] for data in ("al:snd:bogus:on", "al:snd:rain:maybe", "al:snd:rain"): # nothing from the data reaches the state await press(bot, Query(data)) assert state.loud(1) == {"uv"} @@ -395,6 +403,8 @@ async def test_the_settings_list_only_the_subscribed_types(tmp_path): state.set_kind(1, "rain", False) q = Query("al:open:set") await press(bot, q) + q = Query("al:open:snd_on") + await press(bot, q) listed = [d for r in rows(q.edits[0][1])[1:] for _, d in r] assert "al:snd:rain:on" not in listed and "al:snd:uv:on" in listed state.set_kind(1, ALL, False) diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index c4d981b..d25db14 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -709,7 +709,7 @@ async def send_message(self, chat_id, text, **kw): assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("โ˜” Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] assert rows(markup) == [[("โž• Subscribe", "al:open:sub:irrigation_pause"), ("โž– Unsubscribe", "al:open:unsub:irrigation_pause"), - ("โš™๏ธ Settings", "al:open:set:irrigation_pause")]] + ("โš™๏ธ Settings โ–ธ", "al:open:set:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too From ef9736aba2a32c544e4f5bf2eed0c7921cc06d60 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:33:17 +0000 Subject: [PATCH 4/9] Under an alert: Unsubscribe and the sound menus list the alert's own type, then Other with the rest Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 56 ++++++++++++++++++++++++---------------- lib/bot.py | 10 ++++--- tests/test_alert_menu.py | 48 ++++++++++++++++++++++++---------- 3 files changed, 75 insertions(+), 39 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 2d0177c..c15651b 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -3,10 +3,10 @@ the title from what a chat has muted (the same menu goes under every alert and under /alerts). Callback data (self-contained and short, so an old message's buttons still work after a restart): - al:open:sub | al:open:unsub | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version) + al:open:sub | al:open:unsub | al:open:other | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version) al:snd::[:parent] (notification sound for one type; Settings is in private chats only) al:on::
| al:off::
(section: sub or unsub, the part left open) -An alert's buttons end with the alert's own type (parent): under an alert the Unsubscribe list is just that type (marked) and "all": al:open:unsub:rain, al:close:rain, +An alert's buttons end with the alert's own type (parent): under an alert the Unsubscribe list is its own type (marked โ—) and "Other", which holds the rest and "all": al:open:unsub:rain, al:close:rain, al:off:uv:unsub:rain. A chat's /alerts message has no parent and no such field. """ @@ -24,7 +24,7 @@ ALL = "all" TITLE = "๐Ÿ”” Alerts in this chat" PER_ROW = 2 -SETTINGS = ("set", "snd_on", "snd_off") # the open sections under the Settings button +SETTINGS = ("set", "snd_on", "snd_off", "snd_on_o", "snd_off_o") # (_o: the "Other" list inside a sound menu) def available_kinds(sources: set[str]) -> list[str]: @@ -47,39 +47,51 @@ def _button(text: str, data: str) -> InlineKeyboardButton: def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: """The collapsed menu, or with one section open (open = "sub" or "unsub") and its options under it. `parent` is the type of the - alert the buttons sit under and is carried in every button. Under an alert the Unsubscribe list is that type (marked) and - "Unsubscribe from all"; on the /alerts message it lists every type that is on. "... all" is there only when it does something - different from the one type listed. `loud`: the types whose alerts make a notification sound; None (a group) leaves out the Settings button, - which opens two sub-menus, enable and disable notification sounds, each listing the subscribed types (just the alert's own under an alert) it applies to.""" + alert the buttons sit under and is carried in every button. Under an alert that is on, the Unsubscribe list is that type + (marked) and an "Other" menu (open = "other") holding the rest and "Unsubscribe from all"; on the /alerts message it lists every type + that is on, with "all" when more than one. `loud`: the types whose alerts make a notification sound; None (a group) leaves out the Settings button, + which opens two sub-menus, enable and disable notification sounds, each listing the subscribed types it applies to.""" tail = f":{parent}" if parent else "" + listed = options(muted, available, "unsub") + nested = parent in listed and len(listed) > 1 # under an alert that is still on: its own type, then the others under "Other" + if open == "other" and not nested: + open = "unsub" + unsub_open = open in ("unsub", "other") rows = [[_button(("โ–พ " if open == "sub" else "") + "โž• Subscribe", f"al:close{tail}" if open == "sub" else f"al:open:sub{tail}"), - _button(("โ–พ " if open == "unsub" else "") + "โž– Unsubscribe", f"al:close{tail}" if open == "unsub" else f"al:open:unsub{tail}")]] + _button(("โ–พ " if unsub_open else "") + "โž– Unsubscribe", f"al:close{tail}" if unsub_open else f"al:open:unsub{tail}")]] if loud is not None: in_settings = open in SETTINGS rows[0].append(_button(("โ–พ " if in_settings else "") + "โš™๏ธ Settings" + ("" if in_settings else " โ–ธ"), f"al:close{tail}" if in_settings else f"al:open:set{tail}")) if open in SETTINGS and loud is not None: - pool = [parent] if parent else [k for k in available if k not in muted] # only the types this chat gets + pool = [k for k in available if k not in muted] # only the types this chat gets for section, heading, items in (("snd_on", "๐Ÿ”” Enable notification sounds", [k for k in pool if k not in loud]), ("snd_off", "๐Ÿ”• Disable notification sounds", [k for k in pool if k in loud])): if not items: continue - rows.append([_button(f"โ–พ {heading}" if open == section else f"{heading} โ–ธ", - f"al:open:set{tail}" if open == section else f"al:open:{section}{tail}")]) # โ–ธ marks a menu, not an action - if open == section: - rows += map(list, batched([_button(label(k), f"al:snd:{k}:{'on' if section == 'snd_on' else 'off'}{tail}") for k in items], PER_ROW)) - if open in ("sub", "unsub"): + here = open in (section, f"{section}_o") + rows.append([_button(f"โ–พ {heading}" if here else f"{heading} โ–ธ", + f"al:open:set{tail}" if here else f"al:open:{section}{tail}")]) # โ–ธ marks a menu, not an action + if here: + button = lambda k: _button(f"โ— {label(k)}" if k == parent else label(k), f"al:snd:{k}:{'on' if section == 'snd_on' else 'off'}{tail}") + if parent in items and len(items) > 1: # under an alert: its own type, then "Other" with the rest + more = open == f"{section}_o" + rows.append([button(parent)]) + rows.append([_button("โ–พ Other" if more else "Other โ–ธ", f"al:open:{section}{tail}" if more else f"al:open:{section}_o{tail}")]) + items = [k for k in items if k != parent] if more else [] + rows += map(list, batched([button(k) for k in items], PER_ROW)) + if open in ("sub", "unsub", "other"): verb = "on" if open == "sub" else "off" - listed = options(muted, available, open) - if open == "unsub" and parent: - others = [k for k in listed if k != parent] - listed, show_all = [k for k in listed if k == parent], bool(others) - else: - show_all = len(listed) > 1 - mark = lambda k: f"โ— {label(k)}" if open == "unsub" and k == parent else label(k) # the alert these buttons are under + listed = options(muted, available, "sub" if open == "sub" else "unsub") + mark = lambda k: f"โ— {label(k)}" if open != "sub" and k == parent else label(k) # the alert these buttons are under + if nested and open != "sub": + rows.append([_button(mark(parent), f"al:off:{parent}:{open}{tail}")]) + rows.append([_button("โ–พ Other" if open == "other" else "Other โ–ธ", f"al:open:unsub{tail}" if open == "other" else f"al:open:other{tail}")]) + listed = listed if open == "other" else [] + listed = [k for k in listed if k != parent] buttons = [_button(mark(k), f"al:{verb}:{k}:{open}{tail}") for k in listed] rows += map(list, batched(buttons, PER_ROW)) - if show_all: + if len(listed) > 1 or (open == "other" and listed): rows.append([_button("๐Ÿ”” Subscribe to all" if open == "sub" else "๐Ÿ”• Unsubscribe from all", f"al:{verb}:{ALL}:{open}{tail}")]) return InlineKeyboardMarkup(rows) diff --git a/lib/bot.py b/lib/bot.py index 3c2a1bb..93cad1c 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -404,13 +404,15 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY if opened == "set" and not parent and not menu.options(self.state.muted(chat.id), kinds, "unsub"): await query.answer("No alerts are on") # no subscribed type to set a sound for return - if opened in ("sub", "unsub") and not menu.has_options(self.state.muted(chat.id), kinds, opened, parent): + if opened in ("sub", "unsub", "other") and not menu.has_options(self.state.muted(chat.id), kinds, opened, parent): await query.answer("You're subscribed to everything" if opened == "sub" else "No alerts are on") # nothing to list return elif action == "snd" and chat.type == ChatType.PRIVATE and arg in kinds and section in ("on", "off") and chat.id in self.state.chats: self.state.set_sound(chat.id, arg, section == "on") parent = tail if tail in kinds else None - opened = "snd_on" if section == "on" else "snd_off" # stay in the list; the keyboard closes it when it empties + opened = "snd_on" if section == "on" else "snd_off" # stay in the list (under an alert: in "Other" for another type) + if parent and arg != parent: + opened += "_o" toast = f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts {'now make' if section == 'on' else 'no longer make'} a sound" elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: self.state.set_kind(chat.id, arg, action == "on") @@ -420,10 +422,10 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY await query.answer() return muted = self.state.muted(chat.id) - if opened in ("sub", "unsub") and not menu.has_options(muted, kinds, opened, parent): + if opened in ("sub", "unsub", "other") and not menu.has_options(muted, kinds, opened, parent): opened = None # the last one was just turned on or off: close the section private = chat.type == ChatType.PRIVATE - markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", *menu.SETTINGS) else None, parent, + markup = menu.keyboard(muted, kinds, opened if opened in ("sub", "unsub", "other", *menu.SETTINGS) else None, parent, self.state.loud(chat.id) if private else None) await query.answer(toast) try: diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index be50220..ddc1f71 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -256,14 +256,20 @@ async def spy(chat_id, text, link=None, kind=None): assert seen == ["forecast"] -def test_under_an_alert_the_unsubscribe_list_is_just_its_type_and_all_and_every_button_carries_the_type(): +def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub", parent="uv")) assert got == [[("โž• Subscribe", "al:open:sub:uv"), ("โ–พ โž– Unsubscribe", "al:close:uv")], - [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub:uv")]] - only = rows(keyboard(set(KINDS) - {"uv"}, KINDS, "unsub", parent="uv")) # the alert's type is the only one on: no "all" + [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("Other โ–ธ", "al:open:other:uv")]] + other = rows(keyboard({"pollen", "forecast"}, KINDS, "other", parent="uv")) + assert other[1:3] == [[("โ— ๐Ÿงด UV", "al:off:uv:other:uv")], [("โ–พ Other", "al:open:unsub:uv")]] + listed = [d for r in other[3:-1] for _, d in r] + assert listed and all(d.startswith("al:off:") and d.endswith(":other:uv") and ":uv:" not in d for d in listed) # the rest, not its own type + assert other[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:other:uv")] + only = rows(keyboard(set(KINDS) - {"uv"}, KINDS, "unsub", parent="uv")) # the alert's type is the only one on: no Other assert only[1:] == [[("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")]] - gone = rows(keyboard({"uv", "gusts"}, KINDS, "unsub", parent="uv")) # its type is already off, others on: just "all" - assert gone[1:] == [[("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub:uv")]] + assert rows(keyboard(set(KINDS) - {"uv"}, KINDS, "other", parent="uv")) == only # and an open Other degrades to the plain list + gone = rows(keyboard({"uv", "gusts"}, KINDS, "unsub", parent="uv")) # its type is already off: a plain list of the rest, with "all" + assert "Other" not in str(gone) and gone[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub:uv")] assert len(rows(keyboard(set(KINDS), KINDS, "unsub", parent="uv"))) == 1 # nothing is on at all: nothing listed sub = rows(keyboard({"pollen", "forecast"}, KINDS, "sub", parent="uv")) # Subscribe is the types that are off, as ever assert [t for row in sub[1:-1] for t, _ in row] == ["๐ŸŒผ pollen & asthma", "๐Ÿ”„ forecast changes"] and sub[-1][0][0] == "๐Ÿ”” Subscribe to all" @@ -284,11 +290,14 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th await press(bot, q) drawn = rows(q.edits[0][1]) assert drawn[0][1] == ("โ–พ โž– Unsubscribe", "al:close:uv") and drawn[1:] == [ - [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub:uv")]] - q = Query("al:off:uv:unsub:uv", text="โ˜€๏ธ UV 10") # unsubscribing from this type leaves "all" for the rest + [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("Other โ–ธ", "al:open:other:uv")]] + q = Query("al:open:other:uv", text="โ˜€๏ธ UV 10") + await press(bot, q) + assert rows(q.edits[0][1])[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:other:uv")] + q = Query("al:off:uv:unsub:uv", text="โ˜€๏ธ UV 10") # unsubscribing from this type leaves a plain list of the rest await press(bot, q) assert "uv" in state.muted(1) and q.toast == "UV alerts off in this chat" - assert rows(q.edits[0][1])[1:] == [[("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub:uv")]] + assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub:uv")] q = Query("al:off:all:unsub:uv", text="โ˜€๏ธ UV 10") # and "all" turns the rest off, closing the empty section await press(bot, q) assert rows(q.edits[0][1]) == [[("โž• Subscribe", "al:open:sub:uv"), ("โž– Unsubscribe", "al:open:unsub:uv"), @@ -317,6 +326,7 @@ def test_every_alert_type_has_its_emoji_and_every_button_in_the_lists_starts_wit for parent in (None, "uv"): drawn = rows(keyboard(set(LABELS) if section == "sub" else set(), everything, section, parent)) texts = [t for row in drawn[1:] for t, _ in row] + texts = [t for t in texts if "Other" not in t] # (the Other menu is a word, not an alert type) assert texts and all(not t.removeprefix("โ— ")[0].isascii() for t in texts), texts # every button under it starts with an emoji assert title(set(), ["rain"]) == f"{TITLE}\nโœ… On: rain\n๐Ÿ”• Off: nothing" # the on/off summary and the toasts stay plain words @@ -341,15 +351,27 @@ async def test_settings_hold_enable_and_disable_sub_menus_of_notification_sounds assert [b for r in rows(q.edits[0][1]) for b in r if b[1].startswith("al:snd")] == [("๐ŸŒง๏ธ rain", "al:snd:rain:off")] await press(bot, Query("al:snd:rain:off")) assert state.loud(1) == set() - q = Query("al:open:set:uv", text="โ˜€๏ธ UV 10") # under an alert: just that type + q = Query("al:open:snd_on:uv", text="โ˜€๏ธ UV 10") # under an alert: its own type, then Other + await press(bot, q) + drawn = rows(q.edits[0][1]) + assert drawn[2:] == [[("โ— ๐Ÿงด UV", "al:snd:uv:on:uv")], [("Other โ–ธ", "al:open:snd_on_o:uv")]] + q = Query("al:open:snd_on_o:uv", text="โ˜€๏ธ UV 10") # Other holds the rest await press(bot, q) - assert rows(q.edits[0][1])[1:] == [[("๐Ÿ”” Enable notification sounds โ–ธ", "al:open:snd_on:uv")]] - q = Query("al:snd:uv:on:uv", text="โ˜€๏ธ UV 10") + drawn = rows(q.edits[0][1]) + assert drawn[3] == [("โ–พ Other", "al:open:snd_on:uv")] and ("๐ŸŒง๏ธ rain", "al:snd:rain:on:uv") in [b for r in drawn for b in r] + assert "al:snd:uv:on:uv" not in [d for r in drawn[4:] for _, d in r] + q = Query("al:snd:rain:on:uv", text="โ˜€๏ธ UV 10") # a press in Other stays in Other await press(bot, q) - assert state.loud(1) == {"uv"} and rows(q.edits[0][1])[1:] == [[("๐Ÿ”• Disable notification sounds โ–ธ", "al:open:snd_off:uv")]] + assert state.loud(1) == {"rain"} and ("โ–พ Other", "al:open:snd_on:uv") in [b for r in rows(q.edits[0][1]) for b in r] + q = Query("al:snd:uv:on:uv", text="โ˜€๏ธ UV 10") # a press on its own type stays on its level + await press(bot, q) + assert state.loud(1) == {"rain", "uv"} and ("๐Ÿ”• Disable notification sounds โ–ธ", "al:open:snd_off:uv") in [b for r in rows(q.edits[0][1]) for b in r] + await press(bot, Query("al:snd:rain:off:uv", text="โ˜€๏ธ UV 10")) + await press(bot, Query("al:snd:uv:off:uv", text="โ˜€๏ธ UV 10")) + assert state.loud(1) == set() for data in ("al:snd:bogus:on", "al:snd:rain:maybe", "al:snd:rain"): # nothing from the data reaches the state await press(bot, Query(data)) - assert state.loud(1) == {"uv"} + assert state.loud(1) == set() q = Query("al:snd:rain:on", chat_type="supergroup", chat_id=-5) # groups have no sound settings state.add_chat(-5, "G") await press(bot, q) From fc2e08d43011937a21fed667c20521ef083166c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:16:13 +0000 Subject: [PATCH 5/9] Enable all / Disable all in the notification sound menus Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 5 ++++- lib/bot.py | 8 +++++--- tests/test_alert_menu.py | 22 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index c15651b..0331acf 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -4,7 +4,7 @@ Callback data (self-contained and short, so an old message's buttons still work after a restart): al:open:sub | al:open:unsub | al:open:other | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version) - al:snd::[:parent] (notification sound for one type; Settings is in private chats only) + al:snd::[:parent] (notification sound for one type; Settings is in private chats only) al:on::
| al:off::
(section: sub or unsub, the part left open) An alert's buttons end with the alert's own type (parent): under an alert the Unsubscribe list is its own type (marked โ—) and "Other", which holds the rest and "all": al:open:unsub:rain, al:close:rain, al:off:uv:unsub:rain. A chat's /alerts message has no parent and no such field. @@ -80,6 +80,9 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par rows.append([_button("โ–พ Other" if more else "Other โ–ธ", f"al:open:{section}{tail}" if more else f"al:open:{section}_o{tail}")]) items = [k for k in items if k != parent] if more else [] rows += map(list, batched([button(k) for k in items], PER_ROW)) + if len(items) > 1 or (items and open == f"{section}_o"): # "all": every type in this list (in Other, the alert's own type too) + rows.append([_button("๐Ÿ”” Enable all" if section == "snd_on" else "๐Ÿ”• Disable all", + f"al:snd:{ALL}:{'on' if section == 'snd_on' else 'off'}{tail}")]) if open in ("sub", "unsub", "other"): verb = "on" if open == "sub" else "off" listed = options(muted, available, "sub" if open == "sub" else "unsub") diff --git a/lib/bot.py b/lib/bot.py index 93cad1c..8402604 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -407,13 +407,15 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY if opened in ("sub", "unsub", "other") and not menu.has_options(self.state.muted(chat.id), kinds, opened, parent): await query.answer("You're subscribed to everything" if opened == "sub" else "No alerts are on") # nothing to list return - elif action == "snd" and chat.type == ChatType.PRIVATE and arg in kinds and section in ("on", "off") and chat.id in self.state.chats: - self.state.set_sound(chat.id, arg, section == "on") + elif action == "snd" and chat.type == ChatType.PRIVATE and (arg in kinds or arg == menu.ALL) and section in ("on", "off") and chat.id in self.state.chats: + for k in ([k for k in kinds if k not in self.state.muted(chat.id)] if arg == menu.ALL else [arg]): # "all": the types offered here + self.state.set_sound(chat.id, k, section == "on") parent = tail if tail in kinds else None opened = "snd_on" if section == "on" else "snd_off" # stay in the list (under an alert: in "Other" for another type) if parent and arg != parent: opened += "_o" - toast = f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts {'now make' if section == 'on' else 'no longer make'} a sound" + what = "All alerts" if arg == menu.ALL else f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts" + toast = f"{what} {'now make' if section == 'on' else 'no longer make'} a sound" elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: self.state.set_kind(chat.id, arg, action == "on") what = "All alerts" if arg == menu.ALL else f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts" # "UV alerts", not "Uv" diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index ddc1f71..33073da 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -433,3 +433,25 @@ async def test_the_settings_list_only_the_subscribed_types(tmp_path): q = Query("al:open:set") await press(bot, q) assert q.toast == "No alerts are on" and q.edits == [] + + +async def test_the_sound_lists_have_an_all_button(tmp_path): + bot, state = make_bot(tmp_path) + state.set_kind(1, "gusts", False) + q = Query("al:open:snd_on") # /alerts: a flat list with "Enable all" last + await press(bot, q) + assert rows(q.edits[0][1])[-1] == [("๐Ÿ”” Enable all", "al:snd:all:on")] + q = Query("al:snd:all:on") + await press(bot, q) + assert state.loud(1) == {k for k in KINDS if k != "gusts"} and q.toast == "All alerts now make a sound" # only the types it gets + assert ("๐Ÿ”• Disable notification sounds โ–ธ", "al:open:snd_off") in [b for r in rows(q.edits[0][1]) for b in r] + q = Query("al:open:snd_off") + await press(bot, q) + assert rows(q.edits[0][1])[-1] == [("๐Ÿ”• Disable all", "al:snd:all:off")] + await press(bot, Query("al:snd:all:off")) + assert state.loud(1) == set() + q = Query("al:open:snd_on_o:uv", text="โ˜€๏ธ UV 10") # under an alert, "all" sits in Other + await press(bot, q) + assert rows(q.edits[0][1])[-1] == [("๐Ÿ”” Enable all", "al:snd:all:on:uv")] + await press(bot, Query("al:snd:all:on:uv", text="โ˜€๏ธ UV 10")) + assert "uv" in state.loud(1) and "rain" in state.loud(1) and "gusts" not in state.loud(1) From a4cc96edc8f3c8610b1d61c3674c896219c1dc26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:20:19 +0000 Subject: [PATCH 6/9] Tidy the alert menus: one shared type-list builder, shared toast wording, docstrings Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 75 +++++++++++++++++++++--------------------- lib/bot.py | 11 ++++--- lib/irrigation/menu.py | 13 +++++--- 3 files changed, 53 insertions(+), 46 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 0331acf..f7e1702 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -6,8 +6,8 @@ al:open:sub | al:open:unsub | al:open:other | al:open:set | al:open:snd_on | al:open:snd_off | al:close | al:noop (the heading rows of messages sent by an earlier version) al:snd::[:parent] (notification sound for one type; Settings is in private chats only) al:on::
| al:off::
(section: sub or unsub, the part left open) -An alert's buttons end with the alert's own type (parent): under an alert the Unsubscribe list is its own type (marked โ—) and "Other", which holds the rest and "all": al:open:unsub:rain, al:close:rain, -al:off:uv:unsub:rain. A chat's /alerts message has no parent and no such field. +An alert's buttons end with the alert's own type (parent): under an alert each list is its own type (marked โ—) and "Other", which holds the rest and "all": al:open:unsub:rain, al:close:rain, +al:off:uv:unsub:rain (the sound menus' Other is al:open:snd_on_o:rain, al:open:snd_off_o:rain). A chat's /alerts message has no parent and no such field. """ from itertools import batched @@ -44,17 +44,33 @@ def _button(text: str, data: str) -> InlineKeyboardButton: return InlineKeyboardButton(text, callback_data=data) +def _type_rows(items: list[str], parent: str | None, more: bool, button, other_data: tuple[str, str], every: InlineKeyboardButton) -> list[list]: + """A list of alert types as button rows, the same under Unsubscribe, Subscribe and both sound menus. Under an alert that is in the + list (with others), the alert's own type comes first, then an "Other" menu (`other_data` = the data that opens it, the data that + closes it; `more`: it is open) that holds the rest and `every` ("all"). Otherwise a flat list, with `every` when it has more than + one type. `button(kind)` draws one type.""" + rows: list[list] = [] + if parent in items and len(items) > 1: + rows += [[button(parent)], [_button("โ–พ Other" if more else "Other โ–ธ", other_data[1] if more else other_data[0])]] + items = [k for k in items if k != parent] if more else [] + elif more: + more = False # nothing to put under Other + rows += map(list, batched(map(button, items), PER_ROW)) + if len(items) > 1 or (more and items): + rows.append([every]) + return rows + + def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: - """The collapsed menu, or with one section open (open = "sub" or "unsub") and its options under it. `parent` is the type of the - alert the buttons sit under and is carried in every button. Under an alert that is on, the Unsubscribe list is that type - (marked) and an "Other" menu (open = "other") holding the rest and "Unsubscribe from all"; on the /alerts message it lists every type - that is on, with "all" when more than one. `loud`: the types whose alerts make a notification sound; None (a group) leaves out the Settings button, - which opens two sub-menus, enable and disable notification sounds, each listing the subscribed types it applies to.""" + """The menu: Subscribe, Unsubscribe and (private chats) Settings, one section open at a time (`open`: sub, unsub, other, set, + snd_on, snd_off, or snd_on_o / snd_off_o, the Other list of a sound menu) with its options under it. A โ–ธ marks a button that opens + a menu, โ–พ the open one; the rest do something. `parent` is the type of the alert the buttons sit under (carried in every button): + its lists put that type first (โ—) and the rest under "Other". `loud`: the types whose alerts make a notification sound; None (a + group) leaves out Settings, which holds two menus, Enable and Disable notification sounds, over the subscribed types.""" tail = f":{parent}" if parent else "" - listed = options(muted, available, "unsub") - nested = parent in listed and len(listed) > 1 # under an alert that is still on: its own type, then the others under "Other" - if open == "other" and not nested: + subscribed = options(muted, available, "unsub") + if open == "other" and not (parent in subscribed and len(subscribed) > 1): open = "unsub" unsub_open = open in ("unsub", "other") rows = [[_button(("โ–พ " if open == "sub" else "") + "โž• Subscribe", f"al:close{tail}" if open == "sub" else f"al:open:sub{tail}"), @@ -63,39 +79,24 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par in_settings = open in SETTINGS rows[0].append(_button(("โ–พ " if in_settings else "") + "โš™๏ธ Settings" + ("" if in_settings else " โ–ธ"), f"al:close{tail}" if in_settings else f"al:open:set{tail}")) + mark = lambda k: f"โ— {label(k)}" if k == parent else label(k) # the alert these buttons are under if open in SETTINGS and loud is not None: - pool = [k for k in available if k not in muted] # only the types this chat gets - for section, heading, items in (("snd_on", "๐Ÿ”” Enable notification sounds", [k for k in pool if k not in loud]), - ("snd_off", "๐Ÿ”• Disable notification sounds", [k for k in pool if k in loud])): + for section, heading, items in (("snd_on", "๐Ÿ”” Enable notification sounds", [k for k in subscribed if k not in loud]), + ("snd_off", "๐Ÿ”• Disable notification sounds", [k for k in subscribed if k in loud])): + here = open in (section, f"{section}_o") if not items: continue - here = open in (section, f"{section}_o") - rows.append([_button(f"โ–พ {heading}" if here else f"{heading} โ–ธ", - f"al:open:set{tail}" if here else f"al:open:{section}{tail}")]) # โ–ธ marks a menu, not an action + rows.append([_button(f"โ–พ {heading}" if here else f"{heading} โ–ธ", f"al:open:set{tail}" if here else f"al:open:{section}{tail}")]) if here: - button = lambda k: _button(f"โ— {label(k)}" if k == parent else label(k), f"al:snd:{k}:{'on' if section == 'snd_on' else 'off'}{tail}") - if parent in items and len(items) > 1: # under an alert: its own type, then "Other" with the rest - more = open == f"{section}_o" - rows.append([button(parent)]) - rows.append([_button("โ–พ Other" if more else "Other โ–ธ", f"al:open:{section}{tail}" if more else f"al:open:{section}_o{tail}")]) - items = [k for k in items if k != parent] if more else [] - rows += map(list, batched([button(k) for k in items], PER_ROW)) - if len(items) > 1 or (items and open == f"{section}_o"): # "all": every type in this list (in Other, the alert's own type too) - rows.append([_button("๐Ÿ”” Enable all" if section == "snd_on" else "๐Ÿ”• Disable all", - f"al:snd:{ALL}:{'on' if section == 'snd_on' else 'off'}{tail}")]) + verb = "on" if section == "snd_on" else "off" + rows += _type_rows(items, parent, open == f"{section}_o", lambda k: _button(mark(k), f"al:snd:{k}:{verb}{tail}"), + (f"al:open:{section}_o{tail}", f"al:open:{section}{tail}"), + _button("๐Ÿ”” Enable all" if verb == "on" else "๐Ÿ”• Disable all", f"al:snd:{ALL}:{verb}{tail}")) if open in ("sub", "unsub", "other"): verb = "on" if open == "sub" else "off" - listed = options(muted, available, "sub" if open == "sub" else "unsub") - mark = lambda k: f"โ— {label(k)}" if open != "sub" and k == parent else label(k) # the alert these buttons are under - if nested and open != "sub": - rows.append([_button(mark(parent), f"al:off:{parent}:{open}{tail}")]) - rows.append([_button("โ–พ Other" if open == "other" else "Other โ–ธ", f"al:open:unsub{tail}" if open == "other" else f"al:open:other{tail}")]) - listed = listed if open == "other" else [] - listed = [k for k in listed if k != parent] - buttons = [_button(mark(k), f"al:{verb}:{k}:{open}{tail}") for k in listed] - rows += map(list, batched(buttons, PER_ROW)) - if len(listed) > 1 or (open == "other" and listed): - rows.append([_button("๐Ÿ”” Subscribe to all" if open == "sub" else "๐Ÿ”• Unsubscribe from all", f"al:{verb}:{ALL}:{open}{tail}")]) + rows += _type_rows(options(muted, available, "sub" if open == "sub" else "unsub"), parent if open != "sub" else None, open == "other", + lambda k: _button(mark(k), f"al:{verb}:{k}:{open}{tail}"), (f"al:open:other{tail}", f"al:open:unsub{tail}"), + _button("๐Ÿ”” Subscribe to all" if verb == "on" else "๐Ÿ”• Unsubscribe from all", f"al:{verb}:{ALL}:{open}{tail}")) return InlineKeyboardMarkup(rows) diff --git a/lib/bot.py b/lib/bot.py index 8402604..f3ad98d 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -381,6 +381,11 @@ async def on_usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE): self.remember_chat(update) await self._help_and_alerts(update, context) + @staticmethod + def _alerts_named(kind: str) -> str: + """"All alerts" or "UV alerts" (not "Uv"), for a toast.""" + return "All alerts" if kind == menu.ALL else f"{menu.LABELS[kind][0].upper()}{menu.LABELS[kind][1:]} alerts" + async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """A press on the alert settings buttons (under an alert or the /alerts message): open or close a section, or subscribe or unsubscribe a type or all. In a group only admins may change them.""" @@ -414,12 +419,10 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY opened = "snd_on" if section == "on" else "snd_off" # stay in the list (under an alert: in "Other" for another type) if parent and arg != parent: opened += "_o" - what = "All alerts" if arg == menu.ALL else f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts" - toast = f"{what} {'now make' if section == 'on' else 'no longer make'} a sound" + toast = f"{self._alerts_named(arg)} {'now make' if section == 'on' else 'no longer make'} a sound" elif action in ("on", "off") and (arg == menu.ALL or arg in kinds) and chat.id in self.state.chats: self.state.set_kind(chat.id, arg, action == "on") - what = "All alerts" if arg == menu.ALL else f"{menu.LABELS[arg][0].upper()}{menu.LABELS[arg][1:]} alerts" # "UV alerts", not "Uv" - toast = f"{what} {action} in this chat" + toast = f"{self._alerts_named(arg)} {action} in this chat" else: await query.answer() return diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 078a3cf..40c8ffc 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -1,8 +1,10 @@ """The irrigation status message and its buttons. Pure: it only builds the text and the keyboard. -Collapsed: [๐Ÿ’ง Water] [โธ Pause] / [๐Ÿ”” Alerts] [๐Ÿ”„ Refresh]. While the timer is paused the Pause button is [โ–ถ๏ธ Unpause] (one press ends the pause) and Water is -hidden. A press on Water, Pause or Alerts opens its options under the rows (โ–พ marks the open one; pressing -it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). While the valve is on, a Water off โŒ button is the third row. Alerts has one button per alert type, each naming what pressing it does for this chat (enable or disable). Callback data +Collapsed: [๐Ÿ’ง Water] [โธ Pause] / [๐Ÿ”” Alerts] [๐Ÿ”„ Refresh]. While the valve is on, a [Water off โŒ] row follows. While the timer is paused, +Pause is [โ–ถ๏ธ Unpause] (one press ends the pause) and Water is hidden. A press on Water, Pause or Alerts opens its options under the +rows (โ–พ marks the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has +one button per alert type, each naming what pressing it does for this chat (enable or disable). +Callback data (short, self-contained, so an old message's buttons still work after a restart): ir:open: | ir:close | ir:refresh (read the state again, change nothing) ir:water: | ir:delay:<24h|48h|72h|cancel> (cancel = unpause, the Unpause button) | ir:sw:off @@ -85,13 +87,14 @@ def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | N def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False, running: bool = False) -> InlineKeyboardMarkup: """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button - names what pressing it does (disable when it is on, enable when it is off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" + names what pressing it does (disable when it is on, enable when it is off). `paused`: the timer is paused, so its + button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" def head(name: str, text: str) -> InlineKeyboardButton: return _button(("โ–พ " if open == name else "") + text, "ir:close" if open == name else f"ir:open:{name}") delay = _button("โ–ถ๏ธ Unpause", "ir:delay:cancel") if paused else head("delay", "โธ Pause") rows = [[delay] if paused else [head("water", "๐Ÿ’ง Water"), delay], [head("alerts", "๐Ÿ”” Alerts"), _button("๐Ÿ”„ Refresh", "ir:refresh")]] - if running: # the valve is open: Off is one press away, whatever else is open + if running: # the valve is open: off is one press away, whatever else is open rows.append([_button("Water off โŒ", "ir:sw:off")]) if open == "water" and not paused: rows.append([_button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES]) From 9a126884395c4516ef64b4f1522ce8afde318bcd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:23:36 +0000 Subject: [PATCH 7/9] =?UTF-8?q?Every=20menu=20button=20carries=20a=20?= =?UTF-8?q?=E2=96=B8=20when=20closed=20(=E2=96=BE=20when=20open);=20action?= =?UTF-8?q?s=20have=20neither?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 9 ++++----- lib/irrigation/menu.py | 2 +- tests/test_alert_menu.py | 14 +++++++------- tests/test_irrigation.py | 12 ++++++------ 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index f7e1702..15b2455 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -73,12 +73,11 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par if open == "other" and not (parent in subscribed and len(subscribed) > 1): open = "unsub" unsub_open = open in ("unsub", "other") - rows = [[_button(("โ–พ " if open == "sub" else "") + "โž• Subscribe", f"al:close{tail}" if open == "sub" else f"al:open:sub{tail}"), - _button(("โ–พ " if unsub_open else "") + "โž– Unsubscribe", f"al:close{tail}" if unsub_open else f"al:open:unsub{tail}")]] + def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: # a menu button: โ–ธ closed, โ–พ open + return _button(f"โ–พ {text}" if is_open else f"{text} โ–ธ", f"al:close{tail}" if is_open else f"al:open:{section}{tail}") + rows = [[head(open == "sub", "โž• Subscribe", "sub"), head(unsub_open, "โž– Unsubscribe", "unsub")]] if loud is not None: - in_settings = open in SETTINGS - rows[0].append(_button(("โ–พ " if in_settings else "") + "โš™๏ธ Settings" + ("" if in_settings else " โ–ธ"), - f"al:close{tail}" if in_settings else f"al:open:set{tail}")) + rows[0].append(head(open in SETTINGS, "โš™๏ธ Settings", "set")) mark = lambda k: f"โ— {label(k)}" if k == parent else label(k) # the alert these buttons are under if open in SETTINGS and loud is not None: for section, heading, items in (("snd_on", "๐Ÿ”” Enable notification sounds", [k for k in subscribed if k not in loud]), diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 40c8ffc..4ed2538 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -90,7 +90,7 @@ def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = names what pressing it does (disable when it is on, enable when it is off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" def head(name: str, text: str) -> InlineKeyboardButton: - return _button(("โ–พ " if open == name else "") + text, "ir:close" if open == name else f"ir:open:{name}") + return _button(f"โ–พ {text}" if open == name else f"{text} โ–ธ", "ir:close" if open == name else f"ir:open:{name}") # โ–ธ closed, โ–พ open delay = _button("โ–ถ๏ธ Unpause", "ir:delay:cancel") if paused else head("delay", "โธ Pause") rows = [[delay] if paused else [head("water", "๐Ÿ’ง Water"), delay], [head("alerts", "๐Ÿ”” Alerts"), _button("๐Ÿ”„ Refresh", "ir:refresh")]] diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 33073da..569a25e 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -16,12 +16,12 @@ def rows(markup): def test_the_collapsed_menu_is_subscribe_and_unsubscribe(): - assert rows(keyboard(set(), KINDS)) == [[("โž• Subscribe", "al:open:sub"), ("โž– Unsubscribe", "al:open:unsub")]] + assert rows(keyboard(set(), KINDS)) == [[("โž• Subscribe โ–ธ", "al:open:sub"), ("โž– Unsubscribe โ–ธ", "al:open:unsub")]] def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub")) - assert got[0] == [("โž• Subscribe", "al:open:sub"), ("โ–พ โž– Unsubscribe", "al:close")] + assert got[0] == [("โž• Subscribe โ–ธ", "al:open:sub"), ("โ–พ โž– Unsubscribe", "al:close")] assert got[1] == [("๐ŸŒง๏ธ rain", "al:off:rain:unsub"), ("๐ŸŒฆ๏ธ rain predicted", "al:off:rain_likely:unsub")] assert [text for row in got[1:-1] for text, _ in row] == ["๐ŸŒง๏ธ rain", "๐ŸŒฆ๏ธ rain predicted", "๐Ÿ’จ gusts", "๐Ÿงด UV", "๐ŸŒก๏ธ temperature crossing", "๐Ÿ˜ท particulates"] assert got[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub")] @@ -85,7 +85,7 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("โž• Subscribe", "al:open:sub:rain"), ("โž– Unsubscribe", "al:open:unsub:rain"), + assert rows(markup) == [[("โž• Subscribe โ–ธ", "al:open:sub:rain"), ("โž– Unsubscribe โ–ธ", "al:open:unsub:rain"), ("โš™๏ธ Settings โ–ธ", "al:open:set:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -205,7 +205,7 @@ async def reply_text(body, **kw): update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) assert replies[0][0].startswith(TITLE) and "โœ… On: rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] - assert rows(replies[0][1]["reply_markup"])[0] == [("โž• Subscribe", "al:open:sub"), ("โž– Unsubscribe", "al:open:unsub"), ("โš™๏ธ Settings โ–ธ", "al:open:set")] + assert rows(replies[0][1]["reply_markup"])[0] == [("โž• Subscribe โ–ธ", "al:open:sub"), ("โž– Unsubscribe โ–ธ", "al:open:unsub"), ("โš™๏ธ Settings โ–ธ", "al:open:set")] await bot.on_alerts(update, NS(args=["off"])) assert not state.alerts_on(1) and "โœ… On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) @@ -258,7 +258,7 @@ async def spy(chat_id, text, link=None, kind=None): def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("โž• Subscribe", "al:open:sub:uv"), ("โ–พ โž– Unsubscribe", "al:close:uv")], + assert got == [[("โž• Subscribe โ–ธ", "al:open:sub:uv"), ("โ–พ โž– Unsubscribe", "al:close:uv")], [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("Other โ–ธ", "al:open:other:uv")]] other = rows(keyboard({"pollen", "forecast"}, KINDS, "other", parent="uv")) assert other[1:3] == [[("โ— ๐Ÿงด UV", "al:off:uv:other:uv")], [("โ–พ Other", "al:open:unsub:uv")]] @@ -300,7 +300,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub:uv")] q = Query("al:off:all:unsub:uv", text="โ˜€๏ธ UV 10") # and "all" turns the rest off, closing the empty section await press(bot, q) - assert rows(q.edits[0][1]) == [[("โž• Subscribe", "al:open:sub:uv"), ("โž– Unsubscribe", "al:open:unsub:uv"), + assert rows(q.edits[0][1]) == [[("โž• Subscribe โ–ธ", "al:open:sub:uv"), ("โž– Unsubscribe โ–ธ", "al:open:unsub:uv"), ("โš™๏ธ Settings โ–ธ", "al:open:set:uv")]] q = Query("al:open:unsub:uv", text="โ˜€๏ธ UV 10") # nothing is on now: a toast, no menu await press(bot, q) @@ -389,7 +389,7 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ flags = {(c, t.split("\n")[0]): kw["disable_notification"] for c, t, kw in bot.sent} assert flags[(1, "It's raining")] is False and flags[(1, "UV")] is True and flags[(-5, "It's raining")] is True group = next(kw for c, _, kw in bot.sent if c == -5) - assert [b[0] for b in rows(group["reply_markup"])[0]] == ["โž• Subscribe", "โž– Unsubscribe"] + assert [b[0] for b in rows(group["reply_markup"])[0]] == ["โž• Subscribe โ–ธ", "โž– Unsubscribe โ–ธ"] async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index d25db14..f552bf7 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -162,7 +162,7 @@ def test_the_status_always_shows_mode_state_pause_time_and_battery_in_that_order def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): - collapsed = [[("๐Ÿ’ง Water", "ir:open:water"), ("โธ Pause", "ir:open:delay")], [("๐Ÿ”” Alerts", "ir:open:alerts"), ("๐Ÿ”„ Refresh", "ir:refresh")]] + collapsed = [[("๐Ÿ’ง Water โ–ธ", "ir:open:water"), ("โธ Pause โ–ธ", "ir:open:delay")], [("๐Ÿ”” Alerts โ–ธ", "ir:open:alerts"), ("๐Ÿ”„ Refresh", "ir:refresh")]] assert rows(menu.keyboard(False, False)) == collapsed == rows(menu.keyboard(True, True)) # the alert states show only once Alerts is open water = rows(menu.keyboard(True, True, "water")) assert water[0][0] == ("โ–พ ๐Ÿ’ง Water", "ir:close") and water[-1] == [(f"{m} min", f"ir:water:{m}") for m in (5, 10, 20, 30)] @@ -451,7 +451,7 @@ async def reply_text(body, **kw): update = NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(id=7, full_name="Rob", username="rob")) await bot.on_message(update, NS(bot=NS(username="b", id=99))) # no model is involved (the agent is None) assert sent[0][0].startswith("๐ŸŒฑ Irrigation\nmode: idle\nstate: off โŒ\npause: not paused (alerts: on)\nbattery: 100% ๐Ÿ”‹ (alerts: on)") - assert rows(sent[0][1]["reply_markup"])[0][0] == ("๐Ÿ’ง Water", "ir:open:water") and rows(sent[0][1]["reply_markup"])[1][0] == ("๐Ÿ”” Alerts", "ir:open:alerts") + assert rows(sent[0][1]["reply_markup"])[0][0] == ("๐Ÿ’ง Water โ–ธ", "ir:open:water") and rows(sent[0][1]["reply_markup"])[1][0] == ("๐Ÿ”” Alerts โ–ธ", "ir:open:alerts") device.down = True await bot.on_message(update, NS(bot=NS(username="b", id=99))) assert sent[1][0] == "Sorry, I couldn't reach the irrigation controller." and len(sent) == 2 @@ -591,7 +591,7 @@ async def test_typing_irrigation_does_what_the_button_does_in_private_chats_and_ assert sent[0][0].startswith("๐ŸŒฑ Irrigation\nmode: idle\nstate: off โŒ") and sent[0][1]["reply_markup"], text update, sent = group_message("@testbot irrigation") await bot.on_message(update, ctx) - assert sent[0][0].startswith("๐ŸŒฑ Irrigation") and rows(sent[0][1]["reply_markup"])[0][0] == ("๐Ÿ’ง Water", "ir:open:water") + assert sent[0][0].startswith("๐ŸŒฑ Irrigation") and rows(sent[0][1]["reply_markup"])[0][0] == ("๐Ÿ’ง Water โ–ธ", "ir:open:water") update, sent = group_message("water", replied=True) # a reply to the bot counts as addressing it await bot.on_message(update, NS(bot=NS(username="testbot", id=99))) assert sent == [] or sent[0][0].startswith("๐ŸŒฑ Irrigation") @@ -708,7 +708,7 @@ async def send_message(self, chat_id, text, **kw): await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("โ˜” Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("โž• Subscribe", "al:open:sub:irrigation_pause"), ("โž– Unsubscribe", "al:open:unsub:irrigation_pause"), + assert rows(markup) == [[("โž• Subscribe โ–ธ", "al:open:sub:irrigation_pause"), ("โž– Unsubscribe โ–ธ", "al:open:unsub:irrigation_pause"), ("โš™๏ธ Settings โ–ธ", "al:open:set:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) @@ -816,7 +816,7 @@ async def test_while_paused_the_button_is_unpause_and_one_press_ends_the_pause(t bot, state, device = irrigation_bot(tmp_path) update, sent = private_message("irrigation") await bot.on_message(update, NS(bot=NS(username="testbot", id=99))) - assert rows(sent[0][1]["reply_markup"])[0][1] == ("โธ Pause", "ir:open:delay") # not paused + assert rows(sent[0][1]["reply_markup"])[0][1] == ("โธ Pause โ–ธ", "ir:open:delay") # not paused q = await press(bot, "ir:open:delay") assert [b[0] for b in rows(q.edits[0][1])[-1]] == ["24h", "48h", "72h"] q = await press(bot, "ir:delay:48h") # pause it @@ -831,7 +831,7 @@ async def test_while_paused_the_button_is_unpause_and_one_press_ends_the_pause(t assert rows(q.edits[0][2])[0][0] == ("โ–ถ๏ธ Unpause", "ir:delay:cancel") q = await press(bot, "ir:delay:cancel") # the Unpause button assert q.toast == "Unpaused" and device.commands[-1] == ("delay", "cancel") and "pause: not paused" in q.edits[0][1] - assert rows(q.edits[0][2])[0][1] == ("โธ Pause", "ir:open:delay") # and it is Pause again + assert rows(q.edits[0][2])[0][1] == ("โธ Pause โ–ธ", "ir:open:delay") # and it is Pause again device.status_now["weather_delay"] = "24h" # paused from the app q = await press(bot, "ir:refresh") assert rows(q.edits[0][2])[0][0] == ("โ–ถ๏ธ Unpause", "ir:delay:cancel") From 5c0d3a4a4df3b190f3720b6d450ca96f0b3e3784 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:25:36 +0000 Subject: [PATCH 8/9] Menu buttons read Sub and Unsub Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 2 +- tests/test_alert_menu.py | 20 ++++++++++---------- tests/test_irrigation.py | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 15b2455..10e4e0e 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -75,7 +75,7 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par unsub_open = open in ("unsub", "other") def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: # a menu button: โ–ธ closed, โ–พ open return _button(f"โ–พ {text}" if is_open else f"{text} โ–ธ", f"al:close{tail}" if is_open else f"al:open:{section}{tail}") - rows = [[head(open == "sub", "โž• Subscribe", "sub"), head(unsub_open, "โž– Unsubscribe", "unsub")]] + rows = [[head(open == "sub", "โž• Sub", "sub"), head(unsub_open, "โž– Unsub", "unsub")]] if loud is not None: rows[0].append(head(open in SETTINGS, "โš™๏ธ Settings", "set")) mark = lambda k: f"โ— {label(k)}" if k == parent else label(k) # the alert these buttons are under diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 569a25e..84d9b5f 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -16,17 +16,17 @@ def rows(markup): def test_the_collapsed_menu_is_subscribe_and_unsubscribe(): - assert rows(keyboard(set(), KINDS)) == [[("โž• Subscribe โ–ธ", "al:open:sub"), ("โž– Unsubscribe โ–ธ", "al:open:unsub")]] + assert rows(keyboard(set(), KINDS)) == [[("โž• Sub โ–ธ", "al:open:sub"), ("โž– Unsub โ–ธ", "al:open:unsub")]] def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub")) - assert got[0] == [("โž• Subscribe โ–ธ", "al:open:sub"), ("โ–พ โž– Unsubscribe", "al:close")] + assert got[0] == [("โž• Sub โ–ธ", "al:open:sub"), ("โ–พ โž– Unsub", "al:close")] assert got[1] == [("๐ŸŒง๏ธ rain", "al:off:rain:unsub"), ("๐ŸŒฆ๏ธ rain predicted", "al:off:rain_likely:unsub")] assert [text for row in got[1:-1] for text, _ in row] == ["๐ŸŒง๏ธ rain", "๐ŸŒฆ๏ธ rain predicted", "๐Ÿ’จ gusts", "๐Ÿงด UV", "๐ŸŒก๏ธ temperature crossing", "๐Ÿ˜ท particulates"] assert got[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub")] got = rows(keyboard({"pollen", "forecast"}, KINDS, "sub")) - assert got[0][0] == ("โ–พ โž• Subscribe", "al:close") + assert got[0][0] == ("โ–พ โž• Sub", "al:close") assert got[1:] == [[("๐ŸŒผ pollen & asthma", "al:on:pollen:sub"), ("๐Ÿ”„ forecast changes", "al:on:forecast:sub")], [("๐Ÿ”” Subscribe to all", "al:on:all:sub")]] assert not any(data == "al:noop" for row in got for _, data in row) # no button that does nothing assert options({"pollen"}, KINDS, "sub") == ["pollen"] and options({"pollen"}, ["rain", "pollen"], "unsub") == ["rain"] @@ -85,7 +85,7 @@ async def test_an_alert_goes_to_the_chats_subscribed_to_its_type_and_carries_the await notifier("Gusts", kind="gusts") assert [(c, t.split("\n")[0]) for c, t, _ in bot.sent] == [(1, "It's raining"), (1, "Gusts"), (2, "Gusts")] markup = bot.sent[0][2]["reply_markup"] - assert rows(markup) == [[("โž• Subscribe โ–ธ", "al:open:sub:rain"), ("โž– Unsubscribe โ–ธ", "al:open:unsub:rain"), + assert rows(markup) == [[("โž• Sub โ–ธ", "al:open:sub:rain"), ("โž– Unsub โ–ธ", "al:open:unsub:rain"), ("โš™๏ธ Settings โ–ธ", "al:open:set:rain")]] # the alert's own type rides along assert bot.sent[0][1] == "It's raining" # no footer: the buttons say how to change it assert await notifier.to_chat(2, "x", kind="rain") is False and await notifier.to_chat(2, "x", kind="uv") is True @@ -137,7 +137,7 @@ async def test_pressing_unsubscribe_then_a_type_updates_the_state_and_redraws_th await press(bot, q) assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("โ–พ โž– Unsubscribe", "al:close") and "๐ŸŒง๏ธ rain" not in [t for row in drawn[1:] for t, _ in row] + assert drawn[0][1] == ("โ–พ โž– Unsub", "al:close") and "๐ŸŒง๏ธ rain" not in [t for row in drawn[1:] for t, _ in row] q = Query("al:on:rain:sub") await press(bot, q) assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" @@ -205,7 +205,7 @@ async def reply_text(body, **kw): update = NS(effective_message=NS(reply_text=reply_text, chat_id=1), effective_chat=chat, effective_user=NS(full_name="Rob", username="rob")) await bot.on_alerts(update, NS(args=[])) assert replies[0][0].startswith(TITLE) and "โœ… On: rain, rain predicted, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes" in replies[0][0] - assert rows(replies[0][1]["reply_markup"])[0] == [("โž• Subscribe โ–ธ", "al:open:sub"), ("โž– Unsubscribe โ–ธ", "al:open:unsub"), ("โš™๏ธ Settings โ–ธ", "al:open:set")] + assert rows(replies[0][1]["reply_markup"])[0] == [("โž• Sub โ–ธ", "al:open:sub"), ("โž– Unsub โ–ธ", "al:open:unsub"), ("โš™๏ธ Settings โ–ธ", "al:open:set")] await bot.on_alerts(update, NS(args=["off"])) assert not state.alerts_on(1) and "โœ… On: nothing" in replies[1][0] await bot.on_alerts(update, NS(args=["on"])) @@ -258,7 +258,7 @@ async def spy(chat_id, text, link=None, kind=None): def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("โž• Subscribe โ–ธ", "al:open:sub:uv"), ("โ–พ โž– Unsubscribe", "al:close:uv")], + assert got == [[("โž• Sub โ–ธ", "al:open:sub:uv"), ("โ–พ โž– Unsub", "al:close:uv")], [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("Other โ–ธ", "al:open:other:uv")]] other = rows(keyboard({"pollen", "forecast"}, KINDS, "other", parent="uv")) assert other[1:3] == [[("โ— ๐Ÿงด UV", "al:off:uv:other:uv")], [("โ–พ Other", "al:open:unsub:uv")]] @@ -289,7 +289,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th q = Query("al:open:unsub:uv", text="โ˜€๏ธ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("โ–พ โž– Unsubscribe", "al:close:uv") and drawn[1:] == [ + assert drawn[0][1] == ("โ–พ โž– Unsub", "al:close:uv") and drawn[1:] == [ [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("Other โ–ธ", "al:open:other:uv")]] q = Query("al:open:other:uv", text="โ˜€๏ธ UV 10") await press(bot, q) @@ -300,7 +300,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th assert "Other" not in str(rows(q.edits[0][1])) and rows(q.edits[0][1])[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub:uv")] q = Query("al:off:all:unsub:uv", text="โ˜€๏ธ UV 10") # and "all" turns the rest off, closing the empty section await press(bot, q) - assert rows(q.edits[0][1]) == [[("โž• Subscribe โ–ธ", "al:open:sub:uv"), ("โž– Unsubscribe โ–ธ", "al:open:unsub:uv"), + assert rows(q.edits[0][1]) == [[("โž• Sub โ–ธ", "al:open:sub:uv"), ("โž– Unsub โ–ธ", "al:open:unsub:uv"), ("โš™๏ธ Settings โ–ธ", "al:open:set:uv")]] q = Query("al:open:unsub:uv", text="โ˜€๏ธ UV 10") # nothing is on now: a toast, no menu await press(bot, q) @@ -389,7 +389,7 @@ async def test_an_alert_has_a_sound_only_in_a_chat_that_turned_it_on_and_groups_ flags = {(c, t.split("\n")[0]): kw["disable_notification"] for c, t, kw in bot.sent} assert flags[(1, "It's raining")] is False and flags[(1, "UV")] is True and flags[(-5, "It's raining")] is True group = next(kw for c, _, kw in bot.sent if c == -5) - assert [b[0] for b in rows(group["reply_markup"])[0]] == ["โž• Subscribe โ–ธ", "โž– Unsubscribe โ–ธ"] + assert [b[0] for b in rows(group["reply_markup"])[0]] == ["โž• Sub โ–ธ", "โž– Unsub โ–ธ"] async def test_a_chat_that_became_a_supergroup_is_followed_not_a_crash(tmp_path): diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index f552bf7..cd183ef 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -708,7 +708,7 @@ async def send_message(self, chat_id, text, **kw): await m.check(NOW) assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("โ˜” Irrigation paused") for _, text, _ in delivered) markup = delivered[0][2]["reply_markup"] - assert rows(markup) == [[("โž• Subscribe โ–ธ", "al:open:sub:irrigation_pause"), ("โž– Unsubscribe โ–ธ", "al:open:unsub:irrigation_pause"), + assert rows(markup) == [[("โž• Sub โ–ธ", "al:open:sub:irrigation_pause"), ("โž– Unsub โ–ธ", "al:open:unsub:irrigation_pause"), ("โš™๏ธ Settings โ–ธ", "al:open:set:irrigation_pause")]] from lib.alerts.menu import keyboard kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) From a879c41642796cec49476bfefca19d0fdfdeb083 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:30:26 +0000 Subject: [PATCH 9/9] =?UTF-8?q?Menu=20buttons:=20=E2=96=B8=20closed=20/=20?= =?UTF-8?q?=E2=96=BE=20open=20in=20the=20same=20place=20after=20the=20text?= =?UTF-8?q?,=20from=20one=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/alerts/menu.py | 14 ++++++++------ lib/buttons.py | 7 +++++++ lib/irrigation/menu.py | 6 ++++-- tests/test_alert_menu.py | 20 ++++++++++---------- tests/test_irrigation.py | 4 ++-- 5 files changed, 31 insertions(+), 20 deletions(-) create mode 100644 lib/buttons.py diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index 10e4e0e..b5b7605 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -14,6 +14,8 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from ..buttons import menu_text + # kind -> the name on the button, in the order listed LABELS = {"rain": "rain", "rain_likely": "rain predicted", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing", "air": "particulates", "pollen": "pollen & asthma", "forecast": "forecast changes", @@ -51,7 +53,7 @@ def _type_rows(items: list[str], parent: str | None, more: bool, button, other_d one type. `button(kind)` draws one type.""" rows: list[list] = [] if parent in items and len(items) > 1: - rows += [[button(parent)], [_button("โ–พ Other" if more else "Other โ–ธ", other_data[1] if more else other_data[0])]] + rows += [[button(parent)], [_button(menu_text("Other", more), other_data[1] if more else other_data[0])]] items = [k for k in items if k != parent] if more else [] elif more: more = False # nothing to put under Other @@ -64,8 +66,8 @@ def _type_rows(items: list[str], parent: str | None, more: bool, button, other_d def keyboard(muted: set[str], available: list[str], open: str | None = None, parent: str | None = None, loud: set[str] | None = None) -> InlineKeyboardMarkup: """The menu: Subscribe, Unsubscribe and (private chats) Settings, one section open at a time (`open`: sub, unsub, other, set, - snd_on, snd_off, or snd_on_o / snd_off_o, the Other list of a sound menu) with its options under it. A โ–ธ marks a button that opens - a menu, โ–พ the open one; the rest do something. `parent` is the type of the alert the buttons sit under (carried in every button): + snd_on, snd_off, or snd_on_o / snd_off_o, the Other list of a sound menu) with its options under it. A โ–ธ after the text marks a button that opens + a menu, โ–พ (in the same place) the open one; the rest do something. `parent` is the type of the alert the buttons sit under (carried in every button): its lists put that type first (โ—) and the rest under "Other". `loud`: the types whose alerts make a notification sound; None (a group) leaves out Settings, which holds two menus, Enable and Disable notification sounds, over the subscribed types.""" tail = f":{parent}" if parent else "" @@ -73,8 +75,8 @@ def keyboard(muted: set[str], available: list[str], open: str | None = None, par if open == "other" and not (parent in subscribed and len(subscribed) > 1): open = "unsub" unsub_open = open in ("unsub", "other") - def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: # a menu button: โ–ธ closed, โ–พ open - return _button(f"โ–พ {text}" if is_open else f"{text} โ–ธ", f"al:close{tail}" if is_open else f"al:open:{section}{tail}") + def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: + return _button(menu_text(text, is_open), f"al:close{tail}" if is_open else f"al:open:{section}{tail}") rows = [[head(open == "sub", "โž• Sub", "sub"), head(unsub_open, "โž– Unsub", "unsub")]] if loud is not None: rows[0].append(head(open in SETTINGS, "โš™๏ธ Settings", "set")) @@ -85,7 +87,7 @@ def head(is_open: bool, text: str, section: str) -> InlineKeyboardButton: # a here = open in (section, f"{section}_o") if not items: continue - rows.append([_button(f"โ–พ {heading}" if here else f"{heading} โ–ธ", f"al:open:set{tail}" if here else f"al:open:{section}{tail}")]) + rows.append([_button(menu_text(heading, here), f"al:open:set{tail}" if here else f"al:open:{section}{tail}")]) if here: verb = "on" if section == "snd_on" else "off" rows += _type_rows(items, parent, open == f"{section}_o", lambda k: _button(mark(k), f"al:snd:{k}:{verb}{tail}"), diff --git a/lib/buttons.py b/lib/buttons.py new file mode 100644 index 0000000..af7135e --- /dev/null +++ b/lib/buttons.py @@ -0,0 +1,7 @@ +"""Shared look of the bot's inline buttons.""" + + +def menu_text(text: str, is_open: bool) -> str: + """The label of a button that opens a menu: โ–ธ after the text while it is closed, โ–พ in the same place once it is open (so pressing it + looks like the arrow turning). Buttons that do something have neither.""" + return f"{text} {'โ–พ' if is_open else 'โ–ธ'}" diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py index 4ed2538..9cdb17d 100644 --- a/lib/irrigation/menu.py +++ b/lib/irrigation/menu.py @@ -2,7 +2,7 @@ Collapsed: [๐Ÿ’ง Water] [โธ Pause] / [๐Ÿ”” Alerts] [๐Ÿ”„ Refresh]. While the valve is on, a [Water off โŒ] row follows. While the timer is paused, Pause is [โ–ถ๏ธ Unpause] (one press ends the pause) and Water is hidden. A press on Water, Pause or Alerts opens its options under the -rows (โ–พ marks the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has +rows (โ–ธ after the text marks a menu, โ–พ the open one; pressing it again closes them). Water lists 5, 10, 20 and 30 minutes (a run is always timed). Alerts has one button per alert type, each naming what pressing it does for this chat (enable or disable). Callback data (short, self-contained, so an old message's buttons still work after a restart): @@ -16,6 +16,8 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, MessageEntity +from ..buttons import menu_text + TITLE = "๐ŸŒฑ Irrigation" WATER_MINUTES = (5, 10, 20, 30) DELAYS = ("24h", "48h", "72h", "cancel") @@ -90,7 +92,7 @@ def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = names what pressing it does (disable when it is on, enable when it is off). `paused`: the timer is paused, so its button unpauses and Water is hidden. `running`: the valve is on, so Water off is a top-level row.""" def head(name: str, text: str) -> InlineKeyboardButton: - return _button(f"โ–พ {text}" if open == name else f"{text} โ–ธ", "ir:close" if open == name else f"ir:open:{name}") # โ–ธ closed, โ–พ open + return _button(menu_text(text, open == name), "ir:close" if open == name else f"ir:open:{name}") delay = _button("โ–ถ๏ธ Unpause", "ir:delay:cancel") if paused else head("delay", "โธ Pause") rows = [[delay] if paused else [head("water", "๐Ÿ’ง Water"), delay], [head("alerts", "๐Ÿ”” Alerts"), _button("๐Ÿ”„ Refresh", "ir:refresh")]] diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 84d9b5f..3c8872a 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -21,12 +21,12 @@ def test_the_collapsed_menu_is_subscribe_and_unsubscribe(): def test_unsubscribe_opens_the_types_that_are_on_and_subscribe_the_types_that_are_off(): got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub")) - assert got[0] == [("โž• Sub โ–ธ", "al:open:sub"), ("โ–พ โž– Unsub", "al:close")] + assert got[0] == [("โž• Sub โ–ธ", "al:open:sub"), ("โž– Unsub โ–พ", "al:close")] assert got[1] == [("๐ŸŒง๏ธ rain", "al:off:rain:unsub"), ("๐ŸŒฆ๏ธ rain predicted", "al:off:rain_likely:unsub")] assert [text for row in got[1:-1] for text, _ in row] == ["๐ŸŒง๏ธ rain", "๐ŸŒฆ๏ธ rain predicted", "๐Ÿ’จ gusts", "๐Ÿงด UV", "๐ŸŒก๏ธ temperature crossing", "๐Ÿ˜ท particulates"] assert got[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:unsub")] got = rows(keyboard({"pollen", "forecast"}, KINDS, "sub")) - assert got[0][0] == ("โ–พ โž• Sub", "al:close") + assert got[0][0] == ("โž• Sub โ–พ", "al:close") assert got[1:] == [[("๐ŸŒผ pollen & asthma", "al:on:pollen:sub"), ("๐Ÿ”„ forecast changes", "al:on:forecast:sub")], [("๐Ÿ”” Subscribe to all", "al:on:all:sub")]] assert not any(data == "al:noop" for row in got for _, data in row) # no button that does nothing assert options({"pollen"}, KINDS, "sub") == ["pollen"] and options({"pollen"}, ["rain", "pollen"], "unsub") == ["rain"] @@ -137,7 +137,7 @@ async def test_pressing_unsubscribe_then_a_type_updates_the_state_and_redraws_th await press(bot, q) assert state.muted(1) == {"rain"} and q.toast == "Rain alerts off in this chat" drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("โ–พ โž– Unsub", "al:close") and "๐ŸŒง๏ธ rain" not in [t for row in drawn[1:] for t, _ in row] + assert drawn[0][1] == ("โž– Unsub โ–พ", "al:close") and "๐ŸŒง๏ธ rain" not in [t for row in drawn[1:] for t, _ in row] q = Query("al:on:rain:sub") await press(bot, q) assert state.muted(1) == set() and q.toast == "Rain alerts on in this chat" @@ -258,10 +258,10 @@ async def spy(chat_id, text, link=None, kind=None): def test_under_an_alert_the_unsubscribe_list_is_its_type_then_other_which_holds_the_rest_and_all(): got = rows(keyboard({"pollen", "forecast"}, KINDS, "unsub", parent="uv")) - assert got == [[("โž• Sub โ–ธ", "al:open:sub:uv"), ("โ–พ โž– Unsub", "al:close:uv")], + assert got == [[("โž• Sub โ–ธ", "al:open:sub:uv"), ("โž– Unsub โ–พ", "al:close:uv")], [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("Other โ–ธ", "al:open:other:uv")]] other = rows(keyboard({"pollen", "forecast"}, KINDS, "other", parent="uv")) - assert other[1:3] == [[("โ— ๐Ÿงด UV", "al:off:uv:other:uv")], [("โ–พ Other", "al:open:unsub:uv")]] + assert other[1:3] == [[("โ— ๐Ÿงด UV", "al:off:uv:other:uv")], [("Other โ–พ", "al:open:unsub:uv")]] listed = [d for r in other[3:-1] for _, d in r] assert listed and all(d.startswith("al:off:") and d.endswith(":other:uv") and ":uv:" not in d for d in listed) # the rest, not its own type assert other[-1] == [("๐Ÿ”• Unsubscribe from all", "al:off:all:other:uv")] @@ -289,7 +289,7 @@ async def test_the_alerts_own_type_survives_opening_unsubscribing_and_closing_th q = Query("al:open:unsub:uv", text="โ˜€๏ธ UV 10") await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][1] == ("โ–พ โž– Unsub", "al:close:uv") and drawn[1:] == [ + assert drawn[0][1] == ("โž– Unsub โ–พ", "al:close:uv") and drawn[1:] == [ [("โ— ๐Ÿงด UV", "al:off:uv:unsub:uv")], [("Other โ–ธ", "al:open:other:uv")]] q = Query("al:open:other:uv", text="โ˜€๏ธ UV 10") await press(bot, q) @@ -336,11 +336,11 @@ async def test_settings_hold_enable_and_disable_sub_menus_of_notification_sounds q = Query("al:open:set") # /alerts: Settings opens the two sub-menus (โ–ธ marks a menu) await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[0][2] == ("โ–พ โš™๏ธ Settings", "al:close") and drawn[1:] == [[("๐Ÿ”” Enable notification sounds โ–ธ", "al:open:snd_on")]] + assert drawn[0][2] == ("โš™๏ธ Settings โ–พ", "al:close") and drawn[1:] == [[("๐Ÿ”” Enable notification sounds โ–ธ", "al:open:snd_on")]] q = Query("al:open:snd_on") # only the types with the sound off, as buttons await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[1] == [("โ–พ ๐Ÿ”” Enable notification sounds", "al:open:set")] and ("๐ŸŒง๏ธ rain", "al:snd:rain:on") in [b for r in drawn[2:] for b in r] + assert drawn[1] == [("๐Ÿ”” Enable notification sounds โ–พ", "al:open:set")] and ("๐ŸŒง๏ธ rain", "al:snd:rain:on") in [b for r in drawn[2:] for b in r] q = Query("al:snd:rain:on") await press(bot, q) assert state.loud(1) == {"rain"} and "make a sound" in q.toast @@ -358,11 +358,11 @@ async def test_settings_hold_enable_and_disable_sub_menus_of_notification_sounds q = Query("al:open:snd_on_o:uv", text="โ˜€๏ธ UV 10") # Other holds the rest await press(bot, q) drawn = rows(q.edits[0][1]) - assert drawn[3] == [("โ–พ Other", "al:open:snd_on:uv")] and ("๐ŸŒง๏ธ rain", "al:snd:rain:on:uv") in [b for r in drawn for b in r] + assert drawn[3] == [("Other โ–พ", "al:open:snd_on:uv")] and ("๐ŸŒง๏ธ rain", "al:snd:rain:on:uv") in [b for r in drawn for b in r] assert "al:snd:uv:on:uv" not in [d for r in drawn[4:] for _, d in r] q = Query("al:snd:rain:on:uv", text="โ˜€๏ธ UV 10") # a press in Other stays in Other await press(bot, q) - assert state.loud(1) == {"rain"} and ("โ–พ Other", "al:open:snd_on:uv") in [b for r in rows(q.edits[0][1]) for b in r] + assert state.loud(1) == {"rain"} and ("Other โ–พ", "al:open:snd_on:uv") in [b for r in rows(q.edits[0][1]) for b in r] q = Query("al:snd:uv:on:uv", text="โ˜€๏ธ UV 10") # a press on its own type stays on its level await press(bot, q) assert state.loud(1) == {"rain", "uv"} and ("๐Ÿ”• Disable notification sounds โ–ธ", "al:open:snd_off:uv") in [b for r in rows(q.edits[0][1]) for b in r] diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py index cd183ef..c4b94a8 100644 --- a/tests/test_irrigation.py +++ b/tests/test_irrigation.py @@ -165,7 +165,7 @@ def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): collapsed = [[("๐Ÿ’ง Water โ–ธ", "ir:open:water"), ("โธ Pause โ–ธ", "ir:open:delay")], [("๐Ÿ”” Alerts โ–ธ", "ir:open:alerts"), ("๐Ÿ”„ Refresh", "ir:refresh")]] assert rows(menu.keyboard(False, False)) == collapsed == rows(menu.keyboard(True, True)) # the alert states show only once Alerts is open water = rows(menu.keyboard(True, True, "water")) - assert water[0][0] == ("โ–พ ๐Ÿ’ง Water", "ir:close") and water[-1] == [(f"{m} min", f"ir:water:{m}") for m in (5, 10, 20, 30)] + assert water[0][0] == ("๐Ÿ’ง Water โ–พ", "ir:close") and water[-1] == [(f"{m} min", f"ir:water:{m}") for m in (5, 10, 20, 30)] assert "ir:sw:off" not in [data for row in water for _, data in row] # Off is not in the Water list: it is a top-level row while on assert rows(menu.keyboard(True, True, running=True)) == collapsed + [[("Water off โŒ", "ir:sw:off")]] # only while the valve is on assert rows(menu.keyboard(True, True, paused=True, running=True))[-1] == [("Water off โŒ", "ir:sw:off")] @@ -182,7 +182,7 @@ def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): (False, True, ("๐Ÿ”” Enable battery alerts", "ir:batt:on"), ("๐Ÿ”• Disable pause alerts", "ir:pause:off")), (True, True, ("๐Ÿ”• Disable battery alerts", "ir:batt:off"), ("๐Ÿ”• Disable pause alerts", "ir:pause:off"))): alerts = rows(menu.keyboard(battery, pause, "alerts")) - assert alerts[:2] == collapsed[:1] + [[("โ–พ ๐Ÿ”” Alerts", "ir:close"), ("๐Ÿ”„ Refresh", "ir:refresh")]] + assert alerts[:2] == collapsed[:1] + [[("๐Ÿ”” Alerts โ–พ", "ir:close"), ("๐Ÿ”„ Refresh", "ir:refresh")]] assert alerts[2:] == [[battery_button], [pause_button]] # each names the action it takes for section in (None, *menu.SECTIONS): for battery in (True, False):