Each visualizer style's settings page has a Preset drop-down and a "Save as Preset…" button. The drop-down lists the built-in Default, your presets, and "Edit Presets…" (rename in place, delete); it reads "Custom" whenever the settings match no preset. Presets sync with the preferences; the per-machine sync delay is never part of one.
1042 lines
44 KiB
Python
1042 lines
44 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_box.setValue(dialog._width_box.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
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 7. The laser's speaker, phosphor, and the controls for them
|
||
# --------------------------------------------------------------------------
|
||
|
||
class TestSpeakerRange:
|
||
def _swing(self, hz, **speaker):
|
||
tilt = vs.LaserRenderer(_config(**speaker)).drive(_sine(hz, 1.0), RATE)
|
||
return np.abs(tilt[len(tilt) // 2:]).max()
|
||
|
||
def test_full_range_filters_nothing(self):
|
||
r = vs.LaserRenderer(_config())
|
||
r.drive(_sine(100, 0.1), RATE)
|
||
assert r._low_a is None and r._high_a is None
|
||
|
||
def test_bass_only_drops_the_treble(self):
|
||
assert self._swing(6000, laser_high_hz=200) < 1e-3 * self._swing(6000)
|
||
|
||
def test_treble_only_drowns_no_treble_in_bass(self):
|
||
# The membrane answers bass ~80 dB more than treble: a gentle
|
||
# speaker slope would let the bass through and hide the treble.
|
||
bass = self._swing(98, laser_low_hz=2000)
|
||
treble = self._swing(6000, laser_low_hz=2000)
|
||
assert treble > 10 * bass
|
||
|
||
def test_treble_only_is_not_held_under_the_noise_floor(self):
|
||
r = vs.LaserRenderer(_config(laser_low_hz=2000))
|
||
hat = _sine(6000, 1.0, 0.05)
|
||
_run(r, hat)
|
||
assert np.abs(r.paths[0][0]).max() > 0.3
|
||
|
||
def test_treble_only_simulates_every_sample(self):
|
||
fast = vs.LaserRenderer(_config(laser_low_hz=2000))
|
||
slow = vs.LaserRenderer(_config())
|
||
fast.drive(_sine(100, 0.1), RATE)
|
||
slow.drive(_sine(100, 0.1), RATE)
|
||
assert fast._step == 1 and slow._step == 4
|
||
|
||
def test_filtered_chunks_match_one_long_drive(self):
|
||
audio = _sine(3000, 0.3) + _sine(150, 0.3)
|
||
config = _config(laser_low_hz=500, laser_high_hz=8000)
|
||
whole = vs.LaserRenderer(config).drive(audio, RATE)
|
||
r = vs.LaserRenderer(config)
|
||
parts = [r.drive(c, RATE) for c in np.array_split(audio, [7, 900, 4000])]
|
||
assert np.allclose(np.concatenate(parts), whole)
|
||
|
||
def test_crossed_handles_are_uncrossed(self, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
prefs.set("visualizer_laser_low_hz", 5000)
|
||
prefs.set("visualizer_laser_high_hz", 1000)
|
||
config = vs.settings(prefs)
|
||
low, high = (config["visualizer_laser_low_hz"],
|
||
config["visualizer_laser_high_hz"])
|
||
assert high == 1000 and low * vs.SPEAKER_MIN_RATIO <= high
|
||
|
||
def test_one_pole_matches_the_recursion(self):
|
||
x = np.random.default_rng(0).standard_normal(5000)
|
||
for a in (0.05, 0.6, 0.999):
|
||
y, _ = vs.one_pole(x, a, 0.0)
|
||
ref, prev = np.empty_like(x), 0.0
|
||
for n, v in enumerate(x):
|
||
prev = a * prev + (1 - a) * v
|
||
ref[n] = prev
|
||
assert np.allclose(y, ref)
|
||
|
||
def test_thinning_keeps_a_fast_zigzag(self):
|
||
zigzag = np.column_stack([np.arange(100.0), np.tile([0, 20.0], 50)])
|
||
assert len(vs.thin_path(zigzag, 0.75)) == 100
|
||
smooth = np.column_stack([np.linspace(0, 1, 1000), np.zeros(1000)])
|
||
assert len(vs.thin_path(smooth, 0.75)) <= 3
|
||
|
||
|
||
class TestPhosphor:
|
||
def test_dimming_reaches_zero(self, qapp):
|
||
p = vs._Phosphor(20, 20, 1.0)
|
||
p.image.fill(QColor(255, 0, 0))
|
||
assert p.pixels.max() == 255 # the view is the image itself
|
||
for _ in range(600):
|
||
p.dim(0.995) # a slow fade, rounding down
|
||
assert p.pixels.max() == 0
|
||
|
||
def test_afterimage_length_follows_the_setting(self, qapp):
|
||
def lit(after):
|
||
r = vs.LaserRenderer(_config(laser_afterimage=after, laser_glow=False))
|
||
audio = _sine(150, 1.0) * np.linspace(1, 0.2, RATE)
|
||
for i in range(0, len(audio) - 706, 706):
|
||
r.advance(_frame(np.zeros(vs.HISTORY, np.float32),
|
||
audio[i:i + 706]))
|
||
_paint(r, 300, 200)
|
||
return (r._phosphor[(300, 200)].pixels[..., 3] > 0).sum()
|
||
assert lit(0.05) < lit(1.0)
|
||
|
||
def test_big_window_phosphor_is_half_resolution(self, qapp):
|
||
r = _run(vs.LaserRenderer(_config(laser_afterimage=0.5)), _sine(150, 0.2))
|
||
_paint(r, 1200, 750)
|
||
_paint(r, 140, 62)
|
||
assert r._phosphor[(1200, 750)].pixels.shape[:2] == (375, 600)
|
||
assert r._phosphor[(140, 62)].pixels.shape[:2] == (62, 140)
|
||
|
||
def test_a_setting_change_starts_it_afresh(self, qapp):
|
||
r = _run(vs.LaserRenderer(_config(laser_afterimage=0.5)), _sine(150, 0.2))
|
||
_paint(r, 300, 200)
|
||
r.configure(_config(laser_afterimage=0.5, laser_pitch=30))
|
||
assert r._phosphor == {}
|
||
|
||
|
||
class TestRangeSlider:
|
||
def test_handles_keep_their_gap_and_order(self, qapp):
|
||
from lintunes.gui.range_slider import RangeSlider
|
||
slider = RangeSlider(0, 100, gap=10)
|
||
got = []
|
||
slider.rangeChanged.connect(lambda lo, hi: got.append((lo, hi)))
|
||
slider.setValues(20, 80)
|
||
slider.set_handle(0, 95) # can't pass the other
|
||
assert slider.values() == (70, 80)
|
||
slider.set_handle(1, -5)
|
||
assert slider.values() == (70, 80)
|
||
assert got[-1] == (70, 80)
|
||
|
||
def test_a_click_moves_the_nearer_handle(self, qapp):
|
||
from PyQt6.QtCore import Qt, QPointF, QEvent
|
||
from PyQt6.QtGui import QMouseEvent
|
||
from lintunes.gui.range_slider import RangeSlider
|
||
slider = RangeSlider(0, 100)
|
||
slider.resize(216, 22)
|
||
slider.setValues(0, 100)
|
||
at = QPointF(slider._x_of(30), 11)
|
||
slider.mousePressEvent(QMouseEvent(
|
||
QEvent.Type.MouseButtonPress, at, at, Qt.MouseButton.LeftButton,
|
||
Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier))
|
||
assert slider.values() == (30, 100)
|
||
|
||
def test_paints(self, qapp):
|
||
from lintunes.gui.range_slider import RangeSlider
|
||
slider = RangeSlider(0, 300)
|
||
slider.resize(200, 22)
|
||
slider.setValues(50, 250)
|
||
assert not slider.grab().isNull()
|
||
|
||
|
||
class TestNewControls:
|
||
def _dialog(self, prefs):
|
||
return TestPreferencesPage._dialog(None, prefs)
|
||
|
||
def test_speaker_range_slider_writes_hz(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
dialog = self._dialog(prefs)
|
||
assert dialog._speaker.values() == (0, 300) # 20 Hz – 20 kHz
|
||
dialog._speaker.setValues(200, 300) # 2 kHz up
|
||
assert prefs.get("visualizer_laser_low_hz") == 2000
|
||
assert prefs.get("visualizer_laser_high_hz") == 20000
|
||
|
||
def test_line_width_box_steps_by_half(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
dialog = self._dialog(prefs)
|
||
box = dialog._width_box
|
||
box.setValue(2.0)
|
||
box.stepUp()
|
||
assert prefs.get("visualizer_laser_width") == 2.5
|
||
box.stepDown()
|
||
box.stepDown()
|
||
assert prefs.get("visualizer_laser_width") == 1.5
|
||
box.setValue(0.2) # under the minimum
|
||
assert box.value() == 1.0
|
||
|
||
def test_radial_dot_size(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
dialog = self._dialog(prefs)
|
||
dialog._radial_dot_slider.setValue(200)
|
||
assert prefs.get("visualizer_radial_dot") == 2.0
|
||
|
||
def ink(scale):
|
||
r = vs.RadialRenderer(_config(radial_dot=scale, radial_normalize=False))
|
||
r.advance(_frame(_tone(1000, 0.8)))
|
||
img = _paint(r, 400, 400)
|
||
return sum(QColor(img.pixel(x, y)) != QColor("white")
|
||
for x in range(400) for y in range(0, 400, 2))
|
||
assert ink(0.5) < ink(1.0) < ink(2.0)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 8. Presets
|
||
# --------------------------------------------------------------------------
|
||
|
||
from lintunes.gui import visualizer_presets as presets
|
||
|
||
|
||
def _combo_texts(combo):
|
||
return [combo.itemText(i) for i in range(combo.count())]
|
||
|
||
|
||
class TestPresets:
|
||
def _dialog(self, prefs):
|
||
return TestPreferencesPage._dialog(None, prefs)
|
||
|
||
def test_every_style_has_a_preset_menu(self, qapp, tmp_path):
|
||
dialog = self._dialog(Preferences(tmp_path))
|
||
for style in ("bars", "radial", "laser"):
|
||
combo = dialog._preset_combos[style]
|
||
assert combo.currentText() == "Default" # untouched = Default
|
||
assert _combo_texts(combo)[-1] == "Edit Presets…"
|
||
|
||
def test_changing_a_setting_reads_custom(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
dialog = self._dialog(prefs)
|
||
dialog._spokes_slider.setValue(60)
|
||
assert dialog._preset_combos["radial"].currentText() == "Custom"
|
||
assert dialog._preset_combos["laser"].currentText() == "Default"
|
||
|
||
def test_save_and_apply(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
dialog = self._dialog(prefs)
|
||
dialog._angle_sliders["visualizer_laser_pitch"].setValue(40)
|
||
dialog._grid.setChecked(True)
|
||
dialog._speaker.setValues(150, 300)
|
||
dialog._width_box.setValue(3.5)
|
||
dialog._ask_preset_name = lambda suggestion: "Big Tilt"
|
||
dialog._save_preset("laser")
|
||
combo = dialog._preset_combos["laser"]
|
||
assert combo.currentText() == "Big Tilt"
|
||
|
||
# Back to Default: every control (and setting) follows.
|
||
dialog._on_preset_chosen("laser", combo.findData("Default"))
|
||
assert prefs.get("visualizer_laser_pitch") == 0
|
||
assert prefs.get("visualizer_laser_grid") is False
|
||
assert prefs.get("visualizer_laser_width") == 1.5
|
||
assert dialog._speaker.values() == (0, 300)
|
||
assert combo.currentText() == "Default"
|
||
|
||
dialog._on_preset_chosen("laser", combo.findData("Big Tilt"))
|
||
assert prefs.get("visualizer_laser_pitch") == 40
|
||
assert prefs.get("visualizer_laser_grid") is True
|
||
assert prefs.get("visualizer_laser_width") == 3.5
|
||
assert dialog._angle_sliders["visualizer_laser_pitch"].value() == 40
|
||
assert dialog._speaker.values() == (150, 300)
|
||
assert combo.currentText() == "Big Tilt"
|
||
|
||
def test_presets_survive_a_restart(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
dialog = self._dialog(prefs)
|
||
dialog._bars_fade_slider.setValue(20)
|
||
dialog._ask_preset_name = lambda suggestion: "Ghosty"
|
||
dialog._save_preset("bars")
|
||
again = self._dialog(Preferences(tmp_path))
|
||
assert "Ghosty" in _combo_texts(again._preset_combos["bars"])
|
||
|
||
def test_default_is_reserved(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
dialog = self._dialog(prefs)
|
||
warned = []
|
||
dialog._preset_warning = warned.append
|
||
dialog._ask_preset_name = lambda suggestion: "Default"
|
||
dialog._save_preset("radial")
|
||
assert warned and presets.presets(prefs, "radial") == {}
|
||
|
||
def test_overwrite_asks_first(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
dialog = self._dialog(prefs)
|
||
dialog._ask_preset_name = lambda suggestion: "Mine"
|
||
dialog._save_preset("radial")
|
||
dialog._spokes_slider.setValue(200)
|
||
dialog._confirm_overwrite = lambda name: False
|
||
dialog._save_preset("radial")
|
||
assert presets.presets(prefs, "radial")["Mine"][
|
||
"visualizer_radial_spokes"] == 120 # kept
|
||
dialog._confirm_overwrite = lambda name: True
|
||
dialog._save_preset("radial")
|
||
assert presets.presets(prefs, "radial")["Mine"][
|
||
"visualizer_radial_spokes"] == 200
|
||
|
||
def test_a_preset_only_holds_its_own_style(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
presets.save(prefs, "bars", "x")
|
||
assert set(presets.presets(prefs, "bars")["x"]) == {"visualizer_bars_fade"}
|
||
assert "visualizer_sync_delay_ms" not in presets.STYLE_KEYS["laser"]
|
||
|
||
def test_an_old_preset_takes_new_settings_defaults(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
prefs.set(presets.PRESETS_KEY,
|
||
{"laser": {"Old": {"visualizer_laser_pitch": 20}}})
|
||
values = presets.values_of(prefs, "laser", "Old")
|
||
assert values["visualizer_laser_pitch"] == 20
|
||
assert values["visualizer_laser_glow"] is True # default
|
||
|
||
def test_every_style_key_has_a_control(self, qapp, tmp_path):
|
||
dialog = self._dialog(Preferences(tmp_path))
|
||
for style, keys in presets.STYLE_KEYS.items():
|
||
for key in keys:
|
||
assert key in dialog._viz_setters or key in (
|
||
"visualizer_laser_low_hz", "visualizer_laser_high_hz")
|
||
|
||
|
||
class TestPresetEditor:
|
||
def _editor(self, prefs, style="laser"):
|
||
return presets.PresetEditor(prefs, style, "Laser")
|
||
|
||
def test_lists_only_user_presets(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
editor = self._editor(prefs)
|
||
assert editor.list.count() == 0 and not editor.empty.isHidden()
|
||
presets.save(prefs, "laser", "b")
|
||
presets.save(prefs, "laser", "A")
|
||
editor = self._editor(prefs)
|
||
assert [editor.list.item(i).text() for i in range(2)] == ["A", "b"]
|
||
|
||
def test_rename_in_place(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
presets.save(prefs, "laser", "Old")
|
||
editor = self._editor(prefs)
|
||
editor.list.item(0).setText("New")
|
||
assert list(presets.presets(prefs, "laser")) == ["New"]
|
||
assert editor.list.item(0).text() == "New"
|
||
|
||
def test_rename_to_a_taken_name_is_refused(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
presets.save(prefs, "laser", "One")
|
||
presets.save(prefs, "laser", "Two")
|
||
editor = self._editor(prefs)
|
||
warned = []
|
||
editor._warn = warned.append
|
||
editor.list.item(0).setText("Two")
|
||
assert warned
|
||
assert sorted(presets.presets(prefs, "laser")) == ["One", "Two"]
|
||
assert editor.list.item(0).text() == "One"
|
||
|
||
def test_rename_to_default_is_refused(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
presets.save(prefs, "laser", "One")
|
||
editor = self._editor(prefs)
|
||
editor._warn = lambda text: None
|
||
editor.list.item(0).setText("Default")
|
||
assert list(presets.presets(prefs, "laser")) == ["One"]
|
||
|
||
def test_delete_asks_then_deletes(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
presets.save(prefs, "laser", "Gone")
|
||
editor = self._editor(prefs)
|
||
editor._confirm = lambda text: False
|
||
editor._delete_current()
|
||
assert "Gone" in presets.presets(prefs, "laser")
|
||
editor._confirm = lambda text: True
|
||
editor._delete_current()
|
||
assert presets.presets(prefs, "laser") == {}
|
||
assert not editor.delete_button.isEnabled()
|
||
|
||
def test_editing_one_style_leaves_the_others(self, qapp, tmp_path):
|
||
prefs = Preferences(tmp_path)
|
||
presets.save(prefs, "bars", "Keep")
|
||
presets.save(prefs, "laser", "Drop")
|
||
presets.delete(prefs, "laser", "Drop")
|
||
assert "Keep" in presets.presets(prefs, "bars")
|