374 lines
11 KiB
Python
374 lines
11 KiB
Python
"""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
|