Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
container: ubuntu:26.04
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
Expand Down
10 changes: 8 additions & 2 deletions src/kotonoha/karaoke_label.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from __future__ import annotations

from typing import Any, Protocol, cast
from typing import Any, Protocol, TypedDict, cast

from PyQt6 import QtCore
from PyQt6.QtCore import QEasingCurve, QPropertyAnimation, QRectF, QSize, Qt
Expand All @@ -40,7 +40,13 @@

# Effect strength per intensity: glow alpha (of the accent), glow radius (px), and
# the active-word brightening (QColor.lighter percent).
_FX = {
class _EffectConfig(TypedDict):
glow_alpha: float
glow_radius: float
pop: int


_FX: dict[str, _EffectConfig] = {
"subtle": {"glow_alpha": 0.22, "glow_radius": 2.0, "pop": 128},
"expressive": {"glow_alpha": 0.42, "glow_radius": 3.0, "pop": 155},
}
Expand Down
3 changes: 2 additions & 1 deletion src/kotonoha/leaf_icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from functools import lru_cache
from pathlib import Path
from typing import cast

from PyQt6.QtCore import QByteArray, QPointF, QRectF, Qt
from PyQt6.QtGui import (
Expand Down Expand Up @@ -71,7 +72,7 @@ def is_generated(key: str) -> bool:
def system_is_dark() -> bool:
"""True when the system colour scheme is dark (so a light tray icon suits the
panel). Defaults to dark when the scheme is unknown or before the app exists."""
app = QGuiApplication.instance()
app = cast(QGuiApplication | None, QGuiApplication.instance())
hints = app.styleHints() if app is not None else None
scheme = hints.colorScheme() if hints is not None else None
return scheme != Qt.ColorScheme.Light
Expand Down
12 changes: 6 additions & 6 deletions src/kotonoha/settings_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ class _FontNameDelegate(QStyledItemDelegate):
"""Font-list delegate that previews each family name in its own font but drops
the file-type 'T' icon QFontComboBox normally draws on the left."""

def initStyleOption(self, option: QStyleOptionViewItem, index: QModelIndex) -> None:
def initStyleOption(self, option: QStyleOptionViewItem | None, index: QModelIndex) -> None:
super().initStyleOption(option, index)
if option is None:
return
family = index.data()
if isinstance(family, str) and family:
option.font = QFont(family) # render the name in its own font
Expand Down Expand Up @@ -277,7 +279,7 @@ def _resolve_theme(value: str) -> str:
"auto" follows the system colour scheme (Qt 6.5+), defaulting to dark."""
if value in ("light", "dark"):
return value
app = QGuiApplication.instance()
app = cast(QGuiApplication | None, QGuiApplication.instance())
hints = app.styleHints() if app is not None else None
scheme = hints.colorScheme() if hints is not None else None
return "light" if scheme == Qt.ColorScheme.Light else "dark"
Expand Down Expand Up @@ -506,10 +508,8 @@ def showEvent(self, a0: QShowEvent | None) -> None:
# sections never resizes the window and the nav never truncates.
self._nav.setFixedWidth(self._nav.sizeHintForColumn(0) + 30)
self._stack.setMinimumWidth(400)
tallest = max(
(self._stack.widget(i).sizeHint().height() for i in range(self._stack.count())),
default=0,
)
widgets = (self._stack.widget(i) for i in range(self._stack.count()))
tallest = max((widget.sizeHint().height() for widget in widgets if widget is not None), default=0)
self._stack.setMinimumHeight(tallest)
needed = self._nav.width() + 1 + 400 + 46 # nav + divider + content + margins/spacing
if self.minimumWidth() < needed:
Expand Down
14 changes: 11 additions & 3 deletions tests/test_clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,17 @@ def test_coarse_position_stall_keeps_flowing_while_playing():
clock = MediaClock(monotonic=fake)
clock.sync(media_time=10.0, playing=True)
previous = clock.now()
assert previous is not None
for _ in range(15): # ~3s of a stalled Position report, well past the grace window
fake.t += 0.2
clock.sync(media_time=10.0, playing=True)
current = clock.now()
assert current is not None
assert current >= previous # never rolls backward
previous = current
assert clock.now() > 12.0 # kept interpolating forward the whole stall
current = clock.now()
assert current is not None
assert current > 12.0 # kept interpolating forward the whole stall
assert clock.playing is True # a stall while Playing is never treated as a pause


Expand All @@ -128,7 +132,9 @@ def test_backward_seek_while_paused_is_followed():
fake.t += 0.2
clock.sync(media_time=20.2, playing=True) # resume
fake.t += 0.5
assert 20.0 <= clock.now() <= 21.5 # plays forward from the seeked position
current = clock.now()
assert current is not None
assert 20.0 <= current <= 21.5 # plays forward from the seeked position


def test_lagging_report_does_not_roll_the_sweep_back():
Expand All @@ -137,7 +143,9 @@ def test_lagging_report_does_not_roll_the_sweep_back():
clock.sync(media_time=10.0, playing=True)
fake.t += 0.5 # estimate ~10.5
clock.sync(media_time=10.2, playing=True) # advanced, but still behind estimate
assert clock.now() >= 10.5 # stayed forward, did not snap back to 10.2
current = clock.now()
assert current is not None
assert current >= 10.5 # stayed forward, did not snap back to 10.2


def test_sync_without_media_time_is_noop():
Expand Down
6 changes: 4 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import cast

from kotonoha.config import Config, load_config, save_config


Expand Down Expand Up @@ -74,7 +76,7 @@ def test_effects_defaults_clamp_and_roundtrip(tmp_path):
assert Config(fx_transition="bogus").clamped().fx_transition == "rise"
# Fuzzy matching: on by default, coerced to bool.
assert Config().fuzzy_match is True
assert Config(fuzzy_match=0).clamped().fuzzy_match is False
assert Config(fuzzy_match=cast(bool, 0)).clamped().fuzzy_match is False
path = tmp_path / "c.json"
save_config(Config(fx_animate=False, fx_glow=False, fx_word_pop=False, fx_intensity="expressive"), path)
loaded = load_config(path)
Expand All @@ -96,7 +98,7 @@ def test_theme_and_white_panel_clamp_and_roundtrip(tmp_path):

def test_frost_window_defaults_and_roundtrips(tmp_path):
assert Config().frost_window is True
assert Config(frost_window=0).clamped().frost_window is False # coerced to bool
assert Config(frost_window=cast(bool, 0)).clamped().frost_window is False # coerced to bool
path = tmp_path / "c.json"
save_config(Config(frost_window=False), path)
assert load_config(path).frost_window is False
Expand Down
7 changes: 5 additions & 2 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from typing import cast

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

Expand All @@ -7,6 +8,8 @@

from kotonoha.config import Config
from kotonoha.controller import AppController
from kotonoha.providers.mpris import MprisProvider
from kotonoha.receiver import LyricsReceiver


@pytest.fixture(scope="module")
Expand Down Expand Up @@ -38,9 +41,9 @@ async def test_start_survives_optional_receiver_bind_failure(qapp):
# A stale instance / double-launch holding port 28745 must only disable the
# optional Cider receiver, not take down the already-shown overlay and tray.
controller = AppController(qapp, Config())
controller._receiver = _FakeReceiver()
controller._receiver = cast(LyricsReceiver, _FakeReceiver())
fake_mpris = _FakeMpris()
controller._mpris = fake_mpris
controller._mpris = cast(MprisProvider, fake_mpris)

await controller.start() # must not raise

Expand Down
4 changes: 3 additions & 1 deletion tests/test_lyrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,9 @@ def test_exact_title_and_artist_survive_small_duration_skew():
def test_best_match_prefers_genuine_artist_over_missing_artist():
track = TrackMetadata("Song", "Artist", "", 180.0)
candidates = [Candidate("noart", "Song", "", 180.0), Candidate("art", "Song", "Artist", 180.0)]
assert best_match(candidates, track).candidate.song_id == "art"
evidence = best_match(candidates, track)
assert evidence is not None
assert evidence.candidate.song_id == "art"


def test_query_variants_are_raw_then_base_title_primary_artist():
Expand Down
6 changes: 3 additions & 3 deletions tests/test_lyrics_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def resolver_with_fakes(
network_hits = network_hits or {}

def adapter(name):
async def fetch(_session, _track, *, fuzzy=False):
async def fetch(session, track, *, fuzzy=False):
calls.append(f"network:{name}")
return network_hits.get(name)

Expand Down Expand Up @@ -195,7 +195,7 @@ async def test_concurrent_identical_requests_share_network_work():
started = asyncio.Event()
release = asyncio.Event()

async def fetch(_session, _track, *, fuzzy=False):
async def fetch(session, track, *, fuzzy=False):
calls.append("network:netease")
started.set()
await release.wait()
Expand All @@ -219,7 +219,7 @@ async def fetch(_session, _track, *, fuzzy=False):


async def test_network_timeout_log_includes_exception_type(caplog):
async def timeout(_session, _track, *, fuzzy=False):
async def timeout(session, track, *, fuzzy=False):
raise TimeoutError

resolver = LyricsResolver(
Expand Down
3 changes: 3 additions & 0 deletions tests/test_mpris_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ def set_cache_enabled(self, _enabled):
def set_prefer_best(self, _enabled):
return None

def set_fuzzy(self, _enabled):
return None

async def clear_cache(self):
return None

Expand Down
7 changes: 7 additions & 0 deletions tests/test_native_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ def test_cmake_is_documented_and_verified_by_ci() -> None:
assert 'archive.namelist().count("kotonoha/libkoto-layer.so")' in package_workflow


def test_python_workflow_uses_bash_for_bash_only_commands() -> None:
workflow = read_packaging_file(TEST_WORKFLOW)
python_job = workflow.split(" python:\n", maxsplit=1)[1].split("\n cider:\n", maxsplit=1)[0]

assert " defaults:\n run:\n shell: bash\n" in python_job


def test_debian_control_declares_package_metadata_and_dependencies() -> None:
control = read_packaging_file(DEBIAN_DIR / "control")

Expand Down
11 changes: 8 additions & 3 deletions tests/test_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
from PyQt6.QtWidgets import QApplication

from kotonoha.config import Config
from kotonoha.native import LayerShellController
from kotonoha.overlay import LyricsOverlay
from kotonoha.state import LyricsState


class UnavailableController:
available = False
class UnavailableController(LayerShellController):
def __init__(self) -> None:
super().__init__("", "wayland", "GNOME")


class RecordingOverlay(LyricsOverlay):
Expand Down Expand Up @@ -169,7 +171,9 @@ def test_white_panel_flips_text_and_context_shadow_to_light(qapp):
# A dark panel keeps light text with a dark halo.
overlay.apply_config(Config(panel_style="pill"))
assert overlay._text_colors()[0].lightness() > 160
assert overlay._prev_label.graphicsEffect().color().lightness() < 100
effect = overlay._prev_label.graphicsEffect()
assert isinstance(effect, QGraphicsDropShadowEffect)
assert effect.color().lightness() < 100
overlay.deleteLater()
qapp.processEvents()

Expand Down Expand Up @@ -222,6 +226,7 @@ def test_lyric_script_converts_displayed_line(qapp):
LyricsState(), Config(lyrics_script="zh-Hant"), UnavailableController()
)
out = converted._convert_line(line)
assert out is not None
assert out.text == "簡體字" # display converted to Traditional
assert out.words[0].text == "簡" # words converted too (for the karaoke sweep)
off = LyricsOverlay(LyricsState(), Config(lyrics_script="off"), UnavailableController())
Expand Down
4 changes: 3 additions & 1 deletion tests/test_receiver.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json
from typing import cast

import pytest
from aiohttp import web

pytest.importorskip("PyQt6.QtCore")
pytest.importorskip("aiohttp")
Expand Down Expand Up @@ -150,7 +152,7 @@ async def send_str(self, _text):
raise ConnectionResetError("socket closed mid-send")

receiver = LyricsReceiver(LyricsState())
receiver._clients.add(FakeWS())
receiver._clients.add(cast(web.WebSocketResponse, FakeWS()))

receiver.update_translation_language("ja") # must not raise
# The send task is retained (RUF006), not fire-and-forget.
Expand Down
34 changes: 21 additions & 13 deletions tests/test_settings_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ def test_content_sits_in_a_raised_card_and_page_switch_is_safe(qapp):
# effect is left on the page (it can never be stuck dim/blank).
dialog._nav.setCurrentRow(2)
assert dialog._stack.currentIndex() == 2
assert dialog._stack.currentWidget().graphicsEffect() is None
current = dialog._stack.currentWidget()
assert current is not None
assert current.graphicsEffect() is None
dialog.close()


Expand Down Expand Up @@ -189,17 +191,17 @@ def test_theme_selector_roundtrips_and_switches_palette(qapp):

dark = SettingsDialog(Config(theme="dark"))
assert dark._theme == "dark"
assert _PALETTES["dark"]["TEXT"] in dark.styleSheet()
assert cast(str, _PALETTES["dark"]["TEXT"]) in dark.styleSheet()
assert dark.current_config().theme == "dark"

light = SettingsDialog(Config(theme="light"))
assert light._theme == "light"
assert _PALETTES["light"]["TEXT"] in light.styleSheet()
assert cast(str, _PALETTES["light"]["TEXT"]) in light.styleSheet()
# Switching theme on Apply re-skins the dialog live.
light._theme_combo.setCurrentIndex(light._theme_combo.findData("dark"))
light._emit()
assert light._theme == "dark"
assert _PALETTES["dark"]["TEXT"] in light.styleSheet()
assert cast(str, _PALETTES["dark"]["TEXT"]) in light.styleSheet()
dark.close()
light.close()

Expand All @@ -208,7 +210,11 @@ def test_connection_section_removed_but_port_preserved(qapp):
# The WS-port control was dropped; the sidebar no longer lists Connection,
# and current_config keeps the config's port untouched (still used by the CLI).
dialog = SettingsDialog(Config(port=41234))
labels = [dialog._nav.item(i).text() for i in range(dialog._nav.count())]
labels = []
for i in range(dialog._nav.count()):
item = dialog._nav.item(i)
assert item is not None
labels.append(item.text())
assert not any("onnect" in label or "连接" in label or "連接" in label or "接続" in label for label in labels)
assert not hasattr(dialog, "_port")
assert dialog.current_config().port == 41234 # preserved from the config
Expand Down Expand Up @@ -317,10 +323,11 @@ def test_icon_picker_includes_generated_leaf_styles(qapp):
from kotonoha import leaf_icon

dialog = SettingsDialog(Config(icon_name=leaf_icon.TILE))
keys = [
str(dialog._tray_icon_list.item(i).data(Qt.ItemDataRole.UserRole))
for i in range(dialog._tray_icon_list.count())
]
keys = []
for i in range(dialog._tray_icon_list.count()):
item = dialog._tray_icon_list.item(i)
assert item is not None
keys.append(str(item.data(Qt.ItemDataRole.UserRole)))
for style in leaf_icon.PICKER_STYLES: # accent / white / black / tile are offered
assert style in keys
assert leaf_icon.WHITE in keys and leaf_icon.BLACK in keys # explicit monochromes
Expand Down Expand Up @@ -349,10 +356,11 @@ def test_tray_and_window_icons_are_chosen_independently(qapp):
assert dialog._picked_icon(dialog._tray_icon_list) == leaf_icon.WHITE
assert dialog._picked_icon(dialog._window_icon_list) == leaf_icon.TILE
# Changing one does not move the other.
window_keys = [
str(dialog._window_icon_list.item(i).data(Qt.ItemDataRole.UserRole))
for i in range(dialog._window_icon_list.count())
]
window_keys = []
for i in range(dialog._window_icon_list.count()):
item = dialog._window_icon_list.item(i)
assert item is not None
window_keys.append(str(item.data(Qt.ItemDataRole.UserRole)))
dialog._window_icon_list.setCurrentRow(window_keys.index(leaf_icon.BLACK))
cfg = dialog.current_config()
assert cfg.icon_name == leaf_icon.WHITE
Expand Down