Files
lintunes/tests/test_round78.py
T
trav 726f7c4a89 v0.41.0: visualizer sync, fade sliders, laser look controls, custom highlight
Sync: decoding runs ahead of the speakers — by up to ~0.8 s after a GUI
stall, when buffers arrive in a burst — and the visualizer drew the newest
audio. It now keeps timestamped history and draws what the player says is
playing, plus a per-machine Sync delay (config.json) for latency Qt can't
see, like Bluetooth.

Every style has one Fade slider (0 = off); bars now leave fading steps.
Laser: afterimage time, line width down to 1 px, glow on/off, tip dot
on/off and size (a resting beam always shows its dot), and an oscilloscope
dot grid under the beam. Visualizer sliders run the full window width.

Highlight: click a swatch for a full color picker; the color picked
replaces that slot (right-click restores it).
2026-10-10 01:11:09 -07:00

690 lines
29 KiB
Python

"""Round 78: visualizer styles — bars, radial dots, laser — and the big window.
1. ``visualizer_style`` picks the renderer, live; nonsense falls back to bars.
2. ``band_levels`` is the bars' old analysis, and fills too-narrow bands.
3. Radial: frequency is the angle, volume the radius, moves leave ghosts.
4. Laser: a mirror on a membrane — resonance, continuity, stays on screen.
5. Double-click opens the big window without changing the brightness.
6. The Preferences page.
"""
import numpy as np
import pytest
from PyQt6.QtCore import QObject, pyqtSignal, QRectF
from PyQt6.QtGui import QImage, QPainter, QColor
from lintunes.preferences import Preferences
from lintunes.gui.visualizer import VisualizerWidget, MODE_ON, MODE_DIM, MODE_OFF
from lintunes.gui import visualizer_styles as vs
RATE = 44100
class _FakePlayer(QObject):
audio_buffer = pyqtSignal(object)
playing_changed = pyqtSignal(bool)
track_changed = pyqtSignal(object)
def __init__(self, playing=False):
super().__init__()
self._playing = playing
def is_playing(self):
return self._playing
def _sine(hz, seconds=0.1, amp=0.5):
t = np.arange(int(RATE * seconds)) / RATE
return (amp * np.sin(2 * np.pi * hz * t)).astype(np.float32)
def _frame(window, pending=None, dt=0.016):
if pending is None:
pending = np.zeros(0, np.float32)
return vs.Frame(np.asarray(window, np.float32), RATE, pending, dt)
def _config(**overrides):
config = vs.settings(None)
config.update({f"visualizer_{k}": v for k, v in overrides.items()})
return config
def _tone(hz, amp=0.5):
return _sine(hz, vs.HISTORY / RATE, amp)
def _paint(renderer, w=140, h=62):
img = QImage(w, h, QImage.Format.Format_ARGB32)
img.fill(QColor("white"))
painter = QPainter(img)
renderer.paint(painter, QRectF(0, 0, w, h), QColor("blue"))
painter.end()
return img
# --------------------------------------------------------------------------
# 1. The style preference
# --------------------------------------------------------------------------
class TestStylePreference:
def test_default_is_bars(self, qapp, tmp_path):
vis = VisualizerWidget(_FakePlayer(), Preferences(tmp_path))
assert isinstance(vis._renderer, vs.BarsRenderer)
@pytest.mark.parametrize("style, cls", [
("radial", vs.RadialRenderer), ("laser", vs.LaserRenderer)])
def test_saved_style_is_used(self, qapp, tmp_path, style, cls):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", style)
assert isinstance(VisualizerWidget(_FakePlayer(), prefs)._renderer, cls)
def test_nonsense_falls_back_to_bars(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "oscilloscope") # hand-edited file
vis = VisualizerWidget(_FakePlayer(), prefs)
assert isinstance(vis._renderer, vs.BarsRenderer)
def test_changing_the_pref_swaps_live(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(), prefs)
prefs.set("visualizer_style", "laser")
assert isinstance(vis._renderer, vs.LaserRenderer)
def test_an_unrelated_pref_keeps_the_renderer(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "radial")
vis = VisualizerWidget(_FakePlayer(), prefs)
before = vis._renderer
prefs.set("visualizer_ghost_fade", 2.5)
assert vis._renderer is before # state survives
assert before.fade == 2.5
def test_settings_are_clamped(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_radial_spokes", 100000) # hand-edited
prefs.set("visualizer_laser_pitch", "sideways")
config = vs.settings(prefs)
assert config["visualizer_radial_spokes"] == 360
assert config["visualizer_laser_pitch"] == 0
def test_every_style_paints_without_error(self, qapp):
for style in vs.STYLES:
renderer = vs.make_renderer(style)
window = _tone(1000)
for _ in range(5):
renderer.advance(_frame(window, _sine(90, 0.016)))
for size in ((140, 62), (1200, 750), (4, 4)):
_paint(renderer, *size)
# --------------------------------------------------------------------------
# 2. Shared analysis
# --------------------------------------------------------------------------
def _spectrum(window, size):
return vs.spectrum_of(np.asarray(window, np.float32), size, RATE)
class TestBandLevels:
def test_a_sine_lights_its_own_band(self):
spectrum, freqs = _spectrum(_tone(1000), vs.BARS_FFT)
edges = vs.band_edges(vs.BANDS, RATE)
levels = vs.band_levels(spectrum, freqs, edges)
assert levels.argmax() == np.searchsorted(edges, 1000) - 1
def test_bars_read_only_the_newest_2048_samples(self):
# The history grew to 4096 for the radial; the bars must not see it.
old = np.concatenate([_sine(3000, 2048 / RATE), _sine(1000, 2048 / RATE)])
spectrum, freqs = _spectrum(old, vs.BARS_FFT)
assert abs(freqs[spectrum.argmax()] - 1000) < 30
def test_narrow_bass_bands_are_not_silent(self):
spectrum, freqs = _spectrum(_tone(45), vs.RADIAL_FFT)
edges = vs.band_edges(360, RATE)
levels = vs.band_levels(spectrum, freqs, edges)
assert levels[:5].min() > 0.5
def test_narrow_bands_are_not_flat_copies(self):
# The flat arcs: runs of bass degrees all reading one bin.
spectrum, freqs = _spectrum(_tone(120) + _tone(300, 0.2), vs.RADIAL_FFT)
db = vs.band_db(spectrum, freqs, vs.band_edges(360, RATE))
bass = db[:120]
runs = np.diff(np.flatnonzero(np.diff(bass) != 0))
assert len(runs) == 0 or runs.max() < 3
# --------------------------------------------------------------------------
# 3. Radial
# --------------------------------------------------------------------------
class TestRadial:
def test_frequency_is_the_angle(self):
r = vs.RadialRenderer(_config(radial_normalize=False))
r.advance(_frame(_tone(1000)))
edges = vs.band_edges(r.spokes, RATE)
assert abs(int(r.rings.argmax()) - (np.searchsorted(edges, 1000) - 1)) <= 1
def test_volume_is_the_radius(self):
config = _config(radial_normalize=False)
loud, quiet = vs.RadialRenderer(config), vs.RadialRenderer(config)
loud.advance(_frame(_tone(1000, amp=0.8)))
quiet.advance(_frame(_tone(1000, amp=0.01)))
assert loud.rings.max() > quiet.rings.max() > 0
def test_spokes_and_levels_are_settings(self):
r = vs.RadialRenderer(_config(radial_spokes=36, radial_levels=8))
r.advance(_frame(_tone(1000, amp=0.9)))
assert len(r.rings) == 36 and r.rings.max() <= 8
r.configure(_config(radial_spokes=200, radial_levels=30))
assert len(r.rings) == 200 and r.ghosts.shape == (200, 31)
def test_a_falling_dot_leaves_ghosts_that_fade(self):
r = vs.RadialRenderer(_config(ghost_fade=0.5, radial_normalize=False))
r.advance(_frame(_tone(1000, amp=0.8)))
degree, top = int(r.rings.argmax()), int(r.rings.max())
silence = _frame(np.zeros(vs.HISTORY, np.float32))
r.advance(silence)
assert r.ghost_alpha()[degree, top] > 0.9 # where it was, fresh
for _ in range(20): # 0.32 s
r.advance(silence)
assert 0 < r.ghost_alpha()[degree, top] < 0.9
for _ in range(40): # well past 0.5 s
r.advance(silence)
assert r.ghost_alpha().max() == 0
assert not np.isfinite(r.ghosts).any() # forgotten, not just faint
def test_fading_off_means_no_ghosts(self):
r = vs.RadialRenderer(_config(ghost_fade=0))
r.advance(_frame(_tone(1000, amp=0.8)))
r.advance(_frame(np.zeros(vs.HISTORY, np.float32)))
assert r.ghost_alpha().max() == 0
def test_ghosts_are_the_dot_color_fading(self, qapp):
r = vs.RadialRenderer(_config(radial_spokes=12, radial_normalize=False))
r.advance(_frame(_tone(1000, amp=0.8)))
r.advance(_frame(np.zeros(vs.HISTORY, np.float32)))
img = _paint(r, 400, 400)
tinted = [QColor(img.pixel(x, y)) for x in range(0, 400, 2)
for y in range(0, 400, 2)]
tinted = [c for c in tinted if c != QColor("white")]
# Every painted pixel is blue over white — never gray.
assert tinted and all(c.blue() == 255 and c.red() == c.green()
for c in tinted)
def test_a_rise_leaves_only_where_it_came_from(self):
# An instant attack must not paint a bar of ghosts below the dot.
r = vs.RadialRenderer(_config(ghost_fade=1.0, radial_normalize=False))
r.advance(_frame(_tone(1000, amp=0.003)))
before = r.rings.copy()
r.advance(_frame(_tone(1000, amp=0.8)))
degree = int(np.argmax(r.rings - before))
alive = np.nonzero(r.ghost_alpha()[degree] > 0)[0]
assert list(alive) == [before[degree]]
def test_ghosts_per_spoke_are_bounded(self):
r = vs.RadialRenderer(_config(ghost_fade=3.0))
rng = np.random.default_rng(1)
for _ in range(200):
r.advance(_frame(_tone(1000, amp=rng.random())))
rings, alpha, _angle = r.pooled(45)
assert len(rings) <= 45
assert (alpha > 0).sum(axis=1).max() <= r.ring_count
def test_evening_out_lines_bass_up_with_treble(self):
# Pink-ish: every octave 6 dB quieter than the one below.
t = np.arange(vs.HISTORY) / RATE
audio = sum(0.5 / 2 ** k * np.sin(2 * np.pi * 55 * 2 ** k * t)
for k in range(8)).astype(np.float32)
spread = {}
for normalize in (False, True):
r = vs.RadialRenderer(_config(radial_normalize=normalize,
radial_spokes=360))
for _ in range(300):
r.advance(_frame(audio))
edges = vs.band_edges(360, RATE)
peaks = [r.levels[np.searchsorted(edges, 55 * 2 ** k) - 1]
for k in range(8)]
spread[normalize] = max(peaks) - min(peaks)
assert spread[True] < spread[False] / 2
def test_evening_out_never_draws_frequencies_the_song_lacks():
# A lone tone: the fit has one loud point to go on. Nothing far from
# it may be lifted into view.
r = vs.RadialRenderer(_config(radial_normalize=True, radial_spokes=360))
for _ in range(300):
r.advance(_frame(_tone(180, 0.1)))
edges = vs.band_edges(360, RATE)
far = np.searchsorted(edges, 2000)
assert r.rings[far:].max() == 0
# --------------------------------------------------------------------------
# 4. Laser
# --------------------------------------------------------------------------
def _run(renderer, audio, hop=706):
for i in range(0, len(audio) - hop, hop):
renderer.advance(_frame(np.zeros(vs.HISTORY, np.float32),
audio[i:i + hop]))
return renderer
class TestLaser:
def test_silence_is_a_still_dot_in_the_middle(self):
r = _run(vs.LaserRenderer(), np.zeros(RATE // 4, np.float32))
assert np.allclose(r.paths[0][0], 0.0)
def test_the_membrane_answers_its_resonance(self):
f = vs.MODES[1][0]
on, off = vs.LaserRenderer(), vs.LaserRenderer()
swing_on = np.abs(on.drive(_sine(f, 0.5), RATE)).max()
swing_off = np.abs(off.drive(_sine(3000, 0.5), RATE)).max()
assert swing_on > 20 * swing_off
def test_chunking_does_not_change_the_path(self):
audio = _sine(143, 0.3) + _sine(90, 0.3)
whole = vs.LaserRenderer().drive(audio, RATE)
pieces = vs.LaserRenderer()
parts = [pieces.drive(chunk, RATE)
for chunk in np.array_split(audio, [101, 700, 702, 5000])]
assert np.allclose(np.concatenate(parts), whole)
def test_the_split_pair_draws_loops(self):
# Between the (1,1) pair's resonances the two axes answer a quarter
# cycle apart: nearly a circle, not a line.
f = (vs.MODES[1][0] + vs.MODES[2][0]) / 2
tilt = vs.LaserRenderer().drive(_sine(f, 1.0), RATE)[-2000:]
tilt = tilt - tilt.mean(axis=0)
s = np.linalg.svd(tilt, compute_uv=False)
assert s[1] / s[0] > 0.4
def test_farther_screen_bigger_figure(self):
audio = _sine(vs.MODES[1][0], 1.0)
near = _run(vs.LaserRenderer(_config(laser_distance=1.0)), audio)
far = _run(vs.LaserRenderer(_config(laser_distance=4.0)), audio)
ratio = np.abs(far.paths[0][0]).max() / np.abs(near.paths[0][0]).max()
assert 3.5 < ratio < 4.5
def test_pitch_stretches_vertically_and_roll_spins(self):
audio = _sine((vs.MODES[1][0] + vs.MODES[2][0]) / 2, 1.0)
def spread(**angles):
path = _run(vs.LaserRenderer(_config(**angles)), audio).paths[0][0]
return np.ptp(path[:, 0]), np.ptp(path[:, 1])
square_x, square_y = spread()
tipped_x, tipped_y = spread(laser_pitch=50)
assert tipped_y / square_y > 1.3 * tipped_x / square_x
turned_x, turned_y = spread(laser_yaw=50)
assert turned_x / square_x > 1.3 * turned_y / square_y
spun = _run(vs.LaserRenderer(_config(laser_roll=90)), audio).paths[0][0]
flat = _run(vs.LaserRenderer(_config()), audio).paths[0][0]
# A quarter turn: x becomes y (up to sign).
assert np.allclose(np.abs(spun[:, 0]), np.abs(flat[:, 1]), atol=1e-9)
@pytest.mark.parametrize("angles", [
{}, {"laser_pitch": 60, "laser_yaw": -60},
{"laser_pitch": -60, "laser_yaw": 60, "laser_roll": 45},
{"laser_distance": 1.0, "laser_pitch": 60}])
def test_every_setting_stays_on_screen_and_visible(self, angles):
rng = np.random.default_rng(0)
audio = _sine(90, 2, 0.9) * (1 + rng.standard_normal(2 * RATE) * 0.3)
audio = audio + _sine(150, 2, 0.5)
r = vs.LaserRenderer(_config(**angles))
rect = QRectF(0, 0, 140, 62)
half_w, half_h = 70 - vs.INSET, 31 - vs.INSET
biggest = 0.0
for i in range(0, len(audio) - 706, 706):
r.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + 706]))
px = r.paths[0][0] * r.scale(rect)
assert np.abs(px[:, 0]).max() <= half_w
assert np.abs(px[:, 1]).max() <= half_h
biggest = max(biggest, np.abs(px).max())
assert biggest > 0.2 * half_h # and never shrunk to a dot
def test_afterimage_lasts_its_fade_time(self):
audio = _sine(150, 1.0)
r = _run(vs.LaserRenderer(_config(laser_afterimage=0.2)), audio)
ages = [r.clock - born for _p, born, _poly in r.paths[1:]]
assert ages and max(ages) < 0.2 + 1e-9
assert 10 <= len(r.paths) <= 14 # 0.2 s of 16 ms frames
def test_afterimage_zero_keeps_only_this_frame(self):
r = _run(vs.LaserRenderer(_config(laser_afterimage=0)), _sine(150, 0.5))
assert len(r.paths) == 1
def test_head_dot_and_glow_settings(self, qapp):
audio = _sine(150, 0.5)
def painted(**kw):
r = _run(vs.LaserRenderer(_config(**kw)), audio)
img = _paint(r, 400, 250)
return sum(QColor(img.pixel(x, y)) != QColor("white")
for x in range(0, 400, 2) for y in range(0, 250, 2))
full = painted()
assert painted(laser_glow=False) < full
assert painted(laser_head=False) <= full
assert painted(laser_width=6) > painted(laser_width=1)
def test_grid_is_drawn_under_the_beam_only_when_on(self, qapp):
calls = []
def paint(renderer, gray):
renderer.paint_grid = lambda painter, rect, g: calls.append(g)
img = QImage(400, 250, QImage.Format.Format_ARGB32)
painter = QPainter(img)
renderer.paint(painter, QRectF(0, 0, 400, 250), QColor("blue"), gray)
painter.end()
paint(vs.LaserRenderer(_config(laser_grid=False)), QColor("gray"))
assert calls == []
paint(vs.LaserRenderer(_config(laser_grid=True)), QColor("gray"))
assert len(calls) == 1
def test_grid_dots_land_on_divisions(self, qapp):
r = vs.LaserRenderer(_config(laser_grid=True))
img = QImage(400, 250, QImage.Format.Format_ARGB32)
img.fill(QColor("white"))
painter = QPainter(img)
r.paint_grid(painter, QRectF(0, 0, 400, 250), QColor("black"))
painter.end()
d = (125 - vs.INSET) / vs.GRID_HALF
on = QColor(img.pixel(200 + round(d), 125 + round(d)))
between = QColor(img.pixel(200 + round(d / 2), 125 + round(d / 2)))
assert on != QColor("white") and between == QColor("white")
def test_resting_beam_always_shows_its_dot(self, qapp):
r = _run(vs.LaserRenderer(_config(laser_head=False, laser_dot=10)),
np.zeros(RATE // 4, np.float32))
img = _paint(r, 400, 250)
assert QColor(img.pixel(200, 125)) != QColor("white")
class TestBarsFade:
def test_a_falling_bar_leaves_fading_steps(self):
r = vs.BarsRenderer(_config(bars_fade=0.5))
r.advance(_frame(_tone(1000, 0.8)))
band = int(np.argmax(r.bars))
top = r.bars[band]
for _ in range(10):
r.advance(_frame(np.zeros(vs.HISTORY, np.float32)))
assert r.bars[band] < top
assert 0 < r.ghost_alpha()[band, top] < 1
def test_a_rise_leaves_nothing(self):
r = vs.BarsRenderer(_config(bars_fade=1.0))
r.advance(_frame(_tone(1000, 0.8)))
assert not np.isfinite(r.ghosts).any()
def test_zero_is_the_classic_bars(self):
r = vs.BarsRenderer(_config(bars_fade=0))
r.advance(_frame(_tone(1000, 0.8)))
r.advance(_frame(np.zeros(vs.HISTORY, np.float32)))
assert r.ghost_alpha().max() == 0
# --------------------------------------------------------------------------
# 5. Double-click → big window
# --------------------------------------------------------------------------
class TestBigWindow:
def test_double_click_keeps_the_brightness_and_opens(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
vis.mousePressEvent(None) # Qt's first press: on -> dim
vis.mouseDoubleClickEvent(None) # …which the double-click undoes
try:
assert vis._mode == MODE_ON
assert prefs.get("visualizer_mode") == MODE_ON
assert vis._big is not None and vis._big.isVisible()
finally:
vis._big.close()
def test_a_click_closes_it(self, qapp):
from PyQt6.QtCore import Qt, QPointF, QEvent
from PyQt6.QtGui import QMouseEvent
vis = VisualizerWidget(_FakePlayer())
vis.open_big()
big = vis._big
center = QPointF(big.contentsRect().center())
release = QMouseEvent(
QEvent.Type.MouseButtonRelease, center, center,
Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier)
big.mouseReleaseEvent(release)
assert not big.isVisible()
def test_off_panel_still_animates_the_big_window(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_OFF)
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
assert not vis._timer.isActive()
vis.open_big()
assert vis._timer.isActive()
vis._big.close()
assert not vis._timer.isActive()
def test_big_window_paints_in_color_while_the_panel_is_off(
self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_OFF)
prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
calls = []
vis._renderer.paint = lambda p, rect, color, gray=None: calls.append(rect)
img = QImage(10, 10, QImage.Format.Format_ARGB32)
painter = QPainter(img)
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
assert calls == [] # the panel stays blank
vis.paint_renderer(painter, QRectF(0, 0, 10, 10), big=True)
painter.end()
assert len(calls) == 1
def test_the_laser_is_drawn_in_gray_when_dim(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_mode", MODE_DIM)
prefs.set("color_visualizer_gray", 120)
prefs.set("visualizer_style", "laser")
vis = VisualizerWidget(_FakePlayer(), prefs)
colors = []
vis._renderer.paint = lambda p, rect, color, gray=None: colors.append(color)
img = QImage(10, 10, QImage.Format.Format_ARGB32)
painter = QPainter(img)
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
painter.end()
assert colors[0].red() == colors[0].green() == colors[0].blue() == 120
# --------------------------------------------------------------------------
# 5b. Lined up with what's heard
# --------------------------------------------------------------------------
class _PositionedPlayer(_FakePlayer):
def __init__(self):
super().__init__(playing=True)
self.position = 0
def position_ms(self):
return self.position
def _marked(ms, rate=RATE):
"""``ms`` of audio whose every sample is its own stream time."""
return (np.arange(int(ms * rate / 1000)) * 1000.0 / rate).astype(np.float32)
class TestSync:
def test_frame_ends_at_what_is_heard_not_the_newest(self, qapp, monkeypatch,
tmp_path):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
player = _PositionedPlayer()
vis = VisualizerWidget(player)
audio = _marked(1000)
vis._feed(audio, RATE, start_ms=0) # a burst: decoded 1 s ahead
player.position = 400 # …but the speakers are at 0.4 s
frame = vis._frame(0.016)
assert abs(frame.samples[-1] - 400) < 2
assert len(frame.samples) == vs.HISTORY
def test_no_position_means_the_newest(self, qapp):
vis = VisualizerWidget(_FakePlayer())
vis._feed(_marked(500), RATE)
assert abs(vis._frame(0.016).samples[-1] - 500) < 2
def test_the_laser_gets_each_heard_sample_once(self, qapp, monkeypatch,
tmp_path):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
player = _PositionedPlayer()
vis = VisualizerWidget(player)
vis._feed(_marked(1000), RATE, start_ms=0)
got = []
for position in (100, 116, 132, 150):
player.position = position
vis._anchor = None # no clock extrapolation in a test
got.append(vis._frame(0.016).pending)
joined = np.concatenate(got[1:])
assert abs(joined[0] - 100) < 1 and abs(joined[-1] - 150) < 1
assert np.all(np.diff(joined) > 0) # in order, nothing repeated
def test_a_seek_starts_a_fresh_history(self, qapp):
vis = VisualizerWidget(_FakePlayer())
vis._feed(np.ones(4410, np.float32), RATE, start_ms=0)
vis._feed(np.full(4410, 2, np.float32), RATE, start_ms=60000)
assert vis._hist[:-4410].max() == 0
def test_sync_delay_comes_from_config(self, qapp, monkeypatch, tmp_path):
from lintunes.config import save_config
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
save_config({"visualizer_sync_delay_ms": 200})
player = _PositionedPlayer()
vis = VisualizerWidget(player)
vis._feed(_marked(1000), RATE, start_ms=0)
player.position = 600
assert abs(vis._frame(0.016).samples[-1] - 400) < 2
# --------------------------------------------------------------------------
# 6. Preferences
# --------------------------------------------------------------------------
class TestPreferencesPage:
def _dialog(self, prefs):
from lintunes.gui.preferences_dialog import PreferencesDialog
class _LastFm(QObject):
login_finished = pyqtSignal(bool, str)
status_message = pyqtSignal(str)
def is_logged_in(self):
return False
return PreferencesDialog(prefs, _LastFm())
def test_style_radios_show_that_styles_settings(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
assert dialog._viz_radios["bars"].isChecked()
assert dialog._viz_pages.currentIndex() == 0
dialog._viz_radios["radial"].setChecked(True)
assert prefs.get("visualizer_style") == "radial"
assert dialog._viz_pages.currentIndex() == 1
dialog._viz_radios["laser"].setChecked(True)
assert dialog._viz_pages.currentIndex() == 2
def test_radial_settings(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
dialog._fade_slider.setValue(50) # twentieths of a second
assert prefs.get("visualizer_ghost_fade") == 2.5
dialog._fade_slider.setValue(0)
assert prefs.get("visualizer_ghost_fade") == 0
dialog._spokes_slider.setValue(60)
dialog._levels_slider.setValue(10)
dialog._normalize.setChecked(False)
assert (prefs.get("visualizer_radial_spokes"),
prefs.get("visualizer_radial_levels"),
prefs.get("visualizer_radial_normalize")) == (60, 10, False)
def test_every_style_has_a_fade_slider(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
for slider, key in ((dialog._bars_fade_slider, "visualizer_bars_fade"),
(dialog._fade_slider, "visualizer_ghost_fade"),
(dialog._laser_fade_slider,
"visualizer_laser_afterimage")):
assert slider.minimum() == 0 # 0 = off
slider.setValue(10)
assert prefs.get(key) == 0.5
def test_laser_settings(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
dialog._grid.setChecked(True)
assert prefs.get("visualizer_laser_grid") is True
dialog._glow.setChecked(False)
dialog._head.setChecked(False)
dialog._width_slider.setValue(dialog._width_slider.minimum())
dialog._dot_slider.setValue(12)
dialog._distance_slider.setValue(15)
dialog._angle_sliders["visualizer_laser_pitch"].setValue(30)
assert prefs.get("visualizer_laser_glow") is False
assert prefs.get("visualizer_laser_head") is False
assert prefs.get("visualizer_laser_width") == 1 # one pixel
assert prefs.get("visualizer_laser_dot") == 6
assert prefs.get("visualizer_laser_distance") == 1.5
assert prefs.get("visualizer_laser_pitch") == 30
lo, hi = vs.LIMITS["visualizer_laser_roll"]
slider = dialog._angle_sliders["visualizer_laser_roll"]
assert (slider.minimum(), slider.maximum()) == (lo, hi)
def test_sync_delay_saves_to_config_not_prefs(self, qapp, tmp_path,
monkeypatch):
from lintunes.config import load_config
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
dialog._sync_slider.setValue(150)
dialog._save_sync_delay()
assert load_config()["visualizer_sync_delay_ms"] == 150
assert prefs.get("visualizer_sync_delay_ms") is None
def test_a_swatch_repaints_its_slot(self, qapp, tmp_path):
from lintunes import theme
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
dialog._pick_color = lambda initial: QColor("#ff3300")
dialog._choose_color("seafoam")
assert prefs.get("highlight") == "seafoam"
assert theme.highlight_color(prefs).name() == "#ff3300"
assert dialog._color_radios["seafoam"].text() == "custom"
assert theme.highlight_palette(prefs)["blue"] == "#3584E4"
dialog._set_slot("seafoam", None) # right-click → restore
assert theme.highlight_palette(prefs)["seafoam"] == "#7FD1AE"
assert dialog._color_radios["seafoam"].text() == "seafoam"
def test_cancelling_the_picker_changes_nothing(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
dialog = self._dialog(prefs)
dialog._pick_color = lambda initial: QColor() # invalid = cancel
dialog._choose_color("magenta")
assert prefs.get("highlight_palette") == {}
assert prefs.get("highlight") == "blue"
def test_a_bad_saved_color_falls_back(self, qapp, tmp_path):
from lintunes import theme
prefs = Preferences(tmp_path)
prefs.set("highlight_palette", {"blue": "not a color", "nope": "#000"})
assert theme.highlight_palette(prefs) == theme.HIGHLIGHT_COLORS
def test_building_the_dialog_writes_nothing(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", "laser")
before = (tmp_path / "preferences.json").read_text()
self._dialog(prefs)
assert (tmp_path / "preferences.json").read_text() == before