diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0d7dce6..f2ca05d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: diff --git a/src/kotonoha/karaoke_label.py b/src/kotonoha/karaoke_label.py index 861d24d..c8e4e6e 100644 --- a/src/kotonoha/karaoke_label.py +++ b/src/kotonoha/karaoke_label.py @@ -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 @@ -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}, } diff --git a/src/kotonoha/leaf_icon.py b/src/kotonoha/leaf_icon.py index db1d485..f73aec9 100644 --- a/src/kotonoha/leaf_icon.py +++ b/src/kotonoha/leaf_icon.py @@ -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 ( @@ -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 diff --git a/src/kotonoha/settings_dialog.py b/src/kotonoha/settings_dialog.py index de24838..bdf6080 100644 --- a/src/kotonoha/settings_dialog.py +++ b/src/kotonoha/settings_dialog.py @@ -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 @@ -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" @@ -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: diff --git a/tests/test_clock.py b/tests/test_clock.py index 72595e6..6d1d974 100644 --- a/tests/test_clock.py +++ b/tests/test_clock.py @@ -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 @@ -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(): @@ -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(): diff --git a/tests/test_config.py b/tests/test_config.py index 0c22285..c5b816a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,5 @@ +from typing import cast + from kotonoha.config import Config, load_config, save_config @@ -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) @@ -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 diff --git a/tests/test_controller.py b/tests/test_controller.py index 4deb630..3c29195 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,4 +1,5 @@ import os +from typing import cast os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -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") @@ -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 diff --git a/tests/test_lyrics.py b/tests/test_lyrics.py index fef8f54..0d01136 100644 --- a/tests/test_lyrics.py +++ b/tests/test_lyrics.py @@ -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(): diff --git a/tests/test_lyrics_resolver.py b/tests/test_lyrics_resolver.py index 5bd3454..fcade85 100644 --- a/tests/test_lyrics_resolver.py +++ b/tests/test_lyrics_resolver.py @@ -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) @@ -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() @@ -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( diff --git a/tests/test_mpris_provider.py b/tests/test_mpris_provider.py index 51f07d3..cb85792 100644 --- a/tests/test_mpris_provider.py +++ b/tests/test_mpris_provider.py @@ -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 diff --git a/tests/test_native_packaging.py b/tests/test_native_packaging.py index 52c24fe..8c003cc 100644 --- a/tests/test_native_packaging.py +++ b/tests/test_native_packaging.py @@ -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") diff --git a/tests/test_overlay.py b/tests/test_overlay.py index 3c96fa5..1baa460 100644 --- a/tests/test_overlay.py +++ b/tests/test_overlay.py @@ -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): @@ -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() @@ -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()) diff --git a/tests/test_receiver.py b/tests/test_receiver.py index 8036fb7..aad31a7 100644 --- a/tests/test_receiver.py +++ b/tests/test_receiver.py @@ -1,6 +1,8 @@ import json +from typing import cast import pytest +from aiohttp import web pytest.importorskip("PyQt6.QtCore") pytest.importorskip("aiohttp") @@ -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. diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 69433c5..7c9eda0 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -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() @@ -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() @@ -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 @@ -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 @@ -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