mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-30 11:02:15 +03:00
Compare commits
3 Commits
dependabot
...
fix/v3850-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
111229083c | ||
|
|
3ba597bbd5 | ||
|
|
44647db82c |
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** replaced the Model Lockout card's unprovenanced notification MP3 with a short, locally synthesized Web Audio chime ([#11731](https://github.com/diegosouzapw/OmniRoute/pull/11731)) — both toggles retain optional audible feedback without shipping replacement media or third-party code, and unsupported, suspended, or rejected audio contexts fail open without blocking the setting change.
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Card, Toggle } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -23,6 +23,66 @@ const DEFAULTS: ModelLockoutSettings = {
|
||||
useExponentialBackoff: true,
|
||||
};
|
||||
|
||||
type WebkitAudioWindow = Window & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
function scheduleNotifyChime(context: AudioContext): void {
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
const startsAt = context.currentTime;
|
||||
const endsAt = startsAt + 0.1;
|
||||
|
||||
// A short, locally synthesized tone avoids shipping a third-party audio asset.
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(880, startsAt);
|
||||
gain.gain.setValueAtTime(0.0001, startsAt);
|
||||
gain.gain.exponentialRampToValueAtTime(0.045, startsAt + 0.012);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + 0.09);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
oscillator.onended = () => {
|
||||
oscillator.disconnect();
|
||||
gain.disconnect();
|
||||
};
|
||||
oscillator.start(startsAt);
|
||||
oscillator.stop(endsAt);
|
||||
}
|
||||
|
||||
function playNotifyChime(contextRef: { current: AudioContext | null }): void {
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const AudioContextConstructor =
|
||||
window.AudioContext ?? (window as WebkitAudioWindow).webkitAudioContext;
|
||||
if (!AudioContextConstructor) return;
|
||||
|
||||
if (!contextRef.current || contextRef.current.state === "closed") {
|
||||
contextRef.current = new AudioContextConstructor();
|
||||
}
|
||||
|
||||
const context = contextRef.current;
|
||||
if (context.state !== "running") {
|
||||
void context
|
||||
.resume()
|
||||
.then(() => {
|
||||
try {
|
||||
scheduleNotifyChime(context);
|
||||
} catch {
|
||||
// Sound is optional and must never block a settings change.
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleNotifyChime(context);
|
||||
} catch {
|
||||
// Sound is optional and must never block a settings change.
|
||||
}
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
@@ -71,6 +131,7 @@ export default function ModelLockoutCard() {
|
||||
const t = useTranslations("settings");
|
||||
const tc = useTranslations("common");
|
||||
const notify = useNotificationStore();
|
||||
const notifyAudioContextRef = useRef<AudioContext | null>(null);
|
||||
|
||||
const [data, setData] = useState<ModelLockoutSettings>(DEFAULTS);
|
||||
const [draft, setDraft] = useState<ModelLockoutSettings>(DEFAULTS);
|
||||
@@ -78,6 +139,21 @@ export default function ModelLockoutCard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const context = notifyAudioContextRef.current;
|
||||
notifyAudioContextRef.current = null;
|
||||
if (context && context.state !== "closed") {
|
||||
try {
|
||||
void context.close().catch(() => undefined);
|
||||
} catch {
|
||||
// Sound cleanup is optional and must never block the page from unmounting.
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
@@ -254,22 +330,6 @@ export default function ModelLockoutCard() {
|
||||
return `${ms}ms`;
|
||||
};
|
||||
|
||||
const notifyRef = useRef<HTMLAudioElement | null>(null);
|
||||
const playNotify = useCallback(() => {
|
||||
try {
|
||||
if (notifyRef.current) {
|
||||
notifyRef.current.pause();
|
||||
notifyRef.current.currentTime = 0;
|
||||
} else {
|
||||
notifyRef.current = new Audio("/audio/ui-notify.mp3");
|
||||
notifyRef.current.volume = 0.3;
|
||||
}
|
||||
void notifyRef.current.play();
|
||||
} catch {
|
||||
// Audio is optional.
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
@@ -314,7 +374,7 @@ export default function ModelLockoutCard() {
|
||||
checked={draft.enabled}
|
||||
onChange={(checked) => {
|
||||
setDraft((prev) => ({ ...prev, enabled: checked }));
|
||||
playNotify();
|
||||
playNotifyChime(notifyAudioContextRef);
|
||||
}}
|
||||
label={t("modelLockoutEnabled")}
|
||||
description={t("modelLockoutEnabledDescription")}
|
||||
@@ -443,7 +503,7 @@ export default function ModelLockoutCard() {
|
||||
...prev,
|
||||
useExponentialBackoff: checked,
|
||||
}));
|
||||
playNotify();
|
||||
playNotifyChime(notifyAudioContextRef);
|
||||
}}
|
||||
label={t("modelLockoutExponentialBackoff")}
|
||||
description={t("modelLockoutExponentialBackoffDescription")}
|
||||
|
||||
282
tests/unit/ui/model-lockout-notify-chime.test.tsx
Normal file
282
tests/unit/ui/model-lockout-notify-chime.test.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
// @vitest-environment jsdom
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const translate = (key: string) => key;
|
||||
const notifications = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => translate,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/notificationStore", () => ({
|
||||
useNotificationStore: () => notifications,
|
||||
}));
|
||||
|
||||
import ModelLockoutCard from "../../../src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard";
|
||||
|
||||
type OscillatorMock = {
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
frequency: { setValueAtTime: ReturnType<typeof vi.fn> };
|
||||
onended: (() => void) | null;
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
type: OscillatorType;
|
||||
};
|
||||
|
||||
function createAudioContextMock(
|
||||
options: { resumeRejects?: boolean; state?: AudioContextState } = {}
|
||||
) {
|
||||
const contexts: AudioContextMock[] = [];
|
||||
const oscillators: OscillatorMock[] = [];
|
||||
const gains: Array<{
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
gain: {
|
||||
cancelScheduledValues: ReturnType<typeof vi.fn>;
|
||||
exponentialRampToValueAtTime: ReturnType<typeof vi.fn>;
|
||||
setValueAtTime: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}> = [];
|
||||
|
||||
class AudioContextMock {
|
||||
close = vi.fn().mockResolvedValue(undefined);
|
||||
currentTime = 1;
|
||||
destination = {};
|
||||
state: AudioContextState = options.state ?? "running";
|
||||
resume = options.resumeRejects
|
||||
? vi.fn().mockRejectedValue(new Error("audio resume denied"))
|
||||
: vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
constructor() {
|
||||
contexts.push(this);
|
||||
}
|
||||
|
||||
createOscillator() {
|
||||
const oscillator: OscillatorMock = {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
frequency: { setValueAtTime: vi.fn() },
|
||||
onended: null,
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
type: "sine",
|
||||
};
|
||||
oscillators.push(oscillator);
|
||||
return oscillator;
|
||||
}
|
||||
|
||||
createGain() {
|
||||
const gain = {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
gain: {
|
||||
cancelScheduledValues: vi.fn(),
|
||||
exponentialRampToValueAtTime: vi.fn(),
|
||||
setValueAtTime: vi.fn(),
|
||||
},
|
||||
};
|
||||
gains.push(gain);
|
||||
return gain;
|
||||
}
|
||||
}
|
||||
|
||||
return { AudioContextMock, contexts, gains, oscillators };
|
||||
}
|
||||
|
||||
const roots: Array<{ container: HTMLDivElement; root: Root }> = [];
|
||||
|
||||
async function renderCard(): Promise<{ container: HTMLDivElement; root: Root }> {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push({ container, root });
|
||||
|
||||
await act(async () => {
|
||||
root.render(<ModelLockoutCard />);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function disposeCard(rendered: { container: HTMLDivElement; root: Root }): void {
|
||||
act(() => rendered.root.unmount());
|
||||
rendered.container.remove();
|
||||
const index = roots.findIndex(({ root }) => root === rendered.root);
|
||||
if (index >= 0) roots.splice(index, 1);
|
||||
}
|
||||
|
||||
describe("Model lockout optional notification sound", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
modelLockout: {
|
||||
enabled: false,
|
||||
errorCodes: [403, 404, 429, 502, 503, 504],
|
||||
baseCooldownMs: 120_000,
|
||||
maxCooldownMs: 1_800_000,
|
||||
maxBackoffSteps: 10,
|
||||
useExponentialBackoff: true,
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { container, root } of roots) {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
roots.length = 0;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not ship or reference the unprovenanced MP3", () => {
|
||||
const legacyAssetName = "ui-notify.mp3";
|
||||
const legacyAssetUrl = ["/audio", legacyAssetName].join("/");
|
||||
const assetPath = path.join(process.cwd(), "public/audio", legacyAssetName);
|
||||
const componentPath = path.join(
|
||||
process.cwd(),
|
||||
"src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx"
|
||||
);
|
||||
|
||||
expect(fs.existsSync(assetPath)).toBe(false);
|
||||
expect(fs.readFileSync(componentPath, "utf8")).not.toContain(legacyAssetUrl);
|
||||
});
|
||||
|
||||
it("plays generated feedback for both model-lockout toggles", async () => {
|
||||
const { AudioContextMock, contexts, gains, oscillators } = createAudioContextMock();
|
||||
vi.stubGlobal("AudioContext", AudioContextMock);
|
||||
const legacyAudio = vi.fn(() => ({
|
||||
currentTime: 0,
|
||||
pause: vi.fn(),
|
||||
play: vi.fn(),
|
||||
volume: 1,
|
||||
}));
|
||||
vi.stubGlobal("Audio", legacyAudio);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggles = [...container.querySelectorAll<HTMLButtonElement>('button[role="switch"]')];
|
||||
expect(toggles).toHaveLength(2);
|
||||
|
||||
act(() => toggles[0]?.click());
|
||||
act(() => toggles[1]?.click());
|
||||
|
||||
expect(contexts).toHaveLength(1);
|
||||
expect(oscillators).toHaveLength(2);
|
||||
expect(gains).toHaveLength(2);
|
||||
oscillators.forEach((oscillator, index) => {
|
||||
const gain = gains[index];
|
||||
expect(gain).toBeDefined();
|
||||
expect(oscillator.type).toBe("sine");
|
||||
expect(oscillator.frequency.setValueAtTime).toHaveBeenCalledWith(880, 1);
|
||||
expect(oscillator.connect).toHaveBeenCalledWith(gain);
|
||||
expect(gain?.connect).toHaveBeenCalledWith(contexts[0]?.destination);
|
||||
expect(gain?.gain.setValueAtTime).toHaveBeenCalledWith(0.0001, 1);
|
||||
expect(gain?.gain.exponentialRampToValueAtTime).toHaveBeenNthCalledWith(1, 0.045, 1.012);
|
||||
expect(gain?.gain.exponentialRampToValueAtTime).toHaveBeenNthCalledWith(2, 0.0001, 1.09);
|
||||
expect(oscillator.start).toHaveBeenCalledWith(1);
|
||||
expect(oscillator.stop).toHaveBeenCalledWith(1.1);
|
||||
|
||||
oscillator.onended?.();
|
||||
expect(oscillator.disconnect).toHaveBeenCalledOnce();
|
||||
expect(gain?.disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(legacyAudio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the prefixed Web Audio constructor when AudioContext is unavailable", async () => {
|
||||
const { AudioContextMock, contexts, oscillators } = createAudioContextMock();
|
||||
vi.stubGlobal("AudioContext", undefined);
|
||||
vi.stubGlobal("webkitAudioContext", AudioContextMock);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[role="switch"]');
|
||||
act(() => toggle?.click());
|
||||
|
||||
expect(contexts).toHaveLength(1);
|
||||
expect(oscillators).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("starts the optional chime after a suspended context resumes", async () => {
|
||||
const { AudioContextMock, contexts, oscillators } = createAudioContextMock({
|
||||
state: "suspended",
|
||||
});
|
||||
vi.stubGlobal("AudioContext", AudioContextMock);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[role="switch"]');
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(contexts[0]?.resume).toHaveBeenCalledOnce();
|
||||
expect(oscillators).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps both toggles working when Web Audio is unavailable", async () => {
|
||||
vi.stubGlobal("AudioContext", undefined);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggles = [...container.querySelectorAll<HTMLButtonElement>('button[role="switch"]')];
|
||||
act(() => toggles[0]?.click());
|
||||
act(() => toggles[1]?.click());
|
||||
|
||||
expect(toggles[0]?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(toggles[1]?.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps the toggle working when a suspended context cannot resume", async () => {
|
||||
const { AudioContextMock, contexts, oscillators } = createAudioContextMock({
|
||||
resumeRejects: true,
|
||||
state: "suspended",
|
||||
});
|
||||
vi.stubGlobal("AudioContext", AudioContextMock);
|
||||
|
||||
const { container } = await renderCard();
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button[role="switch"]');
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(contexts[0]?.resume).toHaveBeenCalledOnce();
|
||||
expect(oscillators).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("releases its audio context when the settings card unmounts", async () => {
|
||||
const { AudioContextMock, contexts } = createAudioContextMock();
|
||||
vi.stubGlobal("AudioContext", AudioContextMock);
|
||||
|
||||
const rendered = await renderCard();
|
||||
const toggle = rendered.container.querySelector<HTMLButtonElement>('button[role="switch"]');
|
||||
act(() => toggle?.click());
|
||||
expect(contexts).toHaveLength(1);
|
||||
|
||||
disposeCard(rendered);
|
||||
|
||||
expect(contexts[0]?.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user