feat(studio): promote voice modes to workspace tabs
This commit is contained in:
@@ -57,6 +57,8 @@
|
||||
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
|
||||
| **License** | AGPL-3.0 application; downloaded models keep their upstream terms |
|
||||
|
||||
The Voice workspace starts with the active engine selector and three tabs: **From audio** for cloning, **By design** for creating a voice, and **Convert** for speech-to-speech conversion. Each tab displays its own workflow below.
|
||||
|
||||
<a id="install"></a>
|
||||
|
||||
## Install
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function EngineQuickSwitch({
|
||||
// workspace-header chips must open downward or the popover clips off the
|
||||
// top of the viewport.
|
||||
dropUp = false,
|
||||
prominent = false,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rootRef = useRef(null);
|
||||
@@ -115,10 +116,12 @@ export default function EngineQuickSwitch({
|
||||
family: family.toUpperCase(),
|
||||
engine: active.display_name,
|
||||
})}
|
||||
className="inline-flex h-[20px] items-center gap-[5px] rounded-sm border-0 bg-transparent px-[7px] text-[11px] font-medium text-[color:var(--chrome-fg-muted)] transition-[background,color] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)]"
|
||||
className={`inline-flex items-center gap-[5px] rounded-sm border-0 bg-transparent px-[7px] font-medium text-[color:var(--chrome-fg-muted)] transition-[background,color] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] ${prominent ? 'min-h-11 text-sm text-left' : 'h-[20px] text-[11px]'}`}
|
||||
>
|
||||
<Cpu size={13} aria-hidden="true" />
|
||||
<span className="max-w-[124px] truncate">{active.display_name}</span>
|
||||
<span className={prominent ? 'min-w-0 break-words' : 'max-w-[124px] truncate'}>
|
||||
{active.display_name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { UploadCloud, X, ArrowRightLeft, Loader } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Button } from '../../ui';
|
||||
@@ -26,7 +26,7 @@ import WaveformPlayer from '../WaveformPlayer';
|
||||
* ScriptPanel (the script IS the source clip) or the shared ActionBar, so it
|
||||
* owns its source file, target voice, and result state locally.
|
||||
*/
|
||||
export default function ConvertMethodPanel({ t, profiles = [] }) {
|
||||
export default function ConvertMethodPanel({ t, profiles = [], onRecordingBusyChange }) {
|
||||
const [sourceFile, setSourceFile] = useState(null);
|
||||
const [voiceId, setVoiceId] = useState('');
|
||||
const [matchDuration, setMatchDuration] = useState(true);
|
||||
@@ -79,6 +79,11 @@ export default function ConvertMethodPanel({ t, profiles = [] }) {
|
||||
stopRecording,
|
||||
} = useRecording(async (file) => ingestSource(file));
|
||||
|
||||
useEffect(() => {
|
||||
onRecordingBusyChange?.(Boolean(isStartingRecording || isRecording));
|
||||
return () => onRecordingBusyChange?.(false);
|
||||
}, [isStartingRecording, isRecording, onRecordingBusyChange]);
|
||||
|
||||
const canConvert = !!sourceFile && !!voiceId && !isConverting;
|
||||
|
||||
const handleConvert = async () => {
|
||||
|
||||
@@ -103,10 +103,20 @@ beforeEach(() => {
|
||||
describe('ConvertMethodPanel', () => {
|
||||
it('shows microphone startup instead of a duplicate record action', () => {
|
||||
recordingState.isStartingRecording = true;
|
||||
render(<ConvertMethodPanel t={t} profiles={profiles} />);
|
||||
const onRecordingBusyChange = vi.fn();
|
||||
const { unmount } = render(
|
||||
<ConvertMethodPanel
|
||||
t={t}
|
||||
profiles={profiles}
|
||||
onRecordingBusyChange={onRecordingBusyChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Starting…');
|
||||
expect(screen.queryByRole('button', { name: 'Record' })).not.toBeInTheDocument();
|
||||
expect(onRecordingBusyChange).toHaveBeenLastCalledWith(true);
|
||||
unmount();
|
||||
expect(onRecordingBusyChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('keeps Convert disabled until a source clip AND a target voice are set', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { CATEGORIES } from '../utils/constants';
|
||||
import { Segmented } from '../ui';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs';
|
||||
import { useAppStore } from '../store';
|
||||
import { API, apiPost, apiFetch } from '../api/client';
|
||||
import { mergeDescribedAttrs, applyVdState } from '../utils/voiceInstruct';
|
||||
@@ -18,6 +18,7 @@ import ActionBar from '../components/clone/ActionBar';
|
||||
import EngineQuickSwitch from '../components/EngineQuickSwitch';
|
||||
|
||||
export default function CloneDesignTab(props) {
|
||||
const [convertRecordingBusy, setConvertRecordingBusy] = useState(false);
|
||||
const {
|
||||
textAreaRef,
|
||||
text,
|
||||
@@ -413,182 +414,192 @@ export default function CloneDesignTab(props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
{/* #1771 follow-up (item 6): NOT flex-1 — the old always-open 12-row
|
||||
<Tabs
|
||||
value={defineMethod}
|
||||
onValueChange={setDefineMethod}
|
||||
activationMode="manual"
|
||||
className="flex-1 min-h-0 min-w-0 gap-0"
|
||||
>
|
||||
<div className="relative z-10 flex shrink-0 flex-col gap-3 px-3 pb-4 pt-2 max-[600px]:px-1.5">
|
||||
<EngineQuickSwitch prominent />
|
||||
<TabsList
|
||||
aria-label={t('clone.voice_kicker')}
|
||||
className="grid h-auto w-full grid-cols-3 gap-1 rounded-lg bg-[var(--chrome-hover-bg)] p-1"
|
||||
>
|
||||
{[
|
||||
{ id: 'audio', label: t('clone.define_from_audio') },
|
||||
{ id: 'design', label: t('clone.define_by_design') },
|
||||
{ id: 'convert', label: t('clone.define_convert') },
|
||||
].map((method) => (
|
||||
<TabsTrigger
|
||||
key={method.id}
|
||||
value={method.id}
|
||||
disabled={isStartingRecording || isRecording || convertRecordingBusy}
|
||||
className="min-h-11 h-auto min-w-0 whitespace-normal rounded-md border-0 bg-transparent px-3 py-2 text-sm font-semibold text-[var(--chrome-fg-muted)] data-[state=active]:bg-[var(--chrome-accent-bg)] data-[state=active]:text-[var(--chrome-accent)] data-[state=active]:shadow-none"
|
||||
>
|
||||
{method.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value={defineMethod} className="flex min-h-0 flex-col">
|
||||
{/* #1771 follow-up (item 6): NOT flex-1 — the old always-open 12-row
|
||||
Design block was tall enough that this area routinely filled the
|
||||
full column height on its own. Now that Details defaults collapsed,
|
||||
forcing this to grow-fill would strand a dead gap between it and
|
||||
ActionBar below. Sizing to content (min-h-0 + overflow-y-auto still
|
||||
in play) lets it shrink for short content and still scroll when
|
||||
content genuinely exceeds the available height. */}
|
||||
<div className="flex flex-col gap-[6px] min-h-0 overflow-y-auto">
|
||||
{/* ═══ SCRIPT — what should it say ═══
|
||||
<div className="flex flex-col gap-[6px] min-h-0 overflow-y-auto">
|
||||
{/* ═══ SCRIPT — what should it say ═══
|
||||
Hidden for Convert: the source clip IS the script (the backend
|
||||
transcribes it), so a text panel would only mislead. */}
|
||||
{defineMethod !== 'convert' && (
|
||||
<ScriptPanel
|
||||
t={t}
|
||||
defineMethod={defineMethod}
|
||||
text={text}
|
||||
setText={setText}
|
||||
activePersonality={activePersonality}
|
||||
demoPresets={demoPresets}
|
||||
applyDemoPreset={applyDemoPreset}
|
||||
showDemoCoachmark={showDemoCoachmark}
|
||||
setShowDemoCoachmark={setShowDemoCoachmark}
|
||||
selectedProfile={selectedProfile}
|
||||
DEMO_PROFILE_ID={DEMO_PROFILE_ID}
|
||||
textAreaRef={textAreaRef}
|
||||
insertOpen={insertOpen}
|
||||
setInsertOpen={setInsertOpen}
|
||||
insertTag={insertTag}
|
||||
/>
|
||||
)}
|
||||
{defineMethod !== 'convert' && (
|
||||
<ScriptPanel
|
||||
t={t}
|
||||
defineMethod={defineMethod}
|
||||
text={text}
|
||||
setText={setText}
|
||||
activePersonality={activePersonality}
|
||||
demoPresets={demoPresets}
|
||||
applyDemoPreset={applyDemoPreset}
|
||||
showDemoCoachmark={showDemoCoachmark}
|
||||
setShowDemoCoachmark={setShowDemoCoachmark}
|
||||
selectedProfile={selectedProfile}
|
||||
DEMO_PROFILE_ID={DEMO_PROFILE_ID}
|
||||
textAreaRef={textAreaRef}
|
||||
insertOpen={insertOpen}
|
||||
setInsertOpen={setInsertOpen}
|
||||
insertTag={insertTag}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ VOICE — who says it ═══ */}
|
||||
<div className="flex flex-col gap-[6px] flex-none min-h-0 relative z-[1]">
|
||||
<div className="flex flex-col min-h-0 overflow-auto bg-[var(--chrome-bg)] border border-transparent rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]">
|
||||
<div className="label-row justify-between">
|
||||
<span className="label-row mb-0">
|
||||
<Volume2 className="label-icon" size={14} />{' '}
|
||||
{t('clone.voice_kicker', { defaultValue: 'Voice' })}
|
||||
</span>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<EngineQuickSwitch />
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={defineMethod}
|
||||
onChange={setDefineMethod}
|
||||
disabled={isStartingRecording || isRecording}
|
||||
items={[
|
||||
{
|
||||
value: 'audio',
|
||||
label: t('clone.define_from_audio', { defaultValue: 'From audio' }),
|
||||
},
|
||||
{
|
||||
value: 'design',
|
||||
label: t('clone.define_by_design', { defaultValue: 'By design' }),
|
||||
},
|
||||
{
|
||||
value: 'convert',
|
||||
label: t('clone.define_convert', { defaultValue: 'Convert' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* ═══ VOICE — who says it ═══ */}
|
||||
<div className="flex flex-col gap-[6px] flex-none min-h-0 relative z-[1]">
|
||||
<div className="flex flex-col min-h-0 overflow-auto bg-[var(--chrome-bg)] border border-transparent rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]">
|
||||
<div className="label-row justify-between">
|
||||
<span className="label-row mb-0">
|
||||
<Volume2 className="label-icon" size={14} />{' '}
|
||||
{t('clone.voice_kicker', { defaultValue: 'Voice' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{defineMethod === 'audio' ? (
|
||||
<AudioMethodPanel
|
||||
t={t}
|
||||
selectedProfile={selectedProfile}
|
||||
setSelectedProfile={setSelectedProfile}
|
||||
profiles={profiles}
|
||||
ingestRefAudio={ingestRefAudio}
|
||||
refAudio={refAudio}
|
||||
isCleaning={isCleaning}
|
||||
isRecording={isRecording}
|
||||
isStartingRecording={isStartingRecording}
|
||||
recordingTime={recordingTime}
|
||||
audioInputs={audioInputs}
|
||||
selectedAudioInputId={selectedAudioInputId}
|
||||
setSelectedAudioInputId={setSelectedAudioInputId}
|
||||
channelMode={channelMode}
|
||||
setChannelMode={setChannelMode}
|
||||
inputLevelStore={inputLevelStore}
|
||||
startRecording={startRecording}
|
||||
stopRecording={stopRecording}
|
||||
refText={refText}
|
||||
setRefText={setRefText}
|
||||
instruct={instruct}
|
||||
setInstruct={setInstruct}
|
||||
defineMethod={defineMethod}
|
||||
designSeed={designSeed}
|
||||
setDesignSeed={setDesignSeed}
|
||||
keepSeed={keepSeed}
|
||||
setKeepSeed={setKeepSeed}
|
||||
showSaveProfile={showSaveProfile}
|
||||
setShowSaveProfile={setShowSaveProfile}
|
||||
profileName={profileName}
|
||||
setProfileName={setProfileName}
|
||||
handleSaveProfile={handleSaveProfile}
|
||||
/>
|
||||
) : defineMethod === 'convert' ? (
|
||||
<ConvertMethodPanel t={t} profiles={profiles} />
|
||||
) : (
|
||||
<DesignMethodPanel
|
||||
t={t}
|
||||
describeText={describeText}
|
||||
onDescribeChange={onDescribeChange}
|
||||
describeMatchedAny={describeMatchedAny}
|
||||
describeUnmatched={describeUnmatched}
|
||||
chipPersonalities={chipPersonalities}
|
||||
activePersonality={activePersonality}
|
||||
applyPersonality={applyPersonality}
|
||||
applyPreset={applyPresetAndInvalidate}
|
||||
identityOpen={identityOpen}
|
||||
setIdentityOpen={setIdentityOpen}
|
||||
identityRecipe={identityRecipe}
|
||||
vdStates={vdStates}
|
||||
onVdChange={handleVdChange}
|
||||
onChipKeyDown={onChipKeyDown}
|
||||
resetToDescription={resetToDescription}
|
||||
showSaveProfile={showSaveProfile}
|
||||
setShowSaveProfile={setShowSaveProfile}
|
||||
profileName={profileName}
|
||||
setProfileName={setProfileName}
|
||||
handleSaveDesignProfile={handleSaveDesignProfile}
|
||||
instruct={instruct}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
{defineMethod === 'audio' ? (
|
||||
<AudioMethodPanel
|
||||
t={t}
|
||||
selectedProfile={selectedProfile}
|
||||
setSelectedProfile={setSelectedProfile}
|
||||
profiles={profiles}
|
||||
ingestRefAudio={ingestRefAudio}
|
||||
refAudio={refAudio}
|
||||
isCleaning={isCleaning}
|
||||
isRecording={isRecording}
|
||||
isStartingRecording={isStartingRecording}
|
||||
recordingTime={recordingTime}
|
||||
audioInputs={audioInputs}
|
||||
selectedAudioInputId={selectedAudioInputId}
|
||||
setSelectedAudioInputId={setSelectedAudioInputId}
|
||||
channelMode={channelMode}
|
||||
setChannelMode={setChannelMode}
|
||||
inputLevelStore={inputLevelStore}
|
||||
startRecording={startRecording}
|
||||
stopRecording={stopRecording}
|
||||
refText={refText}
|
||||
setRefText={setRefText}
|
||||
instruct={instruct}
|
||||
setInstruct={setInstruct}
|
||||
defineMethod={defineMethod}
|
||||
designSeed={designSeed}
|
||||
setDesignSeed={setDesignSeed}
|
||||
keepSeed={keepSeed}
|
||||
setKeepSeed={setKeepSeed}
|
||||
showSaveProfile={showSaveProfile}
|
||||
setShowSaveProfile={setShowSaveProfile}
|
||||
profileName={profileName}
|
||||
setProfileName={setProfileName}
|
||||
handleSaveProfile={handleSaveProfile}
|
||||
/>
|
||||
) : defineMethod === 'convert' ? (
|
||||
<ConvertMethodPanel
|
||||
t={t}
|
||||
profiles={profiles}
|
||||
onRecordingBusyChange={setConvertRecordingBusy}
|
||||
/>
|
||||
) : (
|
||||
<DesignMethodPanel
|
||||
t={t}
|
||||
describeText={describeText}
|
||||
onDescribeChange={onDescribeChange}
|
||||
describeMatchedAny={describeMatchedAny}
|
||||
describeUnmatched={describeUnmatched}
|
||||
chipPersonalities={chipPersonalities}
|
||||
activePersonality={activePersonality}
|
||||
applyPersonality={applyPersonality}
|
||||
applyPreset={applyPresetAndInvalidate}
|
||||
identityOpen={identityOpen}
|
||||
setIdentityOpen={setIdentityOpen}
|
||||
identityRecipe={identityRecipe}
|
||||
vdStates={vdStates}
|
||||
onVdChange={handleVdChange}
|
||||
onChipKeyDown={onChipKeyDown}
|
||||
resetToDescription={resetToDescription}
|
||||
showSaveProfile={showSaveProfile}
|
||||
setShowSaveProfile={setShowSaveProfile}
|
||||
profileName={profileName}
|
||||
setProfileName={setProfileName}
|
||||
handleSaveDesignProfile={handleSaveDesignProfile}
|
||||
instruct={instruct}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ ACTION BAR — pinned to the column bottom ═══
|
||||
{/* ═══ ACTION BAR — pinned to the column bottom ═══
|
||||
Hidden for Convert: it drives text synthesis (script + overrides),
|
||||
and Convert owns its action button inside its panel. */}
|
||||
{defineMethod !== 'convert' && (
|
||||
<ActionBar
|
||||
t={t}
|
||||
showOverrides={showOverrides}
|
||||
setShowOverrides={setShowOverrides}
|
||||
cfg={cfg}
|
||||
setCfg={setCfg}
|
||||
speed={speed}
|
||||
setSpeed={setSpeed}
|
||||
tShift={tShift}
|
||||
setTShift={setTShift}
|
||||
posTemp={posTemp}
|
||||
setPosTemp={setPosTemp}
|
||||
classTemp={classTemp}
|
||||
setClassTemp={setClassTemp}
|
||||
layerPenalty={layerPenalty}
|
||||
setLayerPenalty={setLayerPenalty}
|
||||
duration={duration}
|
||||
setDuration={setDuration}
|
||||
denoise={denoise}
|
||||
setDenoise={setDenoise}
|
||||
postprocess={postprocess}
|
||||
setPostprocess={setPostprocess}
|
||||
language={language}
|
||||
setLanguage={setLanguage}
|
||||
steps={steps}
|
||||
setSteps={setSteps}
|
||||
showHearDemo={showHearDemo}
|
||||
playDemoOutput={playDemoOutput}
|
||||
demoAudioPlaying={demoAudioPlaying}
|
||||
demoAudioRef={demoAudioRef}
|
||||
demoReleaseRef={demoReleaseRef}
|
||||
setDemoAudioPlaying={setDemoAudioPlaying}
|
||||
outputPlaying={outputPlaying}
|
||||
isGenerating={isGenerating}
|
||||
handleGenerate={handleGenerate}
|
||||
generationTime={generationTime}
|
||||
wasGeneratingRef={wasGeneratingRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{defineMethod !== 'convert' && (
|
||||
<ActionBar
|
||||
t={t}
|
||||
showOverrides={showOverrides}
|
||||
setShowOverrides={setShowOverrides}
|
||||
cfg={cfg}
|
||||
setCfg={setCfg}
|
||||
speed={speed}
|
||||
setSpeed={setSpeed}
|
||||
tShift={tShift}
|
||||
setTShift={setTShift}
|
||||
posTemp={posTemp}
|
||||
setPosTemp={setPosTemp}
|
||||
classTemp={classTemp}
|
||||
setClassTemp={setClassTemp}
|
||||
layerPenalty={layerPenalty}
|
||||
setLayerPenalty={setLayerPenalty}
|
||||
duration={duration}
|
||||
setDuration={setDuration}
|
||||
denoise={denoise}
|
||||
setDenoise={setDenoise}
|
||||
postprocess={postprocess}
|
||||
setPostprocess={setPostprocess}
|
||||
language={language}
|
||||
setLanguage={setLanguage}
|
||||
steps={steps}
|
||||
setSteps={setSteps}
|
||||
showHearDemo={showHearDemo}
|
||||
playDemoOutput={playDemoOutput}
|
||||
demoAudioPlaying={demoAudioPlaying}
|
||||
demoAudioRef={demoAudioRef}
|
||||
demoReleaseRef={demoReleaseRef}
|
||||
setDemoAudioPlaying={setDemoAudioPlaying}
|
||||
outputPlaying={outputPlaying}
|
||||
isGenerating={isGenerating}
|
||||
handleGenerate={handleGenerate}
|
||||
generationTime={generationTime}
|
||||
wasGeneratingRef={wasGeneratingRef}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CATEGORIES } from '../utils/constants';
|
||||
import { apiPost } from '../api/client';
|
||||
|
||||
vi.mock('../api/hooks', () => ({
|
||||
useArchetypes: () => ({ data: { items: [] } }),
|
||||
useEngines: () => ({ data: { tts: { backends: [] }, asr: { backends: [] } } }),
|
||||
useSelectEngine: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
@@ -125,8 +126,35 @@ describe('CloneDesignTab — Voice Design panel redesign regressions', () => {
|
||||
['microphone startup is pending', { isStartingRecording: true }],
|
||||
])('keeps the active voice method mounted while %s', (_label, state) => {
|
||||
renderDesignTab(state);
|
||||
expect(screen.getByRole('radio', { name: 'From audio' })).toBeDisabled();
|
||||
expect(screen.getByRole('radio', { name: 'By design' })).toBeDisabled();
|
||||
expect(screen.getByRole('tab', { name: 'From audio' })).toBeDisabled();
|
||||
expect(screen.getByRole('tab', { name: 'By design' })).toBeDisabled();
|
||||
expect(screen.getByRole('tab', { name: 'Convert' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('places mode tabs above their associated workspace and switches the whole content', () => {
|
||||
renderDesignTab({ text: 'Keep my script' });
|
||||
const design = screen.getByRole('tab', { name: 'By design' });
|
||||
const panel = screen.getByRole('tabpanel');
|
||||
expect(design).toHaveAttribute('aria-selected', 'true');
|
||||
expect(design).toHaveAttribute('aria-controls', panel.id);
|
||||
expect(panel).toHaveAttribute('aria-labelledby', design.id);
|
||||
expect(
|
||||
screen.getByRole('tablist').compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Convert' }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
expect(screen.getByTestId('convert-method-panel')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Keep my script')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: 'From audio' }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
});
|
||||
expect(screen.getByDisplayValue('Keep my script')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('convert-method-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts the Details summary collapsed, not expanded', () => {
|
||||
|
||||
Reference in New Issue
Block a user