test: add widget unit and integration tests
This commit is contained in:
@@ -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
373
tests/test_widgets.py
Normal 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
|
||||
Reference in New Issue
Block a user