mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
test: App startup, ChordPicker, HistoryTable, and generate-flow suites
App: non-Tauri skips the startup gate, Tauri dev reaches the router with close-handler/updater wiring, ?view=dictate renders the pill without booting the main app (branches behind import.meta.env.PROD are unreachable under vitest and stay uncovered). ChordPicker: chord capture via raw KeyboardEvents with explicit codes, peak-set semantics, save/cancel. HistoryTable: rows, empty state, player-store autoplay intent, favorite/delete round trips, export through platform.filesystem.saveFile. FloatingGenerateBox: mounted via the real index route so submit → pending → SSE completion → history refetch runs the actual RootLayout wiring.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||
import App from '@/App';
|
||||
import { createMockPlatform } from '@/test/mockPlatform';
|
||||
import { buildModelStatus, buildProfile } from '@/test/msw/fixtures';
|
||||
import {
|
||||
captureHandlers,
|
||||
effectsHandlers,
|
||||
historyHandlers,
|
||||
modelHandlers,
|
||||
profileHandlers,
|
||||
settingsHandlers,
|
||||
storyHandlers,
|
||||
taskHandlers,
|
||||
} from '@/test/msw/handlers';
|
||||
import { worker } from '@/test/msw/worker';
|
||||
import { renderWithProviders } from '@/test/render';
|
||||
|
||||
const originalUrl = window.location.href;
|
||||
|
||||
// useChordSync and the permission gates call the Tauri IPC modules directly,
|
||||
// outside the Platform abstraction. There is no Tauri runtime in the test
|
||||
// browser, so `invoke`/`listen` would reject with a TypeError that some
|
||||
// callers (e.g. useChordSync's `listen('dictate:warm-request')`) never get a
|
||||
// chance to handle, surfacing as unhandled rejections. mockIPC installs the
|
||||
// official in-memory IPC shim; `shouldMockEvents` covers listen/emit too.
|
||||
//
|
||||
// Reinstalled per test for a fresh listener map, but never cleared: the
|
||||
// harness unmounts components after this file's afterEach, and those unmount
|
||||
// cleanups still `unlisten` through the shim. The per-file iframe throws the
|
||||
// window state away anyway.
|
||||
beforeEach(() => {
|
||||
mockIPC(
|
||||
(cmd) => {
|
||||
// Permission checks treat the result as a trusted boolean — grant
|
||||
// them so no permission banners pop over the UI under test.
|
||||
if (cmd.startsWith('check_')) return true;
|
||||
return null;
|
||||
},
|
||||
{ shouldMockEvents: true },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', originalUrl);
|
||||
delete window.__voiceboxServerStartedByApp;
|
||||
});
|
||||
|
||||
/**
|
||||
* Everything the index route (MainEditor + app chrome) fetches on mount.
|
||||
* Unstubbed requests fail the test loudly, so this is the full route budget.
|
||||
*/
|
||||
function useHappyPathHandlers() {
|
||||
worker.use(
|
||||
...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
|
||||
...historyHandlers([]),
|
||||
...captureHandlers([]),
|
||||
...settingsHandlers(),
|
||||
...modelHandlers([buildModelStatus()]),
|
||||
...storyHandlers([]),
|
||||
...effectsHandlers([]),
|
||||
...taskHandlers(),
|
||||
);
|
||||
}
|
||||
|
||||
// App reads window.location at render time: `?view=dictate` picks the pill
|
||||
// window, and the router matches the real browser path. Point the URL at the
|
||||
// state under test before mounting; afterEach restores the runner's URL.
|
||||
function setAppUrl(path: string) {
|
||||
window.history.replaceState(null, '', path);
|
||||
}
|
||||
|
||||
it('skips the startup gate outside Tauri and renders the router', async () => {
|
||||
useHappyPathHandlers();
|
||||
setAppUrl('/');
|
||||
|
||||
const screen = await renderWithProviders(<App />);
|
||||
|
||||
// Index route is MainEditor — the profile list proves the router mounted.
|
||||
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
|
||||
|
||||
// Web mode assumes an external server: no lifecycle management at all.
|
||||
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
|
||||
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips server auto-start in Tauri dev mode and still reaches the router', async () => {
|
||||
// App gates auto-start on `import.meta.env.PROD`, which is false under
|
||||
// vitest. The reachable Tauri branch is therefore the dev one: window
|
||||
// close handler installed, auto-start skipped, serverReady forced true.
|
||||
//
|
||||
// The PROD-only branches — `lifecycle.startServer`, the health-check
|
||||
// polling fallback, and the startup-error screen with its Retry button —
|
||||
// are unreachable here without mocking import.meta.env, so they are
|
||||
// intentionally not covered.
|
||||
useHappyPathHandlers();
|
||||
setAppUrl('/');
|
||||
const platform = createMockPlatform({ metadata: { isTauri: true } });
|
||||
|
||||
const screen = await renderWithProviders(<App />, { platform });
|
||||
|
||||
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
|
||||
|
||||
expect(platform.lifecycle.startServer).not.toHaveBeenCalled();
|
||||
expect(platform.lifecycle.setupWindowCloseHandler).toHaveBeenCalled();
|
||||
// Startup syncs the keep-server-running setting into Rust.
|
||||
expect(platform.lifecycle.setKeepServerRunning).toHaveBeenCalledWith(expect.any(Boolean));
|
||||
// Auto-updater runs its mount check in Tauri.
|
||||
expect(platform.updater.checkForUpdates).toHaveBeenCalled();
|
||||
// Dev mode records that the app does not own the server process.
|
||||
expect(window.__voiceboxServerStartedByApp).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the dictate pill window for ?view=dictate without booting the main app', async () => {
|
||||
// No route handlers on purpose: the dictate view must not touch any of the
|
||||
// main app's endpoints, and an unhandled request would fail the test.
|
||||
setAppUrl('/?view=dictate');
|
||||
|
||||
const screen = await renderWithProviders(<App />);
|
||||
|
||||
// DictateWindow forces the document transparent so the Tauri window takes
|
||||
// the pill's shape — the observable signal that it mounted without
|
||||
// throwing under the non-Tauri mock platform.
|
||||
await expect.poll(() => document.body.style.background).toBe('transparent');
|
||||
|
||||
// The pill starts hidden: the wrapper renders but contains no CapturePill.
|
||||
const wrapper = screen.container.firstElementChild as HTMLElement;
|
||||
expect(wrapper.className).toContain('h-screen');
|
||||
expect(wrapper.childElementCount).toBe(0);
|
||||
|
||||
// The startup gate never ran — no server lifecycle calls from this window.
|
||||
expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
|
||||
expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
|
||||
import { renderWithProviders } from '@/test/render';
|
||||
|
||||
// ChordPicker listens on window in the capture phase and canonicalizes via
|
||||
// `event.code`, so raw KeyboardEvents give exact control over which physical
|
||||
// keys the picker sees (userEvent would depend on the host keyboard layout).
|
||||
function press(code: string) {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { code, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
function release(code: string) {
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { code, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
async function renderPicker(initialKeys: string[] = []) {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const screen = await renderWithProviders(
|
||||
<ChordPicker
|
||||
open
|
||||
title="Push-to-talk shortcut"
|
||||
initialKeys={initialKeys}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
return { screen, onSave, onCancel };
|
||||
}
|
||||
|
||||
it('opens empty with save disabled and flags unsupported keys', async () => {
|
||||
const { screen } = await renderPicker();
|
||||
|
||||
await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
|
||||
await expect.element(screen.getByText('No keys yet')).toBeVisible();
|
||||
await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
|
||||
// NumpadEnter has no canonical chord name — the picker refuses it and
|
||||
// stays empty instead of capturing garbage.
|
||||
press('NumpadEnter');
|
||||
await expect.element(screen.getByText(/isn't supported in chords/)).toBeVisible();
|
||||
await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('captures the held keys and saves them after release', async () => {
|
||||
const { screen, onSave } = await renderPicker();
|
||||
|
||||
press('KeyJ');
|
||||
await expect.element(screen.getByText('Capturing…')).toBeVisible();
|
||||
await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
|
||||
|
||||
press('KeyK');
|
||||
await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
|
||||
|
||||
// Releasing everything freezes the peak so the user can save hands-free.
|
||||
release('KeyK');
|
||||
release('KeyJ');
|
||||
await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
|
||||
await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
|
||||
await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
|
||||
|
||||
await screen.getByRole('button', { name: 'Save' }).click();
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyJ', 'KeyK']);
|
||||
});
|
||||
|
||||
it('keeps the peak set when a key is released mid-chord', async () => {
|
||||
const { screen, onSave } = await renderPicker();
|
||||
|
||||
press('KeyA');
|
||||
press('KeyB');
|
||||
press('KeyC');
|
||||
await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
|
||||
|
||||
// Mid-chord the display tracks only the currently held keys...
|
||||
release('KeyB');
|
||||
await expect.element(screen.getByText('B', { exact: true })).not.toBeInTheDocument();
|
||||
await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
|
||||
|
||||
// ...but the captured peak still includes the released key.
|
||||
release('KeyA');
|
||||
release('KeyC');
|
||||
await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
|
||||
|
||||
await screen.getByRole('button', { name: 'Save' }).click();
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyA', 'KeyB', 'KeyC']);
|
||||
});
|
||||
|
||||
it('replaces a longer saved chord with a fresh shorter one', async () => {
|
||||
const { screen, onSave } = await renderPicker(['KeyA', 'KeyB', 'KeyC']);
|
||||
|
||||
await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
|
||||
|
||||
// The first key of a new sequence resets the peak, so a single key can
|
||||
// beat the three-key seed.
|
||||
press('KeyZ');
|
||||
release('KeyZ');
|
||||
await expect.element(screen.getByText('Z', { exact: true })).toBeVisible();
|
||||
await expect.element(screen.getByText('A', { exact: true })).not.toBeInTheDocument();
|
||||
|
||||
await screen.getByRole('button', { name: 'Save' }).click();
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyZ']);
|
||||
});
|
||||
|
||||
it('cancel fires the cancel callback and never saves', async () => {
|
||||
const { screen, onSave, onCancel } = await renderPicker(['KeyA']);
|
||||
|
||||
press('KeyQ');
|
||||
release('KeyQ');
|
||||
await screen.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { expect, it } from 'vitest';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { buildGeneration, buildModelStatus, buildProfile } from '@/test/msw/fixtures';
|
||||
import {
|
||||
captureHandlers,
|
||||
effectsHandlers,
|
||||
historyHandlers,
|
||||
modelHandlers,
|
||||
profileHandlers,
|
||||
settingsHandlers,
|
||||
storyHandlers,
|
||||
taskHandlers,
|
||||
} from '@/test/msw/handlers';
|
||||
import { worker } from '@/test/msw/worker';
|
||||
import { renderRoute } from '@/test/render';
|
||||
import { sseController } from '@/test/sse';
|
||||
|
||||
/**
|
||||
* FloatingGenerateBox calls useMatchRoute, so it needs router context; the
|
||||
* SSE completion loop (useGenerationProgress) lives in the router's root
|
||||
* layout. Mounting the index route exercises the real wiring for both.
|
||||
* History handlers are registered per test so requests can be counted.
|
||||
*/
|
||||
function stubAppRequests(profiles: VoiceProfileResponse[]) {
|
||||
worker.use(
|
||||
...profileHandlers(profiles),
|
||||
...captureHandlers([]),
|
||||
...settingsHandlers(),
|
||||
...modelHandlers([buildModelStatus()]),
|
||||
...storyHandlers([]),
|
||||
...effectsHandlers([]),
|
||||
...taskHandlers(),
|
||||
);
|
||||
}
|
||||
|
||||
it('renders the generate box wired to the selected profile', async () => {
|
||||
const profile = buildProfile({ name: 'Ada Lovelace' });
|
||||
stubAppRequests([profile]);
|
||||
worker.use(...historyHandlers([]));
|
||||
useUIStore.getState().setSelectedProfileId(profile.id);
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
await expect
|
||||
.element(screen.getByPlaceholder('Generate speech using Ada Lovelace…'))
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByRole('button', { name: 'Generate speech' })).toBeEnabled();
|
||||
expect(useUIStore.getState().selectedProfileId).toBe(profile.id);
|
||||
});
|
||||
|
||||
it('posts to /generate on submit and tracks the pending generation', async () => {
|
||||
const profile = buildProfile({ name: 'Ada Lovelace' });
|
||||
const generation = buildGeneration({
|
||||
profile_id: profile.id,
|
||||
status: 'generating',
|
||||
audio_path: undefined,
|
||||
});
|
||||
const generateBodies: unknown[] = [];
|
||||
const sse = sseController();
|
||||
stubAppRequests([profile]);
|
||||
worker.use(
|
||||
...historyHandlers([]),
|
||||
http.post('*/generate', async ({ request }) => {
|
||||
generateBodies.push(await request.json());
|
||||
return HttpResponse.json(generation);
|
||||
}),
|
||||
http.get('*/generate/:id/status', () => sse.response()),
|
||||
);
|
||||
useUIStore.getState().setSelectedProfileId(profile.id);
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
const input = screen.getByPlaceholder('Generate speech using Ada Lovelace…');
|
||||
await input.fill('Hello from the browser test');
|
||||
await screen.getByRole('button', { name: 'Generate speech' }).click();
|
||||
|
||||
await expect.poll(() => generateBodies.length).toBe(1);
|
||||
expect(generateBodies[0]).toMatchObject({
|
||||
profile_id: profile.id,
|
||||
text: 'Hello from the browser test',
|
||||
language: 'en',
|
||||
engine: 'qwen',
|
||||
});
|
||||
await expect
|
||||
.poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
|
||||
.toBe(true);
|
||||
// The form resets as soon as the request is accepted.
|
||||
await expect.element(input).toHaveValue('');
|
||||
sse.close();
|
||||
});
|
||||
|
||||
it('clears pending state and refetches history when SSE reports completion', async () => {
|
||||
const profile = buildProfile({ name: 'Ada Lovelace' });
|
||||
const generation = buildGeneration({
|
||||
profile_id: profile.id,
|
||||
status: 'generating',
|
||||
audio_path: undefined,
|
||||
});
|
||||
const sse = sseController();
|
||||
let sseConnections = 0;
|
||||
let historyGets = 0;
|
||||
stubAppRequests([profile]);
|
||||
worker.use(
|
||||
http.get('*/history', () => {
|
||||
historyGets += 1;
|
||||
return HttpResponse.json({ items: [], total: 0 });
|
||||
}),
|
||||
http.post('*/generate', () => HttpResponse.json(generation)),
|
||||
http.get('*/generate/:id/status', () => {
|
||||
sseConnections += 1;
|
||||
return sse.response();
|
||||
}),
|
||||
// Autoplay is off via settingsHandlers, but keep audio stubbed so a
|
||||
// completion-triggered player fetch could never fail the run loudly.
|
||||
http.get(
|
||||
'*/audio/:id',
|
||||
() =>
|
||||
new HttpResponse(new Blob([new Uint8Array(64)]), {
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
useUIStore.getState().setSelectedProfileId(profile.id);
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
await screen.getByPlaceholder('Generate speech using Ada Lovelace…').fill('Progress please');
|
||||
await screen.getByRole('button', { name: 'Generate speech' }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
|
||||
.toBe(true);
|
||||
await expect.poll(() => sseConnections).toBe(1);
|
||||
// Initial mount fetch + post-submit invalidation — wait for both so the
|
||||
// final count increase can only come from the SSE completion refetch.
|
||||
await expect.poll(() => historyGets).toBe(2);
|
||||
|
||||
sse.push({ data: { id: generation.id, status: 'generating' } });
|
||||
sse.push({ data: { id: generation.id, status: 'completed', duration: 1.5 } });
|
||||
|
||||
await expect.poll(() => useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
|
||||
await expect.poll(() => historyGets).toBe(3);
|
||||
sse.close();
|
||||
});
|
||||
|
||||
it('disables the input and generate button when no profile is selected', async () => {
|
||||
stubAppRequests([]);
|
||||
worker.use(...historyHandlers([]));
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'Select a voice profile first' }))
|
||||
.toBeDisabled();
|
||||
await expect.element(screen.getByPlaceholder('Select a voice profile above…')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not post to /generate when the text is empty', async () => {
|
||||
const profile = buildProfile({ name: 'Ada Lovelace' });
|
||||
let generateCalls = 0;
|
||||
stubAppRequests([profile]);
|
||||
worker.use(
|
||||
...historyHandlers([]),
|
||||
http.post('*/generate', () => {
|
||||
generateCalls += 1;
|
||||
return HttpResponse.json(buildGeneration());
|
||||
}),
|
||||
);
|
||||
useUIStore.getState().setSelectedProfileId(profile.id);
|
||||
|
||||
const screen = await renderRoute('/');
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Generate speech' });
|
||||
await expect.element(button).toBeEnabled();
|
||||
await button.click();
|
||||
|
||||
// Validation rejects empty text before any request is made — give a
|
||||
// would-be submission ample time to surface, then assert it never did.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(generateCalls).toBe(0);
|
||||
expect(useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { buildHistoryItem } from '@/test/msw/fixtures';
|
||||
import { historyHandlers } from '@/test/msw/handlers';
|
||||
import { worker } from '@/test/msw/worker';
|
||||
import { renderWithProviders } from '@/test/render';
|
||||
|
||||
it('renders history rows with profile names and transcripts', async () => {
|
||||
const ada = buildHistoryItem({
|
||||
profile_name: 'Ada Lovelace',
|
||||
text: 'The analytical engine speaks.',
|
||||
});
|
||||
const grace = buildHistoryItem({
|
||||
profile_name: 'Grace Hopper',
|
||||
text: 'A compiler for the spoken word.',
|
||||
});
|
||||
worker.use(...historyHandlers([ada, grace]));
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
|
||||
await expect.element(screen.getByText('Grace Hopper')).toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole('textbox', { name: /Transcript for sample from Ada Lovelace/ }))
|
||||
.toHaveValue('The analytical engine speaks.');
|
||||
await expect
|
||||
.element(screen.getByRole('textbox', { name: /Transcript for sample from Grace Hopper/ }))
|
||||
.toHaveValue('A compiler for the spoken word.');
|
||||
});
|
||||
|
||||
it('shows the empty state when there is no history', async () => {
|
||||
worker.use(...historyHandlers([]));
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('loads a clicked row into the player store with auto-play intent', async () => {
|
||||
const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Play me back.' });
|
||||
worker.use(...historyHandlers([item]));
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
// Click the profile-name cell — the row's mousedown handler ignores clicks
|
||||
// that land on the transcript textarea.
|
||||
await screen.getByText('Ada Lovelace').click();
|
||||
|
||||
await expect.poll(() => usePlayerStore.getState().audioId).toBe(item.id);
|
||||
const player = usePlayerStore.getState();
|
||||
expect(player.audioUrl).toContain(`/audio/${item.id}`);
|
||||
expect(player.profileId).toBe(item.profile_id);
|
||||
expect(player.shouldAutoPlay).toBe(true);
|
||||
// isPlaying flips only once the AudioPlayer (not mounted here) starts playback.
|
||||
expect(player.isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles favorite via POST and reflects the refetched state', async () => {
|
||||
const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
|
||||
let favorited = false;
|
||||
const favoriteRequests: string[] = [];
|
||||
worker.use(
|
||||
http.get('*/history', () =>
|
||||
HttpResponse.json({ items: [{ ...item, is_favorited: favorited }], total: 1 }),
|
||||
),
|
||||
http.post('*/history/:id/favorite', ({ params }) => {
|
||||
favoriteRequests.push(params.id as string);
|
||||
favorited = true;
|
||||
return HttpResponse.json({ is_favorited: favorited });
|
||||
}),
|
||||
);
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await screen.getByRole('button', { name: 'Favorite' }).click();
|
||||
|
||||
await expect.poll(() => favoriteRequests).toEqual([item.id]);
|
||||
// History was invalidated and refetched — the star now reads as favorited.
|
||||
await expect.element(screen.getByRole('button', { name: 'Unfavorite' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('deletes a generation after confirming the dialog', async () => {
|
||||
const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
|
||||
let items = [item];
|
||||
const deleteRequests: string[] = [];
|
||||
worker.use(
|
||||
http.get('*/history', () => HttpResponse.json({ items, total: items.length })),
|
||||
http.delete('*/history/:id', ({ params }) => {
|
||||
deleteRequests.push(params.id as string);
|
||||
items = items.filter((i) => i.id !== params.id);
|
||||
return HttpResponse.json({ status: 'deleted' });
|
||||
}),
|
||||
);
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await screen.getByRole('button', { name: 'Actions' }).click();
|
||||
await screen.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await expect.element(screen.getByText('Delete Generation')).toBeVisible();
|
||||
await screen.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect.poll(() => deleteRequests).toEqual([item.id]);
|
||||
// The refetched (now empty) list replaces the row.
|
||||
await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
it('exports audio through platform.filesystem.saveFile', async () => {
|
||||
const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Export me please' });
|
||||
worker.use(
|
||||
...historyHandlers([item]),
|
||||
http.get(
|
||||
'*/history/:id/export-audio',
|
||||
() =>
|
||||
new HttpResponse(new Blob([new Uint8Array(64)]), {
|
||||
headers: { 'Content-Type': 'audio/wav' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const screen = await renderWithProviders(<HistoryTable />);
|
||||
|
||||
await screen.getByRole('button', { name: 'Actions' }).click();
|
||||
await screen.getByRole('menuitem', { name: 'Export Audio' }).click();
|
||||
|
||||
const saveFile = vi.mocked(screen.platform.filesystem.saveFile);
|
||||
await expect.poll(() => saveFile.mock.calls.length).toBe(1);
|
||||
const [filename, blob, filters] = saveFile.mock.calls[0];
|
||||
expect(filename).toBe('export-me-please.wav');
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(filters).toEqual([{ name: 'Audio File', extensions: ['wav'] }]);
|
||||
});
|
||||
Reference in New Issue
Block a user