071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
527 lines
15 KiB
TypeScript
527 lines
15 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import type { ComponentProps } from 'react';
|
|
import { afterEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
resetHostRuntimeCacheForTest,
|
|
setHostRuntimeCacheForTest,
|
|
} from '../../services/host-bridge/hostBridge';
|
|
import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge';
|
|
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
|
|
import { CreativeAudioInputPanel } from './CreativeAudioInputPanel';
|
|
|
|
type TestAudioAsset = CreativeAudioAsset;
|
|
type ExportableTestAudioAsset = TestAudioAsset & {
|
|
blob: Blob;
|
|
fileName: string;
|
|
mimeType: string;
|
|
};
|
|
|
|
const originalMediaRecorder = globalThis.MediaRecorder;
|
|
const originalMediaDevices = navigator.mediaDevices;
|
|
|
|
afterEach(() => {
|
|
globalThis.MediaRecorder = originalMediaRecorder;
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: originalMediaDevices,
|
|
});
|
|
window.history.replaceState(null, '', '/');
|
|
delete window.__TAURI__;
|
|
resetNativeAppHostBridgeForTest();
|
|
resetHostRuntimeCacheForTest();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
|
|
return {
|
|
assetId: 'asset-test',
|
|
audioSrc: 'blob:audio-preview',
|
|
audioObjectKey: '',
|
|
assetObjectId: '',
|
|
source: 'uploaded',
|
|
prompt: 'hit.wav',
|
|
durationMs: 800,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildExportableAsset(
|
|
overrides: Partial<ExportableTestAudioAsset> = {},
|
|
): ExportableTestAudioAsset {
|
|
return {
|
|
...buildAsset(),
|
|
blob: new Blob(['audio'], { type: 'audio/wav' }),
|
|
fileName: 'hit.wav',
|
|
mimeType: 'audio/wav',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function trustNativeHostRuntime(
|
|
capabilities: Parameters<
|
|
typeof setHostRuntimeCacheForTest
|
|
>[0]['capabilities'],
|
|
) {
|
|
setHostRuntimeCacheForTest({
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
hostVersion: '0.1.0',
|
|
bridgeVersion: 1,
|
|
capabilities,
|
|
});
|
|
}
|
|
|
|
function renderPanel(
|
|
overrides: Partial<
|
|
ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>
|
|
> = {},
|
|
) {
|
|
const onAssetChange = vi.fn();
|
|
const onError = vi.fn();
|
|
const readFileAsAsset = vi.fn(
|
|
async (file: File, source: 'uploaded' | 'recorded') =>
|
|
buildAsset({
|
|
audioSrc: `blob:${source}`,
|
|
source,
|
|
prompt: file.name,
|
|
}),
|
|
);
|
|
|
|
const rendered = render(
|
|
<CreativeAudioInputPanel<TestAudioAsset>
|
|
title="敲击音效"
|
|
defaultLabel="默认木鱼音"
|
|
asset={null}
|
|
buildRecordedFileName={() => 'recorded-hit.webm'}
|
|
onAssetChange={onAssetChange}
|
|
onError={onError}
|
|
readFileAsAsset={readFileAsAsset}
|
|
{...overrides}
|
|
/>,
|
|
);
|
|
|
|
return { ...rendered, onAssetChange, onError, readFileAsAsset };
|
|
}
|
|
|
|
function getUploadInput() {
|
|
const input = screen
|
|
.getByText('上传')
|
|
.closest('label')
|
|
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
|
expect(input).not.toBeNull();
|
|
return input!;
|
|
}
|
|
|
|
test('音频面板按需显示最长限制标签', () => {
|
|
renderPanel({ limitLabel: '最长 1 秒' });
|
|
|
|
const limitBadge = screen.getByText('最长 1 秒');
|
|
|
|
expect(limitBadge.className).toContain('rounded-full');
|
|
expect(limitBadge.className).toContain(
|
|
'border-[var(--platform-subpanel-border)]',
|
|
);
|
|
expect(limitBadge.className).toContain('bg-[var(--platform-subpanel-fill)]');
|
|
expect(limitBadge.className).toContain('text-[var(--platform-text-soft)]');
|
|
expect(limitBadge.className).toContain('px-2');
|
|
expect(limitBadge.className).toContain('py-1');
|
|
});
|
|
|
|
test('音频面板未传限制标签时不渲染限制提示', () => {
|
|
renderPanel();
|
|
|
|
expect(screen.queryByText('最长 1 秒')).toBeNull();
|
|
});
|
|
|
|
test('音频面板无资产时显示默认音效文案', () => {
|
|
renderPanel();
|
|
|
|
expect(screen.getByText('默认木鱼音')).toBeTruthy();
|
|
});
|
|
|
|
test('音频面板有预览地址时渲染 audio 控件', () => {
|
|
const { container } = render(
|
|
<CreativeAudioInputPanel<TestAudioAsset>
|
|
title="敲击音效"
|
|
defaultLabel="默认木鱼音"
|
|
asset={buildAsset({ audioSrc: 'blob:preview' })}
|
|
buildRecordedFileName={() => 'recorded-hit.webm'}
|
|
onAssetChange={() => {}}
|
|
onError={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('audio')?.getAttribute('src')).toBe(
|
|
'blob:preview',
|
|
);
|
|
});
|
|
|
|
test('音频面板有资产但无预览地址时显示已选择状态', () => {
|
|
renderPanel({ asset: buildAsset({ audioSrc: '' }) });
|
|
|
|
expect(screen.getByText('音效已选择')).toBeTruthy();
|
|
});
|
|
|
|
test('点击重置清空当前音频资产', () => {
|
|
const onAssetChange = vi.fn();
|
|
renderPanel({
|
|
asset: buildAsset(),
|
|
onAssetChange,
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
|
|
|
expect(onAssetChange).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
test('取消上传选择时不读取音频', () => {
|
|
const { readFileAsAsset, onAssetChange } = renderPanel();
|
|
|
|
fireEvent.change(getUploadInput(), { target: { files: [] } });
|
|
|
|
expect(readFileAsAsset).not.toHaveBeenCalled();
|
|
expect(onAssetChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('上传音频成功后清空错误并写入资产', async () => {
|
|
const audioFile = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
|
|
const { readFileAsAsset, onAssetChange, onError } = renderPanel();
|
|
|
|
fireEvent.change(getUploadInput(), { target: { files: [audioFile] } });
|
|
|
|
await waitFor(() =>
|
|
expect(readFileAsAsset).toHaveBeenCalledWith(audioFile, 'uploaded'),
|
|
);
|
|
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
|
|
expect(onError).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
test('原生 App 宿主可用时上传按钮走 HostBridge 音频导入', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'selected',
|
|
fileName: 'hit.webm',
|
|
base64Data: 'YXVkaW8=',
|
|
mimeType: 'audio/webm',
|
|
bytes: 5,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=native_app&hostCapabilities=file.importAudio',
|
|
);
|
|
trustNativeHostRuntime(['file.importAudio']);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: async <Result,>(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) => (await invoke(command, args)) as Result,
|
|
},
|
|
};
|
|
const { readFileAsAsset, onAssetChange, onError } = renderPanel();
|
|
|
|
fireEvent.click(screen.getByText('上传'));
|
|
|
|
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
|
|
const [file, source] = readFileAsAsset.mock.calls[0]!;
|
|
expect(file).toBeInstanceOf(File);
|
|
expect((file as File).name).toBe('hit.webm');
|
|
expect((file as File).type).toBe('audio/webm');
|
|
expect(source).toBe('uploaded');
|
|
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
|
|
expect(onError).toHaveBeenCalledWith(null);
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.importAudio',
|
|
timeoutMs: 30000,
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('原生 App 宿主可用且当前音频为本地资产时可以导出音频', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'saved',
|
|
fileName: 'hit.wav',
|
|
bytes: 5,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
|
|
);
|
|
trustNativeHostRuntime(['file.exportAudio']);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: async <Result,>(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) => (await invoke(command, args)) as Result,
|
|
},
|
|
};
|
|
const { onError } = renderPanel({
|
|
asset: buildExportableAsset(),
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '导出' }));
|
|
|
|
await waitFor(() =>
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportAudio',
|
|
payload: {
|
|
fileName: 'hit.wav',
|
|
base64Data: 'YXVkaW8=',
|
|
mimeType: 'audio/wav',
|
|
},
|
|
timeoutMs: 30000,
|
|
}),
|
|
}),
|
|
);
|
|
expect(onError).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
test('非本地音频资产或未声明能力时不显示导出入口', () => {
|
|
const { rerender } = renderPanel({
|
|
asset: buildExportableAsset(),
|
|
});
|
|
|
|
expect(screen.queryByRole('button', { name: '导出' })).toBeNull();
|
|
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
|
|
);
|
|
trustNativeHostRuntime(['file.exportAudio']);
|
|
rerender(
|
|
<CreativeAudioInputPanel<TestAudioAsset>
|
|
title="敲击音效"
|
|
defaultLabel="默认木鱼音"
|
|
asset={buildAsset({ audioSrc: '/generated/hit.wav' })}
|
|
buildRecordedFileName={() => 'recorded-hit.webm'}
|
|
onAssetChange={() => {}}
|
|
onError={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByRole('button', { name: '导出' })).toBeNull();
|
|
});
|
|
|
|
test('导出音频失败时提示错误', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: false,
|
|
error: {
|
|
code: 'host_error',
|
|
message: '系统保存失败。',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
|
|
);
|
|
trustNativeHostRuntime(['file.exportAudio']);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: async <Result,>(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) => (await invoke(command, args)) as Result,
|
|
},
|
|
};
|
|
const { onError } = renderPanel({
|
|
asset: buildExportableAsset(),
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '导出' }));
|
|
|
|
await waitFor(() => expect(onError).toHaveBeenCalledWith('系统保存失败。'));
|
|
});
|
|
|
|
test('上传音频失败时提示错误且不写入资产', async () => {
|
|
const readFileAsAsset = vi.fn(async () => {
|
|
throw new Error('音频最长 1 秒。');
|
|
});
|
|
const onAssetChange = vi.fn();
|
|
const onError = vi.fn();
|
|
renderPanel({ readFileAsAsset, onAssetChange, onError });
|
|
|
|
fireEvent.change(getUploadInput(), {
|
|
target: {
|
|
files: [new File(['audio'], 'hit.webm', { type: 'audio/webm' })],
|
|
},
|
|
});
|
|
|
|
await waitFor(() => expect(onError).toHaveBeenCalledWith('音频最长 1 秒。'));
|
|
expect(onAssetChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('浏览器不支持录音时提示错误', async () => {
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
globalThis.MediaRecorder = undefined as unknown as typeof MediaRecorder;
|
|
const { onError } = renderPanel();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
|
|
|
await waitFor(() =>
|
|
expect(onError).toHaveBeenCalledWith('当前浏览器不支持录音。'),
|
|
);
|
|
});
|
|
|
|
test('录音启动失败时透传启动错误', async () => {
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: {
|
|
getUserMedia: vi.fn(async () => {
|
|
throw new Error('麦克风拒绝授权。');
|
|
}),
|
|
},
|
|
});
|
|
globalThis.MediaRecorder = class {
|
|
start = vi.fn();
|
|
stop = vi.fn();
|
|
} as unknown as typeof MediaRecorder;
|
|
const { onError } = renderPanel();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
|
|
|
await waitFor(() => expect(onError).toHaveBeenCalledWith('麦克风拒绝授权。'));
|
|
});
|
|
|
|
test('录音停止后按 recorded 来源读取音频', async () => {
|
|
const stopTrack = vi.fn();
|
|
const recorderInstances: Array<{
|
|
ondataavailable: ((event: BlobEvent) => void) | null;
|
|
onstop: (() => void) | null;
|
|
}> = [];
|
|
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: {
|
|
getUserMedia: vi.fn(async () => ({
|
|
getTracks: () => [{ stop: stopTrack }],
|
|
})),
|
|
},
|
|
});
|
|
globalThis.MediaRecorder = class {
|
|
mimeType = 'audio/webm';
|
|
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
|
onstop: (() => void) | null = null;
|
|
|
|
constructor() {
|
|
recorderInstances.push(this);
|
|
}
|
|
|
|
start = vi.fn();
|
|
|
|
stop = vi.fn(() => {
|
|
this.ondataavailable?.({
|
|
data: new Blob(['recorded-audio'], { type: 'audio/webm' }),
|
|
} as BlobEvent);
|
|
this.onstop?.();
|
|
});
|
|
} as unknown as typeof MediaRecorder;
|
|
|
|
const { readFileAsAsset, onAssetChange } = renderPanel();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '停止' }));
|
|
|
|
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
|
|
const [recordedFile, source] = readFileAsAsset.mock.calls[0]!;
|
|
expect(recordedFile).toBeInstanceOf(File);
|
|
expect((recordedFile as File).name).toBe('recorded-hit.webm');
|
|
expect(source).toBe('recorded');
|
|
expect(stopTrack).toHaveBeenCalledTimes(1);
|
|
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
|
|
expect(recorderInstances).toHaveLength(1);
|
|
});
|
|
|
|
test('录音保存失败时提示错误', async () => {
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: {
|
|
getUserMedia: vi.fn(async () => ({
|
|
getTracks: () => [],
|
|
})),
|
|
},
|
|
});
|
|
globalThis.MediaRecorder = class {
|
|
mimeType = 'audio/webm';
|
|
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
|
onstop: (() => void) | null = null;
|
|
start = vi.fn();
|
|
stop = vi.fn(() => this.onstop?.());
|
|
} as unknown as typeof MediaRecorder;
|
|
const readFileAsAsset = vi.fn(async () => {
|
|
throw new Error('音频声音过小,请重新录制或上传。');
|
|
});
|
|
const onError = vi.fn();
|
|
renderPanel({ readFileAsAsset, onError });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '停止' }));
|
|
|
|
await waitFor(() =>
|
|
expect(onError).toHaveBeenCalledWith('音频声音过小,请重新录制或上传。'),
|
|
);
|
|
});
|
|
|
|
test('禁用状态不启动录音也不允许上传', () => {
|
|
const getUserMedia = vi.fn();
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: { getUserMedia },
|
|
});
|
|
const { container } = renderPanel({ disabled: true });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
|
|
|
expect(getUserMedia).not.toHaveBeenCalled();
|
|
const input = container.querySelector('input[type="file"]');
|
|
expect(input).not.toBeNull();
|
|
expect((input as HTMLInputElement).disabled).toBe(true);
|
|
});
|