mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
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>
85 lines
3.3 KiB
TypeScript
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);
|
|
});
|
|
});
|