Files
teamspeak-music-bot/web/src/composables/useDecoupledSlider.test.ts
T
saopig1andClaude Opus 4.8 5ae5168b6b fix(web): keep the volume slider draggable under the per-frame progress loop (#111)
The 60fps requestAnimationFrame progress clock (#107) re-renders the player
every ~16ms, and Vue re-applied `el.value = storeVolume` on a range input each
time — snapping the thumb back to the stale store value mid-drag (un-draggable
on desktop, janky on mobile).

Extract the decoupling into a useDecoupledSlider composable used by both the
desktop (Player.vue) and mobile (App.vue) sliders: a local display ref tracks
the native drag via @input (so the bound value always matches the element), the
store is committed only on @change (release), and an onRelease safety-net
(pointerup/pointercancel/blur) clears the dragging guard even when the browser
skips `change` (value released at its start point). External/store changes still
flow into the display except while dragging. Adds a regression test for the
no-snap-back invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:12:05 +08:00

85 lines
3.3 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { ref, nextTick } from "vue";
import { useDecoupledSlider } from "./useDecoupledSlider.js";
/** Minimal stand-in for an <input type="range"> change/input Event. */
function ev(value: number): Event {
return { target: { value: String(value) } } as unknown as Event;
}
describe("useDecoupledSlider (#111)", () => {
it("initialises display from the source, falling back when undefined", () => {
const src = ref<number | undefined>(40);
const { display } = useDecoupledSlider(() => src.value, () => {});
expect(display.value).toBe(40);
const empty = useDecoupledSlider(() => undefined, () => {}, 75);
expect(empty.display.value).toBe(75);
});
it("reflects external source changes into the display when not dragging", async () => {
const src = ref<number | undefined>(50);
const { display } = useDecoupledSlider(() => src.value, () => {});
src.value = 80;
await nextTick();
expect(display.value).toBe(80);
});
it("tracks @input locally without committing", () => {
const commit = vi.fn();
const { display, onInput } = useDecoupledSlider(() => 50, commit);
onInput(ev(63));
expect(display.value).toBe(63);
expect(commit).not.toHaveBeenCalled();
});
// THE REGRESSION: this is exactly what the 60fps rAF re-render did — push the
// (stale) source value back into the binding mid-drag. The guard must ignore
// it so the thumb stays where the user dragged it.
it("ignores external source changes WHILE dragging (no snap-back)", async () => {
const src = ref<number | undefined>(50);
const { display, onInput } = useDecoupledSlider(() => src.value, () => {});
onInput(ev(70)); // user starts dragging → display 70
expect(display.value).toBe(70);
// Simulate the per-frame re-render re-evaluating the (still-stale) source.
src.value = 50;
await nextTick();
expect(display.value).toBe(70); // stayed put — did NOT snap back to 50
});
// Corner case: a range input skips `change` when released back at its start
// value. onRelease (pointerup/pointercancel/blur) must still end the drag so
// the slider doesn't freeze against later external updates.
it("clears dragging on release even when @change never fires", async () => {
const src = ref<number | undefined>(50);
const { display, onInput, onRelease } = useDecoupledSlider(() => src.value, () => {});
onInput(ev(70)); // drag begins
onInput(ev(50)); // ...dragged back to the start value
onRelease(); // released — browser emits NO change event here
src.value = 30; // a later external update
await nextTick();
expect(display.value).toBe(30); // slider resumed following the source
});
it("commits on @change and resumes following the source afterwards", async () => {
const commit = vi.fn();
const src = ref<number | undefined>(50);
const { display, onInput, onChange } = useDecoupledSlider(() => src.value, commit);
onInput(ev(70));
onChange(ev(70)); // release
expect(commit).toHaveBeenCalledTimes(1);
expect(commit).toHaveBeenCalledWith(70);
expect(display.value).toBe(70);
// After release, external changes flow through again.
src.value = 35;
await nextTick();
expect(display.value).toBe(35);
});
});