gradio-app--gradio
adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
678 行
17 KiB
TypeScript
678 行
17 KiB
TypeScript
import {
|
|
test,
|
|
describe,
|
|
assert,
|
|
afterEach,
|
|
beforeEach,
|
|
vi,
|
|
expect
|
|
} from "vitest";
|
|
import {
|
|
cleanup,
|
|
render,
|
|
fireEvent,
|
|
waitFor,
|
|
upload_file,
|
|
drop_file,
|
|
mock_client,
|
|
TEST_WAV
|
|
} from "@self/tootils/render";
|
|
import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
|
|
import Audio from "./";
|
|
import WaveSurfer from "wavesurfer.js";
|
|
import type { ILoadingStatus as LoadingStatus } from "@gradio/statustracker";
|
|
import { setupi18n } from "../core/src/i18n";
|
|
|
|
// WaveSurfer.destroy() throws AbortError when in-flight fetches are cancelled
|
|
// during test cleanup. This is expected and not a test failure.
|
|
function suppress_abort(e: PromiseRejectionEvent): void {
|
|
if (e.reason?.name === "AbortError") {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
window.addEventListener("unhandledrejection", suppress_abort);
|
|
|
|
const loading_status: LoadingStatus = {
|
|
eta: 0,
|
|
queue_position: 1,
|
|
queue_size: 1,
|
|
status: "complete",
|
|
scroll_to_output: false,
|
|
visible: true,
|
|
fn_index: 0,
|
|
show_progress: "full",
|
|
type: "input" as const,
|
|
stream_state: "closed" as const
|
|
};
|
|
|
|
const fake_value = {
|
|
...TEST_WAV,
|
|
is_stream: false
|
|
};
|
|
|
|
const default_props = {
|
|
loading_status,
|
|
label: "Audio",
|
|
show_label: true,
|
|
value: null as any,
|
|
sources: ["upload", "microphone"] as ("upload" | "microphone")[],
|
|
interactive: true,
|
|
streaming: false,
|
|
pending: false,
|
|
waveform_options: {
|
|
trim_region_color: "#f97316",
|
|
show_recording_waveform: true,
|
|
show_controls: true
|
|
},
|
|
editable: true,
|
|
buttons: [] as (string | { value: string; id: number; icon: null })[]
|
|
};
|
|
|
|
run_shared_prop_tests({
|
|
component: Audio,
|
|
name: "Audio",
|
|
base_props: {
|
|
...default_props
|
|
},
|
|
has_label: false,
|
|
has_validation_error: false
|
|
});
|
|
|
|
describe("Audio", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("renders audio component with upload area when value is null", async () => {
|
|
const { getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
sources: ["upload"],
|
|
value: null
|
|
});
|
|
|
|
expect(getByLabelText("audio.drop_to_upload")).toBeVisible();
|
|
});
|
|
|
|
test("renders audio component with waveform when value is set", async () => {
|
|
const { getAllByTestId } = await render(Audio, {
|
|
...default_props,
|
|
label: "music",
|
|
value: fake_value,
|
|
sources: ["microphone", "upload"]
|
|
});
|
|
|
|
assert.exists(getAllByTestId("waveform-music"));
|
|
});
|
|
});
|
|
|
|
describe("Props: sources", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("multiple sources renders source selection buttons", async () => {
|
|
const { getByTestId } = await render(Audio, {
|
|
...default_props,
|
|
sources: ["microphone", "upload"]
|
|
});
|
|
|
|
expect(getByTestId("source-select")).toBeTruthy();
|
|
});
|
|
|
|
test("single upload source does not render source selection", async () => {
|
|
const { queryByTestId } = await render(Audio, {
|
|
...default_props,
|
|
sources: ["upload"]
|
|
});
|
|
|
|
expect(queryByTestId("source-select")).toBeNull();
|
|
});
|
|
|
|
test("source selection shows correct selected source", async () => {
|
|
const { getByTestId, getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
sources: ["microphone", "upload"]
|
|
});
|
|
|
|
expect(getByTestId("source-select").children).toHaveLength(2);
|
|
expect(getByLabelText("Record audio").classList.contains("selected")).toBe(
|
|
true
|
|
);
|
|
expect(getByLabelText("Upload file").classList.contains("selected")).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
test("clicking upload source button shows upload area", async () => {
|
|
const { getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
sources: ["microphone", "upload"],
|
|
value: null
|
|
});
|
|
|
|
await fireEvent.click(getByLabelText("Upload file"));
|
|
expect(getByLabelText("audio.drop_to_upload")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
describe("Props: interactive", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("interactive=true with value shows editing controls", async () => {
|
|
const { getAllByTestId, getAllByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"]
|
|
});
|
|
|
|
assert.exists(getAllByTestId("waveform-controls"));
|
|
assert.exists(getAllByLabelText("Trim audio to selection"));
|
|
assert.exists(getAllByLabelText("Reset audio"));
|
|
});
|
|
|
|
test("interactive=false does not render editing controls", async () => {
|
|
const { getAllByTestId, queryByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: false,
|
|
value: fake_value,
|
|
sources: ["microphone"]
|
|
});
|
|
|
|
assert.exists(getAllByTestId("waveform-controls"));
|
|
expect(queryByLabelText("Trim audio to selection")).toBeNull();
|
|
expect(queryByLabelText("Reset audio")).toBeNull();
|
|
});
|
|
|
|
test("interactive=false with null value does not show upload area", async () => {
|
|
const { queryByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: false,
|
|
value: null
|
|
});
|
|
|
|
expect(queryByLabelText("audio.drop_to_upload")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Events: change", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("setting value triggers change event", async () => {
|
|
const { listen, set_data } = await render(Audio, {
|
|
...default_props,
|
|
value: null
|
|
});
|
|
|
|
const change = listen("change");
|
|
|
|
await set_data({ value: fake_value });
|
|
|
|
expect(change).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("change event is not triggered on mount with a default value", async () => {
|
|
const { listen } = await render(Audio, {
|
|
...default_props,
|
|
value: fake_value
|
|
});
|
|
|
|
const change = listen("change", { retrospective: true });
|
|
|
|
expect(change).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("changing value multiple times triggers change each time", async () => {
|
|
const { listen, set_data } = await render(Audio, {
|
|
...default_props,
|
|
value: null
|
|
});
|
|
|
|
const change = listen("change");
|
|
|
|
const value_a = { ...fake_value, url: "https://example.com/a.wav" };
|
|
const value_b = { ...fake_value, url: "https://example.com/b.wav" };
|
|
|
|
await set_data({ value: value_a });
|
|
await set_data({ value: value_b });
|
|
|
|
expect(change).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("setting value to null after a value triggers change", async () => {
|
|
const { listen, set_data } = await render(Audio, {
|
|
...default_props,
|
|
value: fake_value
|
|
});
|
|
|
|
const change = listen("change");
|
|
|
|
await set_data({ value: null });
|
|
|
|
expect(change).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("Props: buttons (static mode)", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("buttons with download shows download link", async () => {
|
|
const { container } = await render(Audio, {
|
|
...default_props,
|
|
interactive: false,
|
|
value: fake_value,
|
|
buttons: ["download"]
|
|
});
|
|
|
|
const downloadLink = container.querySelector("a[download]");
|
|
expect(downloadLink).toBeTruthy();
|
|
});
|
|
|
|
test("custom button renders and dispatches custom_button_click", async () => {
|
|
const { listen, getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: false,
|
|
value: fake_value,
|
|
buttons: [{ value: "Transcribe", id: 3, icon: null }]
|
|
});
|
|
|
|
const custom = listen("custom_button_click");
|
|
const btn = getByLabelText("Transcribe");
|
|
|
|
await fireEvent.click(btn);
|
|
|
|
expect(custom).toHaveBeenCalledTimes(1);
|
|
expect(custom).toHaveBeenCalledWith({ id: 3 });
|
|
});
|
|
});
|
|
|
|
describe("Props: buttons (interactive mode)", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("clear button appears when audio has a value", async () => {
|
|
const { getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value
|
|
});
|
|
|
|
const clearBtn = getByLabelText("common.clear");
|
|
expect(clearBtn).toBeTruthy();
|
|
});
|
|
|
|
test("clicking clear button removes the audio and dispatches clear and input", async () => {
|
|
const { getByLabelText, listen } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value
|
|
});
|
|
|
|
const clear = listen("clear");
|
|
const input = listen("input");
|
|
const clearBtn = getByLabelText("common.clear");
|
|
|
|
await fireEvent.click(clearBtn);
|
|
|
|
expect(clear).toHaveBeenCalledTimes(1);
|
|
expect(input).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("get_data", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("get_data returns the current value", async () => {
|
|
const { get_data, set_data } = await render(Audio, {
|
|
...default_props,
|
|
value: null
|
|
});
|
|
|
|
const initial = await get_data();
|
|
expect(initial.value).toBeNull();
|
|
|
|
await set_data({ value: fake_value });
|
|
|
|
const updated = await get_data();
|
|
expect(updated.value).toEqual(fake_value);
|
|
});
|
|
});
|
|
|
|
const upload_props = {
|
|
...default_props,
|
|
sources: ["upload"] as "upload"[],
|
|
interactive: true,
|
|
value: null,
|
|
root: "https://example.com",
|
|
client: mock_client()
|
|
};
|
|
|
|
describe("Events: upload via file input", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("selecting a file triggers upload, change, and input events", async () => {
|
|
const { listen } = await render(Audio, upload_props);
|
|
|
|
const upload = listen("upload");
|
|
const change = listen("change");
|
|
const input = listen("input");
|
|
|
|
await upload_file(TEST_WAV);
|
|
|
|
await waitFor(() => {
|
|
expect(upload).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(input).toHaveBeenCalledTimes(1);
|
|
expect(change).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("drag and drop a file triggers upload, change, and input events", async () => {
|
|
const { listen } = await render(Audio, upload_props);
|
|
|
|
const upload = listen("upload");
|
|
const change = listen("change");
|
|
const input = listen("input");
|
|
|
|
await drop_file(TEST_WAV, "[aria-label='audio.drop_to_upload']");
|
|
|
|
await waitFor(() => {
|
|
expect(upload).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(input).toHaveBeenCalledTimes(1);
|
|
expect(change).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("upload failure dispatches error event with the message", async () => {
|
|
const failing_upload = vi
|
|
.fn()
|
|
.mockRejectedValue(new Error("File too large"));
|
|
const { listen } = await render(Audio, {
|
|
...upload_props,
|
|
client: {
|
|
upload: failing_upload,
|
|
stream: async () => ({ onmessage: null, close: () => {} })
|
|
}
|
|
});
|
|
|
|
const error = listen("error");
|
|
|
|
await upload_file(TEST_WAV);
|
|
|
|
await waitFor(() => {
|
|
expect(failing_upload).toHaveBeenCalled();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(error).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(error).toHaveBeenCalledWith("File too large");
|
|
});
|
|
});
|
|
|
|
describe("Waveform controls", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("play button is rendered when audio has a value", async () => {
|
|
const { getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"]
|
|
});
|
|
|
|
expect(getByLabelText("audio.play")).toBeTruthy();
|
|
});
|
|
|
|
test("skip forward and backward buttons are rendered", async () => {
|
|
const { getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"]
|
|
});
|
|
|
|
expect(getByLabelText("Skip forward by 5 seconds")).toBeTruthy();
|
|
expect(getByLabelText("Skip backwards by 5 seconds")).toBeTruthy();
|
|
});
|
|
|
|
test("trim button is rendered in interactive mode", async () => {
|
|
const { getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"]
|
|
});
|
|
|
|
expect(getByLabelText("Trim audio to selection")).toBeTruthy();
|
|
});
|
|
|
|
test("clicking trim button enters edit mode with Trim and Cancel buttons", async () => {
|
|
const { getByLabelText, getByText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"]
|
|
});
|
|
|
|
await fireEvent.click(getByLabelText("Trim audio to selection"));
|
|
|
|
expect(getByText("Trim")).toBeTruthy();
|
|
expect(getByText("Cancel")).toBeTruthy();
|
|
});
|
|
|
|
test("clicking Cancel exits edit mode and restores trim button", async () => {
|
|
const { getByLabelText, getByText, queryByText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"]
|
|
});
|
|
|
|
await fireEvent.click(getByLabelText("Trim audio to selection"));
|
|
expect(getByText("Cancel")).toBeTruthy();
|
|
|
|
await fireEvent.click(getByText("Cancel"));
|
|
|
|
expect(queryByText("Cancel")).toBeNull();
|
|
expect(getByLabelText("Trim audio to selection")).toBeTruthy();
|
|
});
|
|
|
|
test("playback speed button cycles through speeds", async () => {
|
|
const { getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"]
|
|
});
|
|
|
|
// Default speed is 1x, button label shows next speed (1.5x)
|
|
const speedBtn = getByLabelText("Adjust playback speed to 1.5x");
|
|
expect(speedBtn).toBeTruthy();
|
|
|
|
await fireEvent.click(speedBtn);
|
|
|
|
// After clicking, speed is 1.5x, next is 2x
|
|
expect(getByLabelText("Adjust playback speed to 2x")).toBeTruthy();
|
|
});
|
|
|
|
test("volume button is rendered", async () => {
|
|
const { getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"]
|
|
});
|
|
|
|
expect(getByLabelText("Adjust volume")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("Waveform options", () => {
|
|
setupi18n();
|
|
let createSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
createSpy = vi.spyOn(WaveSurfer, "create");
|
|
});
|
|
afterEach(() => {
|
|
createSpy.mockRestore();
|
|
cleanup();
|
|
});
|
|
|
|
function get_last_create_args(): Record<string, any> {
|
|
return createSpy.mock.calls[createSpy.mock.calls.length - 1][0];
|
|
}
|
|
|
|
test("custom waveform_color is passed to WaveSurfer.create as waveColor", async () => {
|
|
await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"],
|
|
waveform_options: {
|
|
...default_props.waveform_options,
|
|
waveform_color: "#ff0000"
|
|
}
|
|
});
|
|
|
|
expect(get_last_create_args().waveColor).toBe("#ff0000");
|
|
});
|
|
|
|
test("custom waveform_progress_color is passed as progressColor", async () => {
|
|
await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"],
|
|
waveform_options: {
|
|
...default_props.waveform_options,
|
|
waveform_progress_color: "#00ff00"
|
|
}
|
|
});
|
|
|
|
expect(get_last_create_args().progressColor).toBe("#00ff00");
|
|
});
|
|
|
|
test("default waveform colors are applied when not specified", async () => {
|
|
await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"],
|
|
waveform_options: {
|
|
trim_region_color: "#f97316",
|
|
show_recording_waveform: true,
|
|
show_controls: true
|
|
}
|
|
});
|
|
|
|
const args = get_last_create_args();
|
|
expect(args.waveColor).toBe("#9ca3af");
|
|
expect(args.progressColor).toBe("darkorange");
|
|
});
|
|
|
|
test("show_controls maps to mediaControls in WaveSurfer settings", async () => {
|
|
await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"],
|
|
waveform_options: {
|
|
...default_props.waveform_options,
|
|
show_controls: true
|
|
}
|
|
});
|
|
|
|
expect(get_last_create_args().mediaControls).toBe(true);
|
|
});
|
|
|
|
test("show_controls defaults to false when not specified", async () => {
|
|
await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"],
|
|
waveform_options: {
|
|
trim_region_color: "#f97316",
|
|
show_recording_waveform: true
|
|
}
|
|
});
|
|
|
|
expect(get_last_create_args().mediaControls).toBe(false);
|
|
});
|
|
|
|
test("custom sample_rate is passed as sampleRate", async () => {
|
|
await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"],
|
|
waveform_options: {
|
|
...default_props.waveform_options,
|
|
sample_rate: 22050
|
|
}
|
|
});
|
|
|
|
expect(get_last_create_args().sampleRate).toBe(22050);
|
|
});
|
|
|
|
test("default sample_rate is 44100", async () => {
|
|
await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"],
|
|
waveform_options: {
|
|
trim_region_color: "#f97316",
|
|
show_recording_waveform: true,
|
|
show_controls: true
|
|
}
|
|
});
|
|
|
|
expect(get_last_create_args().sampleRate).toBe(44100);
|
|
});
|
|
|
|
test("skip_length affects skip button labels", async () => {
|
|
const { getByLabelText } = await render(Audio, {
|
|
...default_props,
|
|
interactive: true,
|
|
value: fake_value,
|
|
sources: ["microphone"],
|
|
waveform_options: {
|
|
...default_props.waveform_options,
|
|
skip_length: 10
|
|
}
|
|
});
|
|
|
|
// With audio_duration=0 and skip_length=10, get_skip_rewind_amount returns
|
|
// (0/100)*10 || 5 = 5. The label still shows 5 because duration is 0.
|
|
// But the skip_length option is wired through the controls.
|
|
expect(getByLabelText("Skip forward by 5 seconds")).toBeTruthy();
|
|
expect(getByLabelText("Skip backwards by 5 seconds")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("Subtitles", () => {
|
|
setupi18n();
|
|
afterEach(() => cleanup());
|
|
|
|
test("renders audio component with subtitles", async () => {
|
|
const { getByTestId } = await render(Audio, {
|
|
...default_props,
|
|
subtitles: {
|
|
url: "https://example.com/s2.vtt",
|
|
path: "https://example.com/s2.vtt",
|
|
orig_name: "s2.vtt"
|
|
},
|
|
interactive: false,
|
|
value: fake_value
|
|
});
|
|
|
|
expect(getByTestId("subtitle-display").textContent).toBe("");
|
|
});
|
|
});
|