test: add widget unit and integration tests

This commit is contained in:
Username
2026-02-25 09:03:36 +01:00
parent 3dbd126239
commit d9373f8a3b
2 changed files with 374 additions and 0 deletions

View File

@@ -38,3 +38,4 @@ select = ["E", "F", "W", "I"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "strict"

373
tests/test_widgets.py Normal file
View File

@@ -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