mirror of
https://github.com/prdlk/omarchy-beeper.git
synced 2026-11-02 13:12:48 +00:00
feat: omarchy-beeper bar widget for unread Beeper chats
This commit is contained in:
commit
7fc78f1075
17 files changed
+3483
No files matched your search
@@ -0,0 +1,186 @@
|
||||
"""Shared helpers for mocked contract tests. Never talks to Beeper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
from contextlib import contextmanager, redirect_stdout
|
||||
from io import BytesIO, StringIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LIB = ROOT / "lib"
|
||||
|
||||
if str(LIB) not in sys.path:
|
||||
sys.path.insert(0, str(LIB))
|
||||
|
||||
|
||||
def capture_json(fn, *args, **kwargs) -> dict:
|
||||
buf = StringIO()
|
||||
with redirect_stdout(buf):
|
||||
try:
|
||||
fn(*args, **kwargs)
|
||||
except SystemExit:
|
||||
pass
|
||||
text = buf.getvalue().strip()
|
||||
if not text:
|
||||
raise AssertionError("expected JSON on stdout")
|
||||
return json.loads(text.splitlines()[-1])
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
"""Enough of an http.client.HTTPResponse for read_http_body()."""
|
||||
|
||||
def __init__(self, payload: object, status: int = 200, content_length: object | None = None):
|
||||
if isinstance(payload, (bytes, bytearray)):
|
||||
raw = bytes(payload)
|
||||
else:
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
self._buf = BytesIO(raw)
|
||||
self.status = status
|
||||
declared = len(raw) if content_length is None else content_length
|
||||
self.headers = {} if declared is False else {"Content-Length": str(declared)}
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return self._buf.read(n)
|
||||
|
||||
def __enter__(self) -> FakeResponse:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class Call:
|
||||
def __init__(self, method: str, url: str, body: object) -> None:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
self.method = method
|
||||
self.url = url
|
||||
self.host = parsed.netloc
|
||||
self.path = parsed.path
|
||||
self.query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||||
self.body = body
|
||||
|
||||
def one(self, key: str, default: str = "") -> str:
|
||||
values = self.query.get(key) or []
|
||||
return values[0] if values else default
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"<Call {self.method} {self.path} {self.query}>"
|
||||
|
||||
|
||||
class FakeHttp:
|
||||
"""Route (METHOD, path) to a callable or a fixed response."""
|
||||
|
||||
def __init__(self, routes: dict[tuple[str, str], object] | None = None) -> None:
|
||||
self.routes = dict(routes or {})
|
||||
self.calls: list[Call] = []
|
||||
|
||||
def route(self, method: str, path: str, handler: object) -> None:
|
||||
self.routes[(method, path)] = handler
|
||||
|
||||
def open(self, req, timeout: int | None = None): # noqa: D102 - urlopen shape
|
||||
body = None
|
||||
if req.data:
|
||||
body = json.loads(req.data.decode("utf-8"))
|
||||
call = Call(req.get_method(), req.full_url, body)
|
||||
self.calls.append(call)
|
||||
handler = self.routes.get((call.method, call.path))
|
||||
if handler is None:
|
||||
raise urllib.error.HTTPError(call.url, 404, "Not Found", {}, BytesIO(b"{}"))
|
||||
result = handler(call) if callable(handler) else handler
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
if isinstance(result, FakeResponse):
|
||||
return result
|
||||
return FakeResponse(result)
|
||||
|
||||
def paths(self, method: str = "") -> list[str]:
|
||||
return [c.path for c in self.calls if not method or c.method == method]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def fake_api(http: FakeHttp):
|
||||
import beeper
|
||||
|
||||
with patch.object(beeper, "_OPENER", http):
|
||||
yield http
|
||||
|
||||
|
||||
def http_error(status: int, message: str = "nope", url: str = "http://localhost:23373/v1/x"):
|
||||
body = json.dumps({"message": message, "code": "error"}).encode("utf-8")
|
||||
return urllib.error.HTTPError(url, status, message, {"Content-Length": str(len(body))}, BytesIO(body))
|
||||
|
||||
|
||||
def url_error(reason: Exception | None = None):
|
||||
return urllib.error.URLError(reason or ConnectionRefusedError(111, "Connection refused"))
|
||||
|
||||
|
||||
def chat(
|
||||
chat_id: str,
|
||||
*,
|
||||
title: str = "Ada Lovelace",
|
||||
network: str = "WhatsApp",
|
||||
account: str = "local-whatsapp_1",
|
||||
last_activity: str = "2026-02-11T10:00:00.000Z",
|
||||
unread: int = 2,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": chat_id,
|
||||
"localChatID": "1",
|
||||
"accountID": account,
|
||||
"network": network,
|
||||
"title": title,
|
||||
"type": "single",
|
||||
"participants": {"items": [], "hasMore": False, "total": 1},
|
||||
"lastActivity": last_activity,
|
||||
"unreadCount": unread,
|
||||
"isArchived": False,
|
||||
"isMuted": False,
|
||||
"isPinned": False,
|
||||
}
|
||||
|
||||
|
||||
def message(
|
||||
chat_id: str,
|
||||
*,
|
||||
text: str = "are you coming tonight?",
|
||||
sender: str = "Ada",
|
||||
timestamp: str = "2026-02-11T10:00:00.000Z",
|
||||
kind: str = "TEXT",
|
||||
sort_key: str = "100",
|
||||
) -> dict:
|
||||
return {
|
||||
"id": "m1",
|
||||
"chatID": chat_id,
|
||||
"accountID": "local-whatsapp_1",
|
||||
"senderID": "@ada:example",
|
||||
"senderName": sender,
|
||||
"timestamp": timestamp,
|
||||
"sortKey": sort_key,
|
||||
"type": kind,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
def account(account_id: str, network: str) -> dict:
|
||||
return {
|
||||
"accountID": account_id,
|
||||
"loginID": account_id,
|
||||
"bridge": {"id": network.lower(), "type": network.lower(), "provider": "local"},
|
||||
"network": network,
|
||||
"user": {"id": "@me:example", "isSelf": True},
|
||||
"status": "connected",
|
||||
}
|
||||
|
||||
|
||||
def search_page(chats: list[dict], has_more: bool = False, cursor: str | None = None) -> dict:
|
||||
return {
|
||||
"items": chats,
|
||||
"hasMore": has_more,
|
||||
"oldestCursor": cursor,
|
||||
"newestCursor": None,
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from support import ( # noqa: I001 - puts lib/ on sys.path
|
||||
FakeHttp,
|
||||
FakeResponse,
|
||||
account,
|
||||
capture_json,
|
||||
chat,
|
||||
fake_api,
|
||||
http_error,
|
||||
message,
|
||||
search_page,
|
||||
url_error,
|
||||
)
|
||||
|
||||
import beeper
|
||||
import cli
|
||||
import common
|
||||
|
||||
TOKEN = "test-token"
|
||||
SEARCH = "/v1/chats/search"
|
||||
ACCOUNTS = "/v1/accounts"
|
||||
FOCUS = "/v1/focus"
|
||||
|
||||
|
||||
def messages_path(chat_id: str) -> str:
|
||||
import urllib.parse
|
||||
|
||||
return f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/messages"
|
||||
|
||||
|
||||
def read_path(chat_id: str) -> str:
|
||||
import urllib.parse
|
||||
|
||||
return f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/read"
|
||||
|
||||
|
||||
def wire(http: FakeHttp, chats: list[dict], accounts: list[dict] | None = None) -> FakeHttp:
|
||||
"""Standard happy-path routing: one search page, snippets, marks."""
|
||||
http.route("GET", SEARCH, lambda call: search_page(chats))
|
||||
http.route("GET", ACCOUNTS, accounts if accounts is not None else [account("a1", "WhatsApp")])
|
||||
http.route("POST", FOCUS, {"success": True})
|
||||
for item in chats:
|
||||
chat_id = item["id"]
|
||||
http.route("GET", messages_path(chat_id), {"items": [message(chat_id)], "hasMore": False})
|
||||
http.route("POST", read_path(chat_id), item)
|
||||
return http
|
||||
|
||||
|
||||
def run_list(http: FakeHttp, page: str = "", limit: int = 25) -> dict:
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN), patch.dict(
|
||||
"os.environ", {"OMARCHY_BEEPER_MAX": str(limit)}, clear=False
|
||||
):
|
||||
return capture_json(cli.cmd_list, page)
|
||||
|
||||
|
||||
class UrlBindingTests(unittest.TestCase):
|
||||
def test_every_call_stays_on_the_local_api(self) -> None:
|
||||
self.assertTrue(beeper._url("/v1/accounts").startswith("http://localhost:23373/"))
|
||||
|
||||
def test_refuses_paths_outside_v1(self) -> None:
|
||||
for bad in ("/oauth/token", "v1/accounts", "http://evil.test/v1/x", "//evil.test/v1/x"):
|
||||
with self.assertRaises(beeper.ApiError, msg=bad):
|
||||
beeper._url(bad)
|
||||
|
||||
def test_booleans_and_lists_become_query_values(self) -> None:
|
||||
url = beeper._url("/v1/chats/search", {"unreadOnly": True, "includeMuted": False})
|
||||
self.assertIn("unreadOnly=true", url)
|
||||
self.assertIn("includeMuted=false", url)
|
||||
|
||||
def test_redirects_are_not_followed(self) -> None:
|
||||
self.assertIsNone(
|
||||
beeper._NoRedirect().redirect_request(None, None, 302, "Found", {}, "http://evil.test/")
|
||||
)
|
||||
|
||||
|
||||
class ListContractTests(unittest.TestCase):
|
||||
def test_filters_out_muted_archived_and_low_priority(self) -> None:
|
||||
http = wire(FakeHttp(), [chat("!a:x")])
|
||||
run_list(http)
|
||||
search = next(c for c in http.calls if c.path == SEARCH)
|
||||
self.assertEqual(search.one("unreadOnly"), "true")
|
||||
self.assertEqual(search.one("includeMuted"), "false")
|
||||
self.assertEqual(search.one("inbox"), "primary")
|
||||
|
||||
def test_one_row_per_chat_newest_first(self) -> None:
|
||||
chats = [
|
||||
chat("!old:x", title="Old", last_activity="2026-02-01T00:00:00Z"),
|
||||
chat("!new:x", title="New", last_activity="2026-02-11T00:00:00Z"),
|
||||
chat("!mid:x", title="Mid", last_activity="2026-02-05T00:00:00Z"),
|
||||
]
|
||||
payload = run_list(wire(FakeHttp(), chats))
|
||||
self.assertEqual([row["subject"] for row in payload["messages"]], ["New", "Mid", "Old"])
|
||||
self.assertEqual(payload["unread"], 3)
|
||||
|
||||
def test_row_shape_matches_the_panel_contract(self) -> None:
|
||||
chats = [chat("!a:x", title="Ada Lovelace\n", network="Signal")]
|
||||
payload = run_list(wire(FakeHttp(), chats))
|
||||
row = payload["messages"][0]
|
||||
self.assertEqual(
|
||||
sorted(row),
|
||||
["from", "id", "labels", "snippet", "subject", "threadId", "ts", "url"],
|
||||
)
|
||||
self.assertEqual(row["subject"], "Ada Lovelace")
|
||||
self.assertEqual(row["threadId"], "!a:x")
|
||||
self.assertEqual(row["id"], common.encode_id("!a:x"))
|
||||
self.assertEqual(common.decode_id(row["id"]), "!a:x")
|
||||
self.assertEqual(row["labels"], ["Signal"])
|
||||
self.assertEqual(row["from"], "Ada")
|
||||
self.assertEqual(row["snippet"], "are you coming tonight?")
|
||||
self.assertEqual(row["url"], "")
|
||||
self.assertEqual(row["ts"], 1770804000) # 2026-02-11T10:00:00Z
|
||||
|
||||
def test_unread_is_the_pile_not_the_page(self) -> None:
|
||||
chats = [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i:02d}:00Z") for i in range(40)]
|
||||
payload = run_list(wire(FakeHttp(), chats), limit=25)
|
||||
self.assertEqual(payload["unread"], 40)
|
||||
self.assertEqual(len(payload["messages"]), 25)
|
||||
self.assertEqual(payload["thisPage"], "0")
|
||||
self.assertEqual(payload["nextPage"], "25")
|
||||
|
||||
def test_second_page_is_the_offset_slice(self) -> None:
|
||||
# Descending activity, so c0 is newest and the offset slice starts at c25.
|
||||
chats = [
|
||||
chat(f"!c{i}:x", title=f"c{i}", last_activity=f"2026-02-11T10:{59 - i:02d}:00Z")
|
||||
for i in range(40)
|
||||
]
|
||||
payload = run_list(wire(FakeHttp(), chats), page="25", limit=25)
|
||||
self.assertEqual(payload["thisPage"], "25")
|
||||
self.assertEqual(len(payload["messages"]), 15)
|
||||
self.assertEqual(payload["messages"][0]["subject"], "c25")
|
||||
self.assertEqual(payload["nextPage"], "")
|
||||
|
||||
def test_bad_page_token_falls_back_to_the_first_page(self) -> None:
|
||||
payload = run_list(wire(FakeHttp(), [chat("!a:x")]), page="../etc/passwd")
|
||||
self.assertEqual(payload["thisPage"], "0")
|
||||
self.assertEqual(len(payload["messages"]), 1)
|
||||
|
||||
def test_snippets_are_only_fetched_for_the_visible_page(self) -> None:
|
||||
chats = [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i:02d}:00Z") for i in range(40)]
|
||||
http = wire(FakeHttp(), chats)
|
||||
run_list(http, limit=5)
|
||||
message_calls = [p for p in http.paths("GET") if p.endswith("/messages")]
|
||||
self.assertEqual(len(message_calls), 5)
|
||||
|
||||
def test_a_failed_snippet_only_loses_the_snippet(self) -> None:
|
||||
chats = [chat("!a:x")]
|
||||
http = wire(FakeHttp(), chats)
|
||||
http.route("GET", messages_path("!a:x"), lambda call: http_error(500, "boom"))
|
||||
payload = run_list(http)
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["messages"][0]["snippet"], "")
|
||||
self.assertEqual(payload["messages"][0]["subject"], "Ada Lovelace")
|
||||
self.assertEqual(payload["messages"][0]["ts"], 1770804000)
|
||||
|
||||
def test_media_messages_get_a_placeholder_snippet(self) -> None:
|
||||
http = wire(FakeHttp(), [chat("!a:x")])
|
||||
http.route(
|
||||
"GET",
|
||||
messages_path("!a:x"),
|
||||
{"items": [message("!a:x", text="", kind="IMAGE")], "hasMore": False},
|
||||
)
|
||||
payload = run_list(http)
|
||||
self.assertEqual(payload["messages"][0]["snippet"], "[photo]")
|
||||
|
||||
def test_newest_undeleted_message_wins(self) -> None:
|
||||
http = wire(FakeHttp(), [chat("!a:x")])
|
||||
items = [
|
||||
message("!a:x", text="older", timestamp="2026-02-11T09:00:00Z", sort_key="1"),
|
||||
message("!a:x", text="newest", timestamp="2026-02-11T11:00:00Z", sort_key="3"),
|
||||
dict(
|
||||
message("!a:x", text="deleted", timestamp="2026-02-11T12:00:00Z", sort_key="4"),
|
||||
isDeleted=True,
|
||||
),
|
||||
dict(message("!a:x", text="👍", timestamp="2026-02-11T13:00:00Z", sort_key="5"), type="REACTION"),
|
||||
]
|
||||
http.route("GET", messages_path("!a:x"), {"items": items, "hasMore": False})
|
||||
payload = run_list(http)
|
||||
self.assertEqual(payload["messages"][0]["snippet"], "newest")
|
||||
|
||||
def test_inboxes_and_account_count_come_from_accounts(self) -> None:
|
||||
chats = [
|
||||
chat("!a:x", account="wa", network="WhatsApp"),
|
||||
chat("!b:x", account="wa", network="WhatsApp"),
|
||||
chat("!c:x", account="slack", network="Slack"),
|
||||
]
|
||||
accounts = [account("wa", "WhatsApp"), account("slack", "Slack"), account("sig", "Signal")]
|
||||
payload = run_list(wire(FakeHttp(), chats, accounts))
|
||||
self.assertEqual(payload["accountCount"], 3)
|
||||
self.assertEqual(
|
||||
payload["inboxes"],
|
||||
[
|
||||
{"account": "WhatsApp", "unread": 2, "searchUrl": ""},
|
||||
{"account": "Slack", "unread": 1, "searchUrl": ""},
|
||||
{"account": "Signal", "unread": 0, "searchUrl": ""},
|
||||
],
|
||||
)
|
||||
|
||||
def test_chats_on_unknown_accounts_are_still_counted(self) -> None:
|
||||
chats = [chat("!a:x", account="ghost", network="Ghost")]
|
||||
payload = run_list(wire(FakeHttp(), chats, [account("wa", "WhatsApp")]))
|
||||
self.assertEqual(payload["inboxes"][-1], {"account": "Other", "unread": 1, "searchUrl": ""})
|
||||
|
||||
def test_caught_up_is_a_success_with_an_empty_pile(self) -> None:
|
||||
payload = run_list(wire(FakeHttp(), []))
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["unread"], 0)
|
||||
self.assertEqual(payload["messages"], [])
|
||||
self.assertEqual(payload["nextPage"], "")
|
||||
|
||||
|
||||
class PaginationWalkTests(unittest.TestCase):
|
||||
def test_walks_cursors_until_the_api_runs_out(self) -> None:
|
||||
pages = [
|
||||
search_page([chat(f"!p1-{i}:x") for i in range(3)], has_more=True, cursor="cur1"),
|
||||
search_page([chat(f"!p2-{i}:x") for i in range(2)], has_more=False, cursor=None),
|
||||
]
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(call):
|
||||
seen.append(call.one("cursor"))
|
||||
return pages[len(seen) - 1]
|
||||
|
||||
http = FakeHttp({("GET", SEARCH): handler})
|
||||
with fake_api(http):
|
||||
chats, truncated = beeper.unread_chats(TOKEN)
|
||||
self.assertEqual(seen, ["", "cur1"])
|
||||
self.assertEqual(len(chats), 5)
|
||||
self.assertFalse(truncated)
|
||||
|
||||
def test_walk_stops_at_the_fetch_cap_and_reports_truncation(self) -> None:
|
||||
def handler(call):
|
||||
return search_page(
|
||||
[chat(f"!c{call.one('cursor', '0')}-{i}:x") for i in range(150)],
|
||||
has_more=True,
|
||||
cursor="next",
|
||||
)
|
||||
|
||||
http = FakeHttp({("GET", SEARCH): handler})
|
||||
with fake_api(http):
|
||||
chats, truncated = beeper.unread_chats(TOKEN, cap=common.FETCH_CAP)
|
||||
self.assertEqual(len(chats), common.FETCH_CAP)
|
||||
self.assertTrue(truncated)
|
||||
|
||||
def test_truncated_walk_warns_in_the_list_payload(self) -> None:
|
||||
def handler(call):
|
||||
return search_page(
|
||||
[chat(f"!c{call.one('cursor', '0')}-{i}:x") for i in range(200)],
|
||||
has_more=True,
|
||||
cursor="next",
|
||||
)
|
||||
|
||||
http = wire(FakeHttp(), [])
|
||||
http.route("GET", SEARCH, handler)
|
||||
for i in range(200):
|
||||
cid = f"!c0-{i}:x"
|
||||
http.route("GET", messages_path(cid), {"items": [message(cid)], "hasMore": False})
|
||||
payload = run_list(http, limit=2)
|
||||
self.assertEqual(payload["unread"], common.FETCH_CAP)
|
||||
self.assertIn(str(common.FETCH_CAP), payload["warning"])
|
||||
|
||||
def test_duplicate_chats_across_pages_are_collapsed(self) -> None:
|
||||
pages = [
|
||||
search_page([chat("!dup:x"), chat("!a:x")], has_more=True, cursor="cur1"),
|
||||
search_page([chat("!dup:x"), chat("!b:x")], has_more=False, cursor=None),
|
||||
]
|
||||
state = {"n": 0}
|
||||
|
||||
def handler(call):
|
||||
page = pages[state["n"]]
|
||||
state["n"] += 1
|
||||
return page
|
||||
|
||||
http = FakeHttp({("GET", SEARCH): handler})
|
||||
with fake_api(http):
|
||||
chats, _ = beeper.unread_chats(TOKEN)
|
||||
self.assertEqual(len(chats), 3)
|
||||
|
||||
|
||||
class ReadTests(unittest.TestCase):
|
||||
def test_read_marks_exactly_that_chat(self) -> None:
|
||||
http = wire(FakeHttp(), [chat("!a:x")])
|
||||
opaque = common.encode_id("!a:x")
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_read, opaque)
|
||||
self.assertEqual(payload, {"ok": True})
|
||||
self.assertEqual(http.paths("POST"), [read_path("!a:x")])
|
||||
|
||||
def test_read_refuses_a_forged_id(self) -> None:
|
||||
http = FakeHttp()
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_read, "beeper:!!!")
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertEqual(http.calls, [])
|
||||
|
||||
def test_read_reports_an_api_failure(self) -> None:
|
||||
http = wire(FakeHttp(), [chat("!a:x")])
|
||||
http.route("POST", read_path("!a:x"), lambda call: http_error(500, "bridge offline"))
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_read, common.encode_id("!a:x"))
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertIn("500", payload["error"])
|
||||
|
||||
|
||||
class ReadAllTests(unittest.TestCase):
|
||||
def _chats(self, n: int) -> list[dict]:
|
||||
return [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i % 60:02d}:00Z") for i in range(n)]
|
||||
|
||||
def _run(self, http: FakeHttp) -> dict:
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
return capture_json(cli.cmd_read_all)
|
||||
|
||||
def test_snapshots_before_marking(self) -> None:
|
||||
chats = self._chats(3)
|
||||
http = wire(FakeHttp(), chats)
|
||||
payload = self._run(http)
|
||||
self.assertEqual(payload, {"ok": True, "marked": 3})
|
||||
methods = [c.method for c in http.calls]
|
||||
self.assertEqual(methods[0], "GET")
|
||||
self.assertEqual(http.calls[0].path, SEARCH)
|
||||
self.assertNotIn("GET", methods[1:])
|
||||
|
||||
def test_walks_past_the_display_cap(self) -> None:
|
||||
chats = self._chats(300)
|
||||
http = wire(FakeHttp(), chats)
|
||||
payload = self._run(http)
|
||||
self.assertEqual(payload, {"ok": True, "marked": 300})
|
||||
self.assertEqual(len([c for c in http.calls if c.method == "POST"]), 300)
|
||||
|
||||
def test_partial_failure_keeps_the_marked_count(self) -> None:
|
||||
chats = self._chats(4)
|
||||
http = wire(FakeHttp(), chats)
|
||||
http.route("POST", read_path("!c2:x"), lambda call: http_error(500, "bridge offline"))
|
||||
payload = self._run(http)
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertEqual(payload["marked"], 3)
|
||||
self.assertIn("500", payload["error"])
|
||||
|
||||
def test_budget_stops_the_run_with_a_partial_count(self) -> None:
|
||||
chats = self._chats(5)
|
||||
http = wire(FakeHttp(), chats)
|
||||
with patch.object(cli, "READ_ALL_BUDGET", 0.0), patch.object(cli, "READ_ALL_WORKERS", 1):
|
||||
payload = self._run(http)
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertEqual(payload["marked"], 0)
|
||||
self.assertIn("stopped after", payload["error"])
|
||||
|
||||
def test_nothing_unread_is_a_no_op(self) -> None:
|
||||
payload = self._run(wire(FakeHttp(), []))
|
||||
self.assertEqual(payload, {"ok": True, "marked": 0})
|
||||
|
||||
def test_a_failed_snapshot_does_not_mark_anything(self) -> None:
|
||||
http = FakeHttp({("GET", SEARCH): lambda call: http_error(500, "index rebuilding")})
|
||||
payload = self._run(http)
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertEqual(http.paths("POST"), [])
|
||||
|
||||
|
||||
class OpenTests(unittest.TestCase):
|
||||
def test_open_focuses_the_chat(self) -> None:
|
||||
http = wire(FakeHttp(), [chat("!a:x")])
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_open, common.encode_id("!a:x"))
|
||||
self.assertEqual(payload, {"ok": True})
|
||||
focus = next(c for c in http.calls if c.path == FOCUS)
|
||||
self.assertEqual(focus.body, {"chatID": "!a:x"})
|
||||
|
||||
def test_open_without_an_id_only_focuses_the_app(self) -> None:
|
||||
http = wire(FakeHttp(), [])
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_open, "")
|
||||
self.assertEqual(payload, {"ok": True})
|
||||
focus = next(c for c in http.calls if c.path == FOCUS)
|
||||
self.assertEqual(focus.body, {})
|
||||
|
||||
def test_the_only_writes_are_mark_read_and_focus(self) -> None:
|
||||
source = (common.ROOT / "lib" / "beeper.py").read_text(encoding="utf-8")
|
||||
mutations = re.findall(
|
||||
r'"(POST|PUT|PATCH|DELETE)",\s*(?:#[^\n]*\n\s*)?(f?"[^"]+")', source
|
||||
)
|
||||
self.assertTrue(mutations, "expected to find the mutating calls")
|
||||
for method, path in mutations:
|
||||
self.assertTrue(
|
||||
path.endswith('/read"') or path == '"/v1/focus"',
|
||||
msg=f"unexpected write: {method} {path}",
|
||||
)
|
||||
|
||||
|
||||
class FailureMappingTests(unittest.TestCase):
|
||||
def test_401_asks_for_auth(self) -> None:
|
||||
http = FakeHttp({("GET", SEARCH): lambda call: http_error(401, "Unauthorized")})
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_list, "")
|
||||
self.assertEqual(payload["error"], "token missing or invalid; run: omarchy-beeper auth")
|
||||
|
||||
def test_connection_refused_says_beeper_is_not_running(self) -> None:
|
||||
http = FakeHttp({("GET", SEARCH): lambda call: url_error()})
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_list, "")
|
||||
self.assertEqual(payload["error"], "Beeper Desktop is not running")
|
||||
|
||||
def test_missing_token_is_told_apart_from_a_dead_app(self) -> None:
|
||||
http = FakeHttp({("GET", "/v1/info"): {"app": {"name": "Beeper"}}})
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=""):
|
||||
payload = capture_json(cli.cmd_list, "")
|
||||
self.assertEqual(payload["error"], "token missing or invalid; run: omarchy-beeper auth")
|
||||
|
||||
http = FakeHttp({("GET", "/v1/info"): lambda call: url_error()})
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=""):
|
||||
payload = capture_json(cli.cmd_list, "")
|
||||
self.assertEqual(payload["error"], "Beeper Desktop is not running")
|
||||
|
||||
def test_oversized_response_is_refused_before_decode(self) -> None:
|
||||
huge = b'{"items":[' + b" " * (common.MAX_HTTP_BODY + 10) + b"]}"
|
||||
http = FakeHttp({("GET", SEARCH): FakeResponse(huge, content_length=False)})
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_list, "")
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertIn("too large", payload["error"])
|
||||
|
||||
def test_declared_oversized_response_is_refused_without_reading(self) -> None:
|
||||
http = FakeHttp(
|
||||
{("GET", SEARCH): FakeResponse({"items": []}, content_length=common.MAX_HTTP_BODY + 1)}
|
||||
)
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_list, "")
|
||||
self.assertFalse(payload["ok"])
|
||||
|
||||
def test_error_bodies_are_capped(self) -> None:
|
||||
self.assertEqual(beeper.MAX_HTTP_ERROR, 64 * 1024)
|
||||
|
||||
def test_invalid_json_is_reported_not_raised(self) -> None:
|
||||
http = FakeHttp({("GET", SEARCH): FakeResponse(b"not json")})
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_list, "")
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertIn("invalid JSON", payload["error"])
|
||||
|
||||
def test_api_error_text_is_one_line(self) -> None:
|
||||
http = FakeHttp({("GET", SEARCH): lambda call: http_error(503, "a\nb\n c")})
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.cmd_list, "")
|
||||
self.assertNotIn("\n", payload["error"])
|
||||
self.assertIn("a b c", payload["error"])
|
||||
|
||||
|
||||
class CliSurfaceTests(unittest.TestCase):
|
||||
def test_unknown_command_still_prints_one_json_object(self) -> None:
|
||||
payload = capture_json(cli.main, ["frobnicate"])
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertIn("unknown command", payload["error"])
|
||||
|
||||
def test_read_without_an_id_is_a_usage_error(self) -> None:
|
||||
payload = capture_json(cli.main, ["read"])
|
||||
self.assertFalse(payload["ok"])
|
||||
|
||||
def test_limit_flag_sets_the_page_size(self) -> None:
|
||||
http = wire(FakeHttp(), [chat(f"!c{i}:x", last_activity=f"2026-02-11T10:{i:02d}:00Z") for i in range(10)])
|
||||
with fake_api(http), patch.object(cli, "load_token", return_value=TOKEN):
|
||||
payload = capture_json(cli.main, ["list", "--limit", "3"])
|
||||
self.assertEqual(len(payload["messages"]), 3)
|
||||
|
||||
def test_auth_refuses_a_non_terminal(self) -> None:
|
||||
stderr = StringIO()
|
||||
with patch("sys.stdin.isatty", return_value=False), redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
cli.cmd_auth()
|
||||
self.assertEqual(raised.exception.code, 1)
|
||||
self.assertIn("needs a terminal", stderr.getvalue())
|
||||
|
||||
def test_the_token_never_reaches_argv_or_the_shell(self) -> None:
|
||||
bin_script = (common.ROOT / "bin" / "omarchy-beeper").read_text(encoding="utf-8")
|
||||
for banned in ("--token", "$TOKEN", "token.json", "secrets"):
|
||||
self.assertNotIn(banned, bin_script)
|
||||
cli_source = (common.ROOT / "lib" / "cli.py").read_text(encoding="utf-8")
|
||||
# Read from a hidden prompt, and only ever handed to the API client.
|
||||
self.assertIn("getpass.getpass", cli_source)
|
||||
self.assertNotIn("input(", cli_source)
|
||||
panel = (common.ROOT / "Panel.qml").read_text(encoding="utf-8")
|
||||
for banned in ("Authorization", "token.json", "secrets", "XMLHttpRequest", "WebSocket"):
|
||||
self.assertNotIn(banned, panel)
|
||||
api = (common.ROOT / "lib" / "beeper.py").read_text(encoding="utf-8")
|
||||
self.assertEqual(api.count('add_header("Authorization"'), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from support import ROOT, capture_json # noqa: I001 - puts lib/ on sys.path
|
||||
|
||||
import common
|
||||
|
||||
|
||||
def _workdir() -> Path:
|
||||
# Outside the plugin folder: `omarchy plugin validate` rejects symlinks
|
||||
# anywhere inside it, and one of these tests plants one on purpose.
|
||||
return Path(tempfile.mkdtemp(prefix="omarchy-beeper-tests."))
|
||||
|
||||
|
||||
class HelperTests(unittest.TestCase):
|
||||
def test_clamp_int(self) -> None:
|
||||
self.assertEqual(common.clamp_int("25", 10, 1, 50), 25)
|
||||
self.assertEqual(common.clamp_int("0", 10, 1, 50), 1)
|
||||
self.assertEqual(common.clamp_int("99", 10, 1, 50), 50)
|
||||
self.assertEqual(common.clamp_int("nope", 10, 1, 50), 10)
|
||||
self.assertEqual(common.clamp_int(None, 7, 1, 50), 7)
|
||||
|
||||
def test_max_chats_clamps_to_page_size(self) -> None:
|
||||
with patch.dict(os.environ, {"OMARCHY_BEEPER_MAX": "500"}, clear=False):
|
||||
self.assertEqual(common.max_chats(), common.PAGE_SIZE_MAX)
|
||||
with patch.dict(os.environ, {"OMARCHY_BEEPER_MAX": "0"}, clear=False):
|
||||
self.assertEqual(common.max_chats(), 1)
|
||||
|
||||
def test_fetch_limit_caps_at_200(self) -> None:
|
||||
self.assertEqual(common.fetch_limit("200"), 200)
|
||||
self.assertEqual(common.fetch_limit("999"), 200)
|
||||
self.assertEqual(common.fetch_limit("0"), 1)
|
||||
|
||||
def test_one_line_collapses_and_truncates(self) -> None:
|
||||
self.assertEqual(common.one_line("Hello\r\nworld\t there"), "Hello world there")
|
||||
self.assertEqual(common.one_line("zero\u200bwidth"), "zerowidth")
|
||||
long = common.one_line("x" * 400)
|
||||
self.assertEqual(len(long), 180)
|
||||
self.assertTrue(long.endswith("…"))
|
||||
|
||||
def test_encode_decode_chat_id(self) -> None:
|
||||
opaque = common.encode_id("!NCdzlIaMjZUmvmvyHU:beeper.com")
|
||||
self.assertTrue(opaque.startswith("beeper:"))
|
||||
self.assertNotIn("=", opaque)
|
||||
self.assertEqual(common.decode_id(opaque), "!NCdzlIaMjZUmvmvyHU:beeper.com")
|
||||
|
||||
def test_encode_decode_survives_slashes_and_plus(self) -> None:
|
||||
raw = "!a+b/c=d:local-whatsapp.localhost"
|
||||
self.assertEqual(common.decode_id(common.encode_id(raw)), raw)
|
||||
|
||||
def test_decode_id_rejects_junk(self) -> None:
|
||||
for bad in ("", "nope", "beeper:", "other:aGk", "beeper:!!!!", "beeper:" + "A" * 4):
|
||||
payload = capture_json(common.decode_id, bad)
|
||||
self.assertFalse(payload["ok"], msg=bad)
|
||||
self.assertEqual(payload["error"], "not a chat id")
|
||||
|
||||
def test_api_base_is_local_only(self) -> None:
|
||||
self.assertEqual(common.API_BASE, "http://localhost:23373")
|
||||
|
||||
|
||||
class HttpBodyTests(unittest.TestCase):
|
||||
def _body(self, data: bytes, content_length: object | None = ""):
|
||||
class Body:
|
||||
def __init__(self) -> None:
|
||||
self._buf = BytesIO(data)
|
||||
self.headers = {}
|
||||
if content_length != "":
|
||||
self.headers["Content-Length"] = content_length
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return self._buf.read(n)
|
||||
|
||||
return Body()
|
||||
|
||||
def test_accepts_body_at_limit(self) -> None:
|
||||
raw = common.read_http_body(self._body(b"a" * 16), 16)
|
||||
self.assertEqual(raw, b"a" * 16)
|
||||
|
||||
def test_rejects_body_over_limit(self) -> None:
|
||||
with self.assertRaises(common.ResponseTooLargeError):
|
||||
common.read_http_body(self._body(b"a" * 17), 16)
|
||||
|
||||
def test_rejects_declared_length_over_limit(self) -> None:
|
||||
with self.assertRaises(common.ResponseTooLargeError):
|
||||
common.read_http_body(self._body(b"a", content_length="99999"), 16)
|
||||
|
||||
def test_ignores_bogus_declared_length(self) -> None:
|
||||
raw = common.read_http_body(self._body(b"ab", content_length="not-a-number"), 16)
|
||||
self.assertEqual(raw, b"ab")
|
||||
|
||||
def test_default_caps_are_the_documented_ones(self) -> None:
|
||||
self.assertEqual(common.MAX_HTTP_BODY, 2 * 1024 * 1024)
|
||||
self.assertEqual(common.MAX_HTTP_ERROR, 64 * 1024)
|
||||
self.assertEqual(common.MAX_LOCAL_FILE, 64 * 1024)
|
||||
|
||||
|
||||
class SecretFileTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.dir = _workdir()
|
||||
self.path = self.dir / "token.json"
|
||||
self.addCleanup(shutil.rmtree, self.dir, True)
|
||||
|
||||
def _write(self, text: str, mode: int = 0o600) -> None:
|
||||
self.path.write_text(text, encoding="utf-8")
|
||||
os.chmod(self.path, mode)
|
||||
os.chmod(self.dir, 0o700)
|
||||
|
||||
def test_missing_token_file_is_empty_not_an_error(self) -> None:
|
||||
with patch.object(common, "TOKEN_FILE", self.path):
|
||||
self.assertEqual(common.load_token(), "")
|
||||
|
||||
def test_reads_private_token(self) -> None:
|
||||
self._write(json.dumps({"token": "abc123"}))
|
||||
with patch.object(common, "TOKEN_FILE", self.path):
|
||||
self.assertEqual(common.load_token(), "abc123")
|
||||
|
||||
def test_rejects_group_or_world_readable_token(self) -> None:
|
||||
self._write(json.dumps({"token": "abc123"}), mode=0o644)
|
||||
with patch.object(common, "TOKEN_FILE", self.path):
|
||||
payload = capture_json(common.load_token)
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertIn("too open", payload["error"])
|
||||
|
||||
def test_rejects_too_open_secrets_directory(self) -> None:
|
||||
self._write(json.dumps({"token": "abc123"}))
|
||||
os.chmod(self.dir, 0o755)
|
||||
try:
|
||||
with patch.object(common, "TOKEN_FILE", self.path):
|
||||
payload = capture_json(common.load_token)
|
||||
finally:
|
||||
os.chmod(self.dir, 0o700)
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertIn("chmod 700", payload["error"])
|
||||
|
||||
def test_rejects_invalid_json(self) -> None:
|
||||
self._write("{not json")
|
||||
with patch.object(common, "TOKEN_FILE", self.path):
|
||||
payload = capture_json(common.load_token)
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertIn("not valid JSON", payload["error"])
|
||||
|
||||
def test_rejects_oversized_token_file(self) -> None:
|
||||
self._write("x" * (common.MAX_LOCAL_FILE + 1))
|
||||
with patch.object(common, "TOKEN_FILE", self.path):
|
||||
payload = capture_json(common.load_token)
|
||||
self.assertFalse(payload["ok"])
|
||||
self.assertIn("too large", payload["error"])
|
||||
|
||||
def test_symlinked_token_file_is_not_followed(self) -> None:
|
||||
target = self.dir / "real.json"
|
||||
target.write_text(json.dumps({"token": "leak"}), encoding="utf-8")
|
||||
os.chmod(target, 0o600)
|
||||
link = self.dir / "link.json"
|
||||
link.symlink_to(target)
|
||||
with patch.object(common, "TOKEN_FILE", link):
|
||||
self.assertEqual(common.load_token(), "")
|
||||
|
||||
def test_save_token_writes_600_and_survives_replace(self) -> None:
|
||||
with patch.object(common, "CONFIG_DIR", self.dir), patch.object(
|
||||
common, "SECRETS_DIR", self.dir
|
||||
), patch.object(common, "TOKEN_FILE", self.path):
|
||||
common.save_token("first")
|
||||
common.save_token("second")
|
||||
self.assertEqual(common.load_token(), "second")
|
||||
mode = stat.S_IMODE(self.path.stat().st_mode)
|
||||
self.assertEqual(mode, 0o600)
|
||||
self.assertEqual(len(list(self.dir.glob("*.tmp"))), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from support import ROOT # noqa: I001 - puts lib/ on sys.path
|
||||
|
||||
|
||||
class PanelContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.qml = (ROOT / "Panel.qml").read_text(encoding="utf-8")
|
||||
cls.icon = (ROOT / "BeeperIcon.qml").read_text(encoding="utf-8")
|
||||
cls.manifest = json.loads((ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
cls.readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
def test_panel_only_ever_runs_the_cli(self) -> None:
|
||||
self.assertIn('Qt.resolvedUrl("bin/omarchy-beeper")', self.qml)
|
||||
self.assertIn('listProc.command = argv', self.qml)
|
||||
self.assertIn('readProc.command = [root.script, "read", id]', self.qml)
|
||||
self.assertIn('readAllProc.command = [root.script, "read-all"]', self.qml)
|
||||
self.assertIn('openProc.command = [root.script, "open", message.id]', self.qml)
|
||||
self.assertIn('openProc.command = [root.script, "open"]', self.qml)
|
||||
|
||||
def test_passes_limit_from_widget_settings(self) -> None:
|
||||
self.assertIn('setting("max", 25)', self.qml)
|
||||
self.assertIn('setting("refreshIntervalSec", 60)', self.qml)
|
||||
self.assertIn('"--limit"', self.qml)
|
||||
self.assertIn('"--page"', self.qml)
|
||||
|
||||
def test_settings_schema_matches_the_panel_clamps(self) -> None:
|
||||
schema = {item["key"]: item for item in self.manifest["barWidget"]["schema"]}
|
||||
self.assertEqual((schema["max"]["min"], schema["max"]["max"]), (1, 50))
|
||||
self.assertEqual(schema["max"]["defaultValue"], 25)
|
||||
self.assertEqual(
|
||||
(schema["refreshIntervalSec"]["min"], schema["refreshIntervalSec"]["max"]), (15, 3600)
|
||||
)
|
||||
self.assertEqual(schema["refreshIntervalSec"]["defaultValue"], 60)
|
||||
self.assertEqual(self.manifest["barWidget"]["defaults"], {"max": 25, "refreshIntervalSec": 60})
|
||||
self.assertEqual(self.manifest["kinds"], ["bar-widget"])
|
||||
self.assertEqual(self.manifest["entryPoints"]["barWidget"], "Panel.qml")
|
||||
self.assertEqual(self.manifest["schemaVersion"], 1)
|
||||
self.assertIn("Math.min(50, n)", self.qml)
|
||||
self.assertIn("Math.min(3600, n)", self.qml)
|
||||
|
||||
def test_every_untrusted_string_is_plain_text(self) -> None:
|
||||
# One `textFormat: Text.PlainText` per Text that renders API data.
|
||||
self.assertGreaterEqual(self.qml.count("textFormat: Text.PlainText"), 12)
|
||||
for banned in ("Text.RichText", "Text.StyledText", "Text.MarkdownText"):
|
||||
self.assertNotIn(banned, self.qml)
|
||||
|
||||
def test_ids_are_validated_before_use(self) -> None:
|
||||
self.assertIn("/^beeper:[A-Za-z0-9_-]{1,1024}$/", self.qml)
|
||||
self.assertIn("/^[0-9]{1,9}$/", self.qml)
|
||||
self.assertIn("if (!message || !validId(message.id)) return", self.qml)
|
||||
self.assertIn("nextPage = validToken(data.nextPage)", self.qml)
|
||||
|
||||
def test_row_click_opens_dismisses_and_marks_read(self) -> None:
|
||||
self.assertIn("function openChat(message)", self.qml)
|
||||
self.assertRegex(
|
||||
self.qml,
|
||||
r"openProc\.running = true\n dismissLocal\(message\.id\)\n enqueueRead\(message\.id\)\n close\(\)",
|
||||
)
|
||||
|
||||
def test_local_dismiss_and_read_queue_survive_a_slow_api(self) -> None:
|
||||
self.assertIn("property var dismissedIds", self.qml)
|
||||
self.assertIn("property var readQueue", self.qml)
|
||||
self.assertIn("property string pendingId", self.qml)
|
||||
self.assertIn("function enqueueRead(", self.qml)
|
||||
self.assertIn("function pumpRead()", self.qml)
|
||||
self.assertIn("unread = Math.max(0, (data.unread || 0) - dropped)", self.qml)
|
||||
self.assertNotIn("unread = 0", self.qml)
|
||||
self.assertNotIn("messages = []", self.qml)
|
||||
|
||||
def test_keyboard_map_is_complete(self) -> None:
|
||||
self.assertIn("onMoveRequested", self.qml)
|
||||
self.assertIn("onActivateRequested: root.activateCursor()", self.qml)
|
||||
self.assertIn("onTabRequested", self.qml)
|
||||
self.assertIn("onCloseRequested", self.qml)
|
||||
for key in ("o", "i", "a", "A", "n", "p"):
|
||||
self.assertIn(f't === "{key}"', self.qml)
|
||||
self.assertRegex(self.qml, r't === "a"\)\s+root\.markCursorRead\(\)')
|
||||
self.assertRegex(self.qml, r't === "A"\)\s+root\.requestMarkAll\(\)')
|
||||
|
||||
def test_mark_all_needs_two_presses_and_shows_progress(self) -> None:
|
||||
self.assertIn("Mark all unread as read (A)", self.qml)
|
||||
self.assertIn("Click again to confirm", self.qml)
|
||||
self.assertIn("Marking unread chats as read…", self.qml)
|
||||
self.assertIn("property bool markAllArmed", self.qml)
|
||||
self.assertIn("property bool markAllBusy", self.qml)
|
||||
self.assertIn("applyReadAllPayload", self.qml)
|
||||
self.assertIn("opacity: root.markAllBusy ? 0.4 : 1", self.qml)
|
||||
self.assertIn("markAllArmTimer.restart()", self.qml)
|
||||
|
||||
def test_unreachable_state_dims_the_icon_and_keeps_the_list(self) -> None:
|
||||
self.assertIn("opacity: root.reachable ? 1 : 0.5", self.qml)
|
||||
self.assertIn("Could not reach Beeper. Showing the last list.", self.qml)
|
||||
self.assertIn("if (!reachable) return", self.qml)
|
||||
self.assertIn("Beeper unreachable", self.qml)
|
||||
|
||||
def test_tooltip_counts_unread_chats(self) -> None:
|
||||
self.assertIn("function tooltipFor()", self.qml)
|
||||
self.assertIn("tooltipText: root.tooltipFor()", self.qml)
|
||||
self.assertIn('return root.unread + " unread chats"', self.qml)
|
||||
self.assertIn('return "No unread chats"', self.qml)
|
||||
|
||||
def test_badge_uses_bar_foreground_never_the_urgent_colour(self) -> None:
|
||||
self.assertIn("color: button.foreground", self.qml)
|
||||
self.assertIn("dotColor: button.foreground", self.qml)
|
||||
self.assertIn(
|
||||
"color: Qt.rgba(button.foreground.r, button.foreground.g,\n button.foreground.b, 0.14)",
|
||||
self.qml,
|
||||
)
|
||||
self.assertNotIn("button.activeColor", self.qml)
|
||||
self.assertNotIn("badgeCount\n color: bar.urgent", self.qml)
|
||||
self.assertNotIn("active: root.opened", self.qml)
|
||||
|
||||
def test_network_chip_only_shows_with_more_than_one_account(self) -> None:
|
||||
self.assertIn("visible: root.accountCount > 1", self.qml)
|
||||
self.assertIn("(row.modelData.labels || []).slice(0, 1)", self.qml)
|
||||
self.assertIn("elide: Text.ElideRight", self.qml)
|
||||
self.assertIn("Style.space(64)", self.qml)
|
||||
self.assertIn("Math.max(Style.space(40)", self.qml)
|
||||
|
||||
def test_icon_is_a_stroked_bubble_with_no_layer_effects(self) -> None:
|
||||
self.assertIn("ctx.stroke()", self.icon)
|
||||
self.assertIn("ctx.arcTo(", self.icon)
|
||||
self.assertIn("dotAmount", self.icon)
|
||||
self.assertIn("snapStroke", self.icon)
|
||||
self.assertNotIn("layer.enabled", self.icon)
|
||||
self.assertNotIn("Image {", self.icon)
|
||||
|
||||
def test_bubble_and_dots_stay_inside_the_canvas(self) -> None:
|
||||
for size in (10, 12, 16, 20, 24, 32, 48):
|
||||
for dpr in (1.0, 1.25, 1.5, 2.0):
|
||||
geom = bubble_geometry(size, dpr)
|
||||
self.assertGreater(geom["bodyH"], 0, msg=f"size={size} dpr={dpr}")
|
||||
self.assertGreater(geom["tailRight"], geom["tailLeft"])
|
||||
for amount in (0.0, 0.5, 1.0):
|
||||
for x, y in painted_extents(geom, amount):
|
||||
self.assertGreaterEqual(x, -0.51, msg=f"size={size} dpr={dpr} t={amount}")
|
||||
self.assertGreaterEqual(y, -0.51, msg=f"size={size} dpr={dpr} t={amount}")
|
||||
self.assertLessEqual(x, size + 0.51, msg=f"size={size} dpr={dpr} t={amount}")
|
||||
self.assertLessEqual(y, size + 0.51, msg=f"size={size} dpr={dpr} t={amount}")
|
||||
|
||||
def test_tail_never_runs_into_a_corner_arc(self) -> None:
|
||||
for size in (10, 16, 24, 48):
|
||||
geom = bubble_geometry(size, 1.0)
|
||||
self.assertGreaterEqual(geom["tailLeft"], geom["bodyX"] + geom["radius"] - 0.01)
|
||||
self.assertLessEqual(
|
||||
geom["tailRight"], geom["bodyX"] + geom["bodyW"] - geom["radius"] + 0.01
|
||||
)
|
||||
|
||||
def test_readme_documents_install_setup_and_removal(self) -> None:
|
||||
self.assertIn("https://github.com/prdlk/omarchy-beeper.git", self.readme)
|
||||
self.assertIn("omarchy plugin add", self.readme)
|
||||
self.assertIn("--enable", self.readme)
|
||||
self.assertIn("omarchy plugin update prdlk.beeper", self.readme)
|
||||
self.assertIn("omarchy-beeper auth", self.readme)
|
||||
self.assertIn("~/.config/omarchy-beeper/", self.readme)
|
||||
self.assertIn("Settings", self.readme)
|
||||
self.assertIn("Integrations", self.readme)
|
||||
self.assertIn("revoke", self.readme)
|
||||
self.assertIn("muted", self.readme)
|
||||
self.assertIn("archived", self.readme)
|
||||
self.assertIn("low-priority", self.readme)
|
||||
self.assertIn("indexing", self.readme)
|
||||
self.assertIn("never sends", self.readme.lower())
|
||||
self.assertIn("preview.png", self.readme)
|
||||
self.assertTrue((ROOT / "preview.png").is_file(), "marketplace listing wants preview.png")
|
||||
self.assertTrue((ROOT / "docs" / "SETUP.md").is_file())
|
||||
self.assertTrue((ROOT / "LICENSE").is_file())
|
||||
|
||||
def test_readme_key_map_matches_the_panel(self) -> None:
|
||||
for key in ("`j`", "`k`", "`o`", "`a`", "`A`", "`n`", "`p`", "`i`", "`Tab`", "`Esc`"):
|
||||
self.assertIn(key, self.readme)
|
||||
|
||||
def test_changelog_matches_manifest(self) -> None:
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
self.assertIn(f"## {self.manifest['version']}", changelog)
|
||||
|
||||
|
||||
def _snap(value: float, dpr: float) -> float:
|
||||
return round(value * dpr) / dpr
|
||||
|
||||
|
||||
def _snap_stroke(value: float, dpr: float) -> float:
|
||||
return max(1, round(value * dpr)) / dpr
|
||||
|
||||
|
||||
def bubble_geometry(icon_size: float, dpr: float = 1.0) -> dict[str, float]:
|
||||
"""Mirror of BeeperIcon.qml so the geometry can be checked without a GPU."""
|
||||
stroke = _snap_stroke(max(1.5, icon_size * 0.11), dpr)
|
||||
pad = _snap(max(stroke / 2, 0.5), dpr)
|
||||
tail_h = _snap(max(stroke * 1.2, icon_size * 0.16), dpr)
|
||||
body_x = _snap(pad, dpr)
|
||||
body_y = _snap(pad, dpr)
|
||||
body_w = _snap(icon_size - pad * 2, dpr)
|
||||
body_h = _snap(max(stroke * 3, icon_size - pad * 2 - tail_h), dpr)
|
||||
radius = _snap(min(body_w, body_h) * 0.3, dpr)
|
||||
tail_left = _snap(body_x + max(radius, body_w * 0.2), dpr)
|
||||
tail_right = _snap(tail_left + max(stroke, body_w * 0.2), dpr)
|
||||
tail_tip_x = _snap(tail_left + max(stroke / 2, body_w * 0.04), dpr)
|
||||
dot_r = _snap(max(1, min(icon_size * 0.075, body_h * 0.16)), dpr)
|
||||
dot_gap = _snap(max(dot_r * 2.2, body_w * 0.24), dpr)
|
||||
dot_y = _snap(body_y + body_h / 2, dpr)
|
||||
return {
|
||||
"size": icon_size,
|
||||
"stroke": stroke,
|
||||
"pad": pad,
|
||||
"tailH": tail_h,
|
||||
"bodyX": body_x,
|
||||
"bodyY": body_y,
|
||||
"bodyW": body_w,
|
||||
"bodyH": body_h,
|
||||
"radius": min(radius, body_w / 2, body_h / 2),
|
||||
"tailLeft": tail_left,
|
||||
"tailRight": tail_right,
|
||||
"tailTipX": tail_tip_x,
|
||||
"dotR": dot_r,
|
||||
"dotGap": dot_gap,
|
||||
"dotY": dot_y,
|
||||
}
|
||||
|
||||
|
||||
def painted_extents(geom: dict[str, float], dot_amount: float) -> list[tuple[float, float]]:
|
||||
"""Outermost painted points, stroke width included."""
|
||||
half = geom["stroke"] / 2
|
||||
x = geom["bodyX"]
|
||||
y = geom["bodyY"]
|
||||
w = geom["bodyW"]
|
||||
h = geom["bodyH"]
|
||||
points = [
|
||||
(x - half, y - half),
|
||||
(x + w + half, y - half),
|
||||
(x + w + half, y + h + half),
|
||||
(x - half, y + h + half),
|
||||
(geom["tailTipX"] - half, y + h + geom["tailH"] + half),
|
||||
(geom["tailTipX"] + half, y + h + geom["tailH"] + half),
|
||||
]
|
||||
if dot_amount > 0.01:
|
||||
scaled = geom["dotR"] * (0.6 + 0.4 * dot_amount)
|
||||
cx = x + w / 2
|
||||
for i in (-1, 0, 1):
|
||||
points.append((cx + i * geom["dotGap"] - scaled, geom["dotY"] - scaled))
|
||||
points.append((cx + i * geom["dotGap"] + scaled, geom["dotY"] + scaled))
|
||||
return points
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user