v0.40.0: visualizer settings — laser screen angles, radial resolution
Laser: the screen's distance, pitch, yaw and roll are sliders (the beam ray is intersected with the turned screen; the drawing shrinks only when a steep screen would push the figure out of view), and the afterimage can be turned off. The membrane is now a slack 50 Hz balloon whose split rocking pairs land in the bass, so it draws loops ~60% of the time instead of ~25%. Radial: fading trails can be turned off; the number of frequency bands and volume steps are sliders; ghosts fade from the dot's own color into the background; a 4096-point FFT and interpolation end the flat bass arcs; and "Even out bass and treble" takes the song's overall tilt out so the dots sit on a circle.
This commit is contained in:
+208
-69
@@ -40,18 +40,26 @@ def _sine(hz, seconds=0.1, amp=0.5):
|
||||
|
||||
|
||||
def _frame(window, pending=None, dt=0.016):
|
||||
spectrum = np.abs(np.fft.rfft(window * np.hanning(len(window)))) / (len(window) / 4)
|
||||
freqs = np.fft.rfftfreq(len(window), d=1.0 / RATE)
|
||||
if pending is None:
|
||||
pending = np.zeros(0, np.float32)
|
||||
return vs.Frame(spectrum, freqs, RATE, pending, dt)
|
||||
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"), QColor("gray"))
|
||||
renderer.paint(painter, QRectF(0, 0, w, h), QColor("blue"))
|
||||
painter.end()
|
||||
return img
|
||||
|
||||
@@ -93,10 +101,18 @@ class TestStylePreference:
|
||||
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 = _sine(1000, 2048 / RATE)
|
||||
window = _tone(1000)
|
||||
for _ in range(5):
|
||||
renderer.advance(_frame(window, _sine(90, 0.016)))
|
||||
for size in ((140, 62), (1200, 750), (4, 4)):
|
||||
@@ -107,21 +123,37 @@ class TestStylePreference:
|
||||
# 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):
|
||||
frame = _frame(_sine(1000, 2048 / RATE))
|
||||
spectrum, freqs = _spectrum(_tone(1000), vs.BARS_FFT)
|
||||
edges = vs.band_edges(vs.BANDS, RATE)
|
||||
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
|
||||
band = np.searchsorted(edges, 1000) - 1
|
||||
assert levels.argmax() == band
|
||||
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):
|
||||
# At 360 bands the bottom ones are narrower than an FFT bin.
|
||||
frame = _frame(_sine(45, 2048 / RATE))
|
||||
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
|
||||
levels = vs.band_levels(frame.spectrum, frame.freqs, edges)
|
||||
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
|
||||
@@ -129,23 +161,30 @@ class TestBandLevels:
|
||||
|
||||
class TestRadial:
|
||||
def test_frequency_is_the_angle(self):
|
||||
r = vs.RadialRenderer()
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE)))
|
||||
edges = vs.band_edges(vs.RADIAL_BANDS, RATE)
|
||||
degree = np.searchsorted(edges, 1000) - 1
|
||||
assert abs(int(r.rings.argmax()) - degree) <= 2
|
||||
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):
|
||||
loud, quiet = vs.RadialRenderer(), vs.RadialRenderer()
|
||||
loud.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
quiet.advance(_frame(_sine(1000, 2048 / RATE, amp=0.01)))
|
||||
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(fade=0.5)
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
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(2048, np.float32))
|
||||
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
|
||||
@@ -156,51 +195,92 @@ class TestRadial:
|
||||
assert r.ghost_alpha().max() == 0
|
||||
assert not np.isfinite(r.ghosts).any() # forgotten, not just faint
|
||||
|
||||
def test_no_fade_means_no_ghosts(self):
|
||||
r = vs.RadialRenderer(fade=0)
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
r.advance(_frame(np.zeros(2048, np.float32)))
|
||||
def test_fading_off_means_no_ghosts(self):
|
||||
r = vs.RadialRenderer(_config(radial_fade=False))
|
||||
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(fade=1.0)
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=0.003)))
|
||||
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(_sine(1000, 2048 / RATE, amp=0.8)))
|
||||
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(fade=3.0)
|
||||
r = vs.RadialRenderer(_config(ghost_fade=3.0))
|
||||
rng = np.random.default_rng(1)
|
||||
for _ in range(200):
|
||||
r.advance(_frame(_sine(1000, 2048 / RATE, amp=rng.random())))
|
||||
rings, alpha = r.pooled(45)
|
||||
assert (alpha > 0).sum(axis=1).max() <= vs.STEPS
|
||||
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_bigger_circles_get_more_spokes(self):
|
||||
assert vs.spoke_count(28) < vs.spoke_count(400) == 360
|
||||
assert 360 % vs.spoke_count(28) == 0
|
||||
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 = vs.LaserRenderer()
|
||||
for _ in range(10):
|
||||
r.advance(_frame(np.zeros(2048, np.float32),
|
||||
np.zeros(706, np.float32)))
|
||||
r = _run(vs.LaserRenderer(), np.zeros(RATE // 4, np.float32))
|
||||
assert np.allclose(r.paths[0], 0.0)
|
||||
|
||||
def test_the_membrane_answers_its_resonance(self):
|
||||
f0 = vs.MODES[0][0]
|
||||
f = vs.MODES[1][0]
|
||||
on, off = vs.LaserRenderer(), vs.LaserRenderer()
|
||||
swing_on = np.abs(on.drive(_sine(f0, 0.5), RATE)).max()
|
||||
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
|
||||
|
||||
@@ -212,31 +292,64 @@ class TestLaser:
|
||||
for chunk in np.array_split(audio, [101, 700, 702, 5000])]
|
||||
assert np.allclose(np.concatenate(parts), whole)
|
||||
|
||||
def test_degenerate_modes_turn_a_line_into_a_loop(self):
|
||||
# The split (1,1) pair: driven between its two resonances, the two
|
||||
# axes are out of phase — an ellipse, not a line.
|
||||
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.1
|
||||
assert s[1] / s[0] > 0.4
|
||||
|
||||
def test_the_spot_stays_on_the_screen(self):
|
||||
r = vs.LaserRenderer()
|
||||
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]).max() / np.abs(near.paths[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]
|
||||
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]
|
||||
flat = _run(vs.LaserRenderer(_config()), audio).paths[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))
|
||||
hop = 706
|
||||
for i in range(0, len(audio) - hop, hop):
|
||||
r.advance(_frame(np.zeros(2048, np.float32),
|
||||
audio[i:i + hop], dt=0.016))
|
||||
assert np.abs(r.paths[0]).max() <= 1.0
|
||||
|
||||
def test_loud_music_fills_most_of_the_screen(self):
|
||||
r = vs.LaserRenderer()
|
||||
audio = _sine(vs.MODES[0][0], 1.0, 0.5)
|
||||
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(2048, np.float32), audio[i:i + 706]))
|
||||
assert np.abs(r.paths[0]).max() > 0.6
|
||||
r.advance(_frame(np.zeros(vs.HISTORY, np.float32), audio[i:i + 706]))
|
||||
px = r.paths[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_fading_off_keeps_only_this_frame(self):
|
||||
audio = _sine(150, 0.5)
|
||||
assert len(_run(vs.LaserRenderer(), audio).paths) == len(vs.PERSIST)
|
||||
r = _run(vs.LaserRenderer(_config(laser_fade=False)), audio)
|
||||
assert len(r.paths) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -287,7 +400,7 @@ class TestBigWindow:
|
||||
prefs.set("visualizer_style", "laser")
|
||||
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
|
||||
calls = []
|
||||
vis._renderer.paint = lambda p, rect, color, light: calls.append(rect)
|
||||
vis._renderer.paint = lambda p, rect, color: calls.append(rect)
|
||||
img = QImage(10, 10, QImage.Format.Format_ARGB32)
|
||||
painter = QPainter(img)
|
||||
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
|
||||
@@ -303,7 +416,7 @@ class TestBigWindow:
|
||||
prefs.set("visualizer_style", "laser")
|
||||
vis = VisualizerWidget(_FakePlayer(), prefs)
|
||||
colors = []
|
||||
vis._renderer.paint = lambda p, rect, color, light: colors.append(color)
|
||||
vis._renderer.paint = lambda p, rect, color: colors.append(color)
|
||||
img = QImage(10, 10, QImage.Format.Format_ARGB32)
|
||||
painter = QPainter(img)
|
||||
vis.paint_renderer(painter, QRectF(0, 0, 10, 10))
|
||||
@@ -333,21 +446,47 @@ class TestPreferencesPage:
|
||||
return False
|
||||
return PreferencesDialog(prefs, _LastFm())
|
||||
|
||||
def test_style_radios_and_fade_slider(self, qapp, tmp_path):
|
||||
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 not dialog._fade_slider.isEnabled()
|
||||
assert dialog._viz_pages.currentIndex() == 0
|
||||
|
||||
dialog._viz_radios["radial"].setChecked(True)
|
||||
assert prefs.get("visualizer_style") == "radial"
|
||||
assert dialog._fade_slider.isEnabled()
|
||||
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)
|
||||
assert dialog._fade_slider.isEnabled()
|
||||
dialog._fade_slider.setValue(25)
|
||||
assert prefs.get("visualizer_ghost_fade") == 2.5
|
||||
|
||||
dialog._viz_radios["laser"].setChecked(True)
|
||||
dialog._radial_fade.setChecked(False)
|
||||
assert prefs.get("visualizer_radial_fade") is False
|
||||
assert not dialog._fade_slider.isEnabled()
|
||||
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_laser_settings(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
dialog = self._dialog(prefs)
|
||||
dialog._laser_fade.setChecked(False)
|
||||
dialog._distance_slider.setValue(15)
|
||||
dialog._angle_sliders["visualizer_laser_pitch"].setValue(30)
|
||||
dialog._angle_sliders["visualizer_laser_roll"].setValue(-90)
|
||||
assert prefs.get("visualizer_laser_fade") is False
|
||||
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_building_the_dialog_writes_nothing(self, qapp, tmp_path):
|
||||
prefs = Preferences(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user