From d9373f8a3b1df71b9cd357bb5c76c7a08bcfb511 Mon Sep 17 00:00:00 2001 From: Username Date: Wed, 25 Feb 2026 09:03:36 +0100 Subject: [PATCH] test: add widget unit and integration tests --- pyproject.toml | 1 + tests/test_widgets.py | 373 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 tests/test_widgets.py diff --git a/pyproject.toml b/pyproject.toml index c8fe48e..6f47d4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,3 +38,4 @@ select = ["E", "F", "W", "I"] [tool.pytest.ini_options] testpaths = ["tests"] +asyncio_mode = "strict" diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 0000000..9eb9e53 --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,373 @@ +"""Tests for StatusBar, ChannelTree, and related widget helpers.""" + +import pytest + +from tuimble.app import ( + VOLUME_STEPS, + ChannelSelected, + ChannelTree, + StatusBar, + _next_volume, +) +from tuimble.client import Channel, User + +# -- test helpers ------------------------------------------------------------ + + +def _sample_channels(): + return { + 0: Channel(channel_id=0, name="Root", parent_id=-1), + 1: Channel(channel_id=1, name="Alpha", parent_id=0), + 2: Channel(channel_id=2, name="Beta", parent_id=0), + } + + +def _sample_users(): + return { + 1: [User(session_id=10, name="Alice", channel_id=1)], + } + + +# -- A. Pure unit tests (sync) ---------------------------------------------- + + +class TestVolBar: + def test_zero(self): + assert StatusBar._vol_bar(0) == "\u2591\u2591\u2591\u2591" + + def test_25(self): + assert StatusBar._vol_bar(25) == "\u2588\u2591\u2591\u2591" + + def test_50(self): + assert StatusBar._vol_bar(50) == "\u2588\u2588\u2591\u2591" + + def test_100(self): + assert StatusBar._vol_bar(100) == "\u2588\u2588\u2588\u2588" + + def test_200_overgain(self): + result = StatusBar._vol_bar(200) + # 200/25 = 8, clamped to 4 filled + max(0, 4-8)=0 light + assert "\u2588" in result + + +class TestTruncate: + def test_short(self): + assert ChannelTree._truncate("hi", 10) == "hi" + + def test_exact(self): + assert ChannelTree._truncate("abcde", 5) == "abcde" + + def test_long(self): + assert ChannelTree._truncate("abcdefghij", 7) == "abcd..." + + def test_tiny_max(self): + assert ChannelTree._truncate("abcdef", 3) == "abc" + + def test_max_two(self): + assert ChannelTree._truncate("abcdef", 2) == "ab" + + def test_zero_max(self): + assert ChannelTree._truncate("abc", 0) == "" + + +class TestUserStatus: + def test_normal(self): + u = User(session_id=1, name="X", channel_id=0) + assert ChannelTree._user_status(u) == "" + + def test_self_deaf(self): + u = User(session_id=1, name="X", channel_id=0, self_deaf=True) + assert "\u2298" in ChannelTree._user_status(u) + + def test_self_mute(self): + u = User(session_id=1, name="X", channel_id=0, self_mute=True) + assert "\u2715" in ChannelTree._user_status(u) + + def test_server_deaf(self): + u = User(session_id=1, name="X", channel_id=0, deaf=True) + assert "\u2298" in ChannelTree._user_status(u) + + def test_server_mute(self): + u = User(session_id=1, name="X", channel_id=0, mute=True) + assert "\u2715" in ChannelTree._user_status(u) + + def test_deaf_takes_priority(self): + u = User(session_id=1, name="X", channel_id=0, self_deaf=True, self_mute=True) + assert "\u2298" in ChannelTree._user_status(u) + + +class TestNextVolume: + def test_full_cycle(self): + vol = 0.0 + seen = [vol] + for _ in range(len(VOLUME_STEPS)): + vol = _next_volume(vol) + seen.append(vol) + # Must cycle through all steps and wrap + assert seen[-1] == VOLUME_STEPS[0] + + def test_wraparound_from_max(self): + assert _next_volume(VOLUME_STEPS[-1]) == VOLUME_STEPS[0] + + def test_mid_value(self): + assert _next_volume(0.5) == 0.75 + + +# -- B. ChannelTree state tests (sync, direct instantiation) ---------------- + + +class TestChannelTreeState: + def test_build_order_matches_dfs(self): + tree = ChannelTree() + tree.set_state(_sample_channels(), {}) + # Root, then sorted children: Alpha, Beta + assert tree._channel_ids == [0, 1, 2] + + def test_empty_channels(self): + tree = ChannelTree() + tree.set_state({}, {}) + assert tree._channel_ids == [] + + def test_clear_state(self): + tree = ChannelTree() + tree.set_state(_sample_channels(), _sample_users(), my_channel_id=1) + tree.clear_state() + assert tree._channels == {} + assert tree._users_by_channel == {} + assert tree._channel_ids == [] + assert tree._focused_idx == 0 + assert tree._my_channel_id is None + + def test_focus_clamped_when_shrinks(self): + tree = ChannelTree() + tree.set_state(_sample_channels(), {}) + tree._focused_idx = 2 # last channel + # Shrink to only root + tree.set_state({0: Channel(channel_id=0, name="Root", parent_id=-1)}, {}) + assert tree._focused_idx == 0 + + def test_focus_preserved_when_in_range(self): + tree = ChannelTree() + tree.set_state(_sample_channels(), {}) + tree._focused_idx = 1 + # Re-set same state + tree.set_state(_sample_channels(), {}) + assert tree._focused_idx == 1 + + def test_nested_channels_order(self): + channels = { + 0: Channel(channel_id=0, name="Root", parent_id=-1), + 1: Channel(channel_id=1, name="Zulu", parent_id=0), + 2: Channel(channel_id=2, name="Alpha", parent_id=0), + 3: Channel(channel_id=3, name="Sub", parent_id=2), + } + tree = ChannelTree() + tree.set_state(channels, {}) + # Root -> Alpha (sorted) -> Sub -> Zulu + assert tree._channel_ids == [0, 2, 3, 1] + + +# -- C. StatusBar integration tests (async) --------------------------------- + +from textual.app import App, ComposeResult # noqa: E402 + + +class StatusBarApp(App): + CSS = """ + #status { dock: bottom; height: 1; } + """ + + def compose(self) -> ComposeResult: + yield StatusBar(id="status") + + +@pytest.mark.asyncio +async def test_statusbar_default_disconnected(): + app = StatusBarApp() + async with app.run_test(size=(80, 5)) as _pilot: + bar = app.query_one("#status", StatusBar) + assert bar.connected is False + rendered = bar.render() + assert "\u25cb" in rendered # disconnected circle + + +@pytest.mark.asyncio +async def test_statusbar_connected(): + app = StatusBarApp() + async with app.run_test(size=(80, 5)) as _pilot: + bar = app.query_one("#status", StatusBar) + bar.connected = True + rendered = bar.render() + assert "\u25cf" in rendered # filled circle + assert "connected" in rendered + + +@pytest.mark.asyncio +async def test_statusbar_compact_width(): + app = StatusBarApp() + async with app.run_test(size=(30, 5)) as pilot: + bar = app.query_one("#status", StatusBar) + await pilot.resize_terminal(30, 5) + await pilot.pause() + rendered = bar.render() + # Compact mode: symbols only, no labels + assert "connected" not in rendered + + +@pytest.mark.asyncio +async def test_statusbar_medium_width(): + app = StatusBarApp() + async with app.run_test(size=(50, 5)) as _pilot: + bar = app.query_one("#status", StatusBar) + rendered = bar.render() + assert "disconnected" in rendered + + +@pytest.mark.asyncio +async def test_statusbar_full_width(): + app = StatusBarApp() + async with app.run_test(size=(80, 5)) as _pilot: + bar = app.query_one("#status", StatusBar) + bar.connected = True + bar.output_vol = 100 + bar.input_vol = 50 + rendered = bar.render() + assert "out" in rendered + assert "in" in rendered + + +@pytest.mark.asyncio +async def test_statusbar_ptt_active(): + app = StatusBarApp() + async with app.run_test(size=(80, 5)) as _pilot: + bar = app.query_one("#status", StatusBar) + bar.ptt_active = True + rendered = bar.render() + assert "TX" in rendered + + +@pytest.mark.asyncio +async def test_statusbar_deaf(): + app = StatusBarApp() + async with app.run_test(size=(80, 5)) as _pilot: + bar = app.query_one("#status", StatusBar) + bar.self_deaf = True + rendered = bar.render() + assert "\u2298" in rendered + + +@pytest.mark.asyncio +async def test_statusbar_reconnecting(): + app = StatusBarApp() + async with app.run_test(size=(80, 5)) as _pilot: + bar = app.query_one("#status", StatusBar) + bar.reconnecting = True + rendered = bar.render() + assert "reconnecting" in rendered + + +# -- D. ChannelTree integration tests (async) -------------------------------- + + +class ChannelTreeApp(App): + CSS = """ + #sidebar { width: 24; height: 1fr; } + """ + BINDINGS = [("q", "quit", "Quit")] + + def __init__(self): + super().__init__() + self.selected_messages: list[int] = [] + + def compose(self) -> ComposeResult: + yield ChannelTree(id="sidebar") + + def on_channel_selected(self, msg: ChannelSelected) -> None: + self.selected_messages.append(msg.channel_id) + + +@pytest.mark.asyncio +async def test_tree_empty_render(): + app = ChannelTreeApp() + async with app.run_test(size=(40, 10)) as _pilot: + tree = app.query_one("#sidebar", ChannelTree) + rendered = tree.render() + assert "(not connected)" in rendered + + +@pytest.mark.asyncio +async def test_tree_populated(): + app = ChannelTreeApp() + async with app.run_test(size=(40, 10)) as _pilot: + tree = app.query_one("#sidebar", ChannelTree) + tree.set_state(_sample_channels(), _sample_users()) + rendered = tree.render() + assert "Root" in rendered + assert "Alpha" in rendered + assert "Beta" in rendered + assert "Alice" in rendered + + +@pytest.mark.asyncio +async def test_tree_down_arrow(): + app = ChannelTreeApp() + async with app.run_test(size=(40, 10)) as pilot: + tree = app.query_one("#sidebar", ChannelTree) + tree.set_state(_sample_channels(), {}) + tree.focus() + await pilot.pause() + assert tree._focused_idx == 0 + await pilot.press("down") + assert tree._focused_idx == 1 + + +@pytest.mark.asyncio +async def test_tree_up_arrow(): + app = ChannelTreeApp() + async with app.run_test(size=(40, 10)) as pilot: + tree = app.query_one("#sidebar", ChannelTree) + tree.set_state(_sample_channels(), {}) + tree._focused_idx = 2 + tree.focus() + await pilot.pause() + await pilot.press("up") + assert tree._focused_idx == 1 + + +@pytest.mark.asyncio +async def test_tree_bounds_top(): + app = ChannelTreeApp() + async with app.run_test(size=(40, 10)) as pilot: + tree = app.query_one("#sidebar", ChannelTree) + tree.set_state(_sample_channels(), {}) + tree.focus() + await pilot.pause() + await pilot.press("up") + assert tree._focused_idx == 0 # stays at 0 + + +@pytest.mark.asyncio +async def test_tree_bounds_bottom(): + app = ChannelTreeApp() + async with app.run_test(size=(40, 10)) as pilot: + tree = app.query_one("#sidebar", ChannelTree) + tree.set_state(_sample_channels(), {}) + tree._focused_idx = 2 + tree.focus() + await pilot.pause() + await pilot.press("down") + assert tree._focused_idx == 2 # stays at max + + +@pytest.mark.asyncio +async def test_tree_enter_posts_message(): + app = ChannelTreeApp() + async with app.run_test(size=(40, 10)) as pilot: + tree = app.query_one("#sidebar", ChannelTree) + tree.set_state(_sample_channels(), {}) + tree._focused_idx = 1 # Alpha (channel_id=1) + tree.focus() + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert 1 in app.selected_messages