- applyVolume/setVol now pass the element's CURRENT level as the normalize fallback instead of undefined, so a corrupt persisted volume holds the playing level rather than jumping to PLEX_SCALE.default=1 (full) — the "full blast" failure mode playerVolume.ts's header explicitly warns against. (The old bare clamp muted instead; both are wrong for corrupt data.) readMerged shallow-merges stored prefs without per-field validation, so a corrupt volume is reachable. - setVol normalises the level ONCE and both applies + persists that same value, so the <video> element and the stored pref can never disagree. - Test documents the hold-last-known contract (corrupt raw + valid fallback -> fallback; only an unusable fallback lands on the scale default). Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234).
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
DEFAULT_VOLUME,
|
|
PLEX_SCALE,
|
|
YT_SCALE,
|
|
normalizeVolume,
|
|
normalizeVolumeIn,
|
|
stepVolume,
|
|
stepVolumeIn,
|
|
} from "./playerVolume";
|
|
|
|
describe("normalizeVolume", () => {
|
|
it("keeps a valid level", () => {
|
|
expect(normalizeVolume(0)).toBe(0);
|
|
expect(normalizeVolume(37)).toBe(37);
|
|
expect(normalizeVolume(100)).toBe(100);
|
|
});
|
|
|
|
it("clamps out-of-range levels and rounds to whole percents", () => {
|
|
expect(normalizeVolume(-20)).toBe(0);
|
|
expect(normalizeVolume(150)).toBe(100);
|
|
expect(normalizeVolume(42.6)).toBe(43);
|
|
});
|
|
|
|
it.each([
|
|
["a missing key", undefined],
|
|
["an explicit null", null],
|
|
["a stringified number", "80"],
|
|
["NaN from a broken player", NaN],
|
|
["a whole object", { volume: 50 }],
|
|
])("falls back on %s", (_label, raw) => {
|
|
// The regression: a partial stored object made the level undefined, which Math.round turned
|
|
// into NaN — muting playback and persisting `null` for every later session.
|
|
expect(normalizeVolume(raw, 15)).toBe(15);
|
|
});
|
|
|
|
it("prefers the last known level over the default, so a quiet session stays quiet", () => {
|
|
expect(normalizeVolume(NaN, 15)).toBe(15);
|
|
expect(normalizeVolume(NaN, 15)).not.toBe(DEFAULT_VOLUME);
|
|
});
|
|
|
|
it("uses the default when the fallback is unusable too", () => {
|
|
expect(normalizeVolume(NaN, NaN)).toBe(DEFAULT_VOLUME);
|
|
expect(normalizeVolume(null, undefined)).toBe(DEFAULT_VOLUME);
|
|
expect(normalizeVolume(null, "80")).toBe(DEFAULT_VOLUME);
|
|
});
|
|
});
|
|
|
|
describe("stepVolume", () => {
|
|
it("steps up and down from the current level", () => {
|
|
expect(stepVolume(60, { delta: 5, fallback: 100 })).toBe(65);
|
|
expect(stepVolume(60, { delta: -5, fallback: 100 })).toBe(55);
|
|
});
|
|
|
|
it("clamps at both ends instead of wrapping", () => {
|
|
expect(stepVolume(98, { delta: 5, fallback: 100 })).toBe(100);
|
|
expect(stepVolume(3, { delta: -5, fallback: 100 })).toBe(0);
|
|
});
|
|
|
|
it("steps from the fallback when the player answers garbage", () => {
|
|
// A spin must not lose notches to a stale/unusable reading — it continues from what we last set.
|
|
expect(stepVolume(undefined, { delta: -5, fallback: 90 })).toBe(85);
|
|
expect(stepVolume(NaN, { delta: -5, fallback: 90 })).toBe(85);
|
|
});
|
|
});
|
|
|
|
describe("scale-generic core (shared by both players)", () => {
|
|
it("keeps 0..1 floats WITHOUT rounding on the Plex scale", () => {
|
|
// PLEX_SCALE.round is false — the HTML5 <video> takes a float, so 0.37 must stay 0.37 (the YT
|
|
// scale would round it to a whole percent).
|
|
expect(normalizeVolumeIn(0.37, undefined, PLEX_SCALE)).toBeCloseTo(0.37, 10);
|
|
expect(normalizeVolumeIn(1.5, undefined, PLEX_SCALE)).toBe(1); // clamps to max
|
|
expect(normalizeVolumeIn(-0.2, undefined, PLEX_SCALE)).toBe(0); // clamps to min
|
|
});
|
|
|
|
it("holds the last-known level on a corrupt Plex volume, never jumping to full", () => {
|
|
// The Plex-side latent bug this consolidation fixes: a bare clamp turned a corrupt persisted
|
|
// volume (null / "0.5" / NaN) into 0 — silently muting. The hook passes the element's CURRENT
|
|
// level as the fallback, so a corrupt read HOLDS that level rather than muting (old bug) OR
|
|
// jumping to PLEX_SCALE.default=1 / full (the failure mode the header warns against).
|
|
expect(normalizeVolumeIn(null, 0.3, PLEX_SCALE)).toBeCloseTo(0.3, 10); // holds a quiet 0.3
|
|
expect(normalizeVolumeIn("0.5", 0.3, PLEX_SCALE)).toBeCloseTo(0.3, 10);
|
|
expect(normalizeVolumeIn(NaN, 0.42, PLEX_SCALE)).toBeCloseTo(0.42, 10);
|
|
// Only when the fallback is ALSO unusable does it land on the scale default.
|
|
expect(normalizeVolumeIn(null, undefined, PLEX_SCALE)).toBe(PLEX_SCALE.default);
|
|
expect(normalizeVolumeIn(NaN, NaN, PLEX_SCALE)).toBe(PLEX_SCALE.default);
|
|
});
|
|
|
|
it("steps and clamps on the Plex scale", () => {
|
|
expect(stepVolumeIn(0.5, { delta: 0.05, fallback: 0.5 }, PLEX_SCALE)).toBeCloseTo(0.55, 10);
|
|
expect(stepVolumeIn(0.98, { delta: 0.05, fallback: 1 }, PLEX_SCALE)).toBe(1); // clamps at max
|
|
expect(stepVolumeIn(0.03, { delta: -0.05, fallback: 1 }, PLEX_SCALE)).toBe(0); // clamps at min
|
|
});
|
|
|
|
it("still rounds to whole percents on the YouTube scale via the generic core", () => {
|
|
expect(normalizeVolumeIn(42.6, undefined, YT_SCALE)).toBe(43);
|
|
expect(YT_SCALE.round).toBe(true);
|
|
expect(PLEX_SCALE.round).toBe(false);
|
|
});
|
|
});
|