Files
VoiceStudio/frontend/src/components/CaptureWidget.test.jsx
T
d58010fe1b feat(dictation): rebuild to Wispr-Flow quality — live waveform, streaming commits, honest insertion, polished text (#888)
* feat(dictation): rebuild to instant-feedback quality — waveform, streaming commits, honest insertion, text polish

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): dictation rebuild entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lint): Array.from over new Array(n) — oxlint no-array-constructor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:57:56 +05:30

285 lines
9.9 KiB
React

/**
* CaptureWidget pill behaviour — mocked WS + Tauri invoke.
*
* Covers the truthfulness rebuild: model status frames render real
* download/load progress, "Pasted" only appears after simulate_paste resolves
* Ok, an "a11y:"-prefixed paste failure renders the actionable Accessibility
* error, Esc aborts without pasting, live retract-retype is opt-in (default
* sessions never call simulate_type), the missing-Accessibility setup state
* shows on mount, and the waveform bars move from real mic frames.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n';
// ── Hoisted mock state (vi.mock factories may only reference vi.hoisted vars) ──
const mocks = vi.hoisted(() => {
const state = {
dictationEnabled: true,
dictationMode: 'toggle',
dictationModelId: 'sherpa-parakeet-tdt-v3', // sherpa → raw-PCM live path
aecEnabled: false,
loadDictationPrefs: () => {},
};
const holder = {
// Per-test knobs for the Tauri invoke mock.
a11y: true,
paste: async () => undefined,
calls: [],
// Captured micCapture frame callback (the worklet feed).
onFrame: null,
};
return {
state,
holder,
invoke: async (cmd, args) => {
holder.calls.push([cmd, args]);
if (cmd === 'check_accessibility') return holder.a11y;
if (cmd === 'simulate_paste') return holder.paste();
return undefined;
},
};
});
vi.mock('../store', () => ({
useAppStore: Object.assign((sel) => sel(mocks.state), { getState: () => mocks.state }),
}));
vi.mock('../api/client', () => ({
wsUrl: (p) => `ws://test${p}`,
apiFetch: vi.fn(async () => ({ json: async () => ({}) })),
}));
vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() }));
vi.mock('../utils/copyText', () => ({ copyText: vi.fn(async () => {}) }));
vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke }));
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => {}) }));
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ hide: vi.fn(async () => {}) }),
}));
vi.mock('../utils/aec/micCapture', () => ({
startMicCapture: async (stream, onFrame) => {
mocks.holder.onFrame = onFrame;
return async () => {};
},
}));
import CaptureWidget from './CaptureWidget';
// ── Browser API fakes (jsdom has neither WebSocket use here nor MediaRecorder) ──
class FakeWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances = [];
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.OPEN; // pretend the connect is instant
this.sent = [];
this._listeners = {};
FakeWebSocket.instances.push(this);
}
addEventListener(type, fn) {
(this._listeners[type] ||= []).push(fn);
}
send(d) {
this.sent.push(d);
}
close() {
if (this.readyState === FakeWebSocket.CLOSED) return;
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.();
}
/** Deliver a backend JSON frame. */
msg(obj) {
this.onmessage?.({ data: JSON.stringify(obj) });
}
}
class FakeMediaRecorder {
static isTypeSupported() {
return true;
}
constructor() {
this.state = 'inactive';
}
start() {
this.state = 'recording';
}
stop() {
this.state = 'inactive';
}
}
function withI18n(node) {
return <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
}
// Start a session via the in-page shortcut and wait for the live socket.
async function startSession() {
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true, shiftKey: true });
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
await screen.findByText(/Listening/);
return FakeWebSocket.instances[0];
}
describe('CaptureWidget', () => {
beforeEach(() => {
window.__TAURI_INTERNALS__ = {};
mocks.holder.a11y = true;
mocks.holder.paste = async () => undefined;
mocks.holder.calls = [];
mocks.holder.onFrame = null;
FakeWebSocket.instances = [];
global.WebSocket = FakeWebSocket;
global.MediaRecorder = FakeMediaRecorder;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
});
localStorage.clear();
});
afterEach(() => {
delete window.__TAURI_INTERNALS__;
delete global.WebSocket;
delete global.MediaRecorder;
});
const pasteCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_paste');
const typeCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_type');
it('renders truthful model status from {type:"status"} frames', async () => {
render(withI18n(<CaptureWidget />));
const ws = await startSession();
act(() => ws.msg({ type: 'status', stage: 'downloading', progress: 0.42 }));
expect(screen.getByText(/Downloading voice model/)).toBeInTheDocument();
expect(screen.getByText(/42%/)).toBeInTheDocument();
act(() => ws.msg({ type: 'status', stage: 'loading' }));
expect(screen.getByText(/Loading model/)).toBeInTheDocument();
act(() => ws.msg({ type: 'status', stage: 'ready' }));
expect(screen.getByText(/Listening/)).toBeInTheDocument();
});
it('shows "Pasted" only after simulate_paste resolved Ok', async () => {
render(withI18n(<CaptureWidget />));
const ws = await startSession();
// Offline-model shape: one utterance final, then the EOF summary.
act(() => ws.msg({ type: 'final', text: 'hello world' }));
act(() => ws.msg({ type: 'final', text: 'hello world' }));
await screen.findByText(/Pasted/);
expect(pasteCalls().length).toBeGreaterThan(0);
expect(pasteCalls()[0][1]).toEqual({ text: 'hello world' });
});
it('an "a11y:" paste rejection renders the actionable error, never "Pasted"', async () => {
mocks.holder.paste = async () => {
throw 'a11y: process is not trusted';
};
render(withI18n(<CaptureWidget />));
const ws = await startSession();
act(() => ws.msg({ type: 'final', text: 'hello world' }));
act(() => ws.msg({ type: 'final', text: 'hello world' }));
await screen.findByText(/Accessibility access needed/);
expect(screen.queryByText(/Pasted/)).not.toBeInTheDocument();
// The action button opens the OS Accessibility pane.
fireEvent.click(screen.getByText('Open Settings'));
await waitFor(() =>
expect(mocks.holder.calls.some(([c]) => c === 'open_accessibility_settings')).toBe(true),
);
});
it('Esc during recording aborts: socket closed, nothing pasted, pill gone', async () => {
const { container } = render(withI18n(<CaptureWidget />));
const ws = await startSession();
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() => expect(container.querySelector('.capture-pill')).toBeNull());
expect(ws.readyState).toBe(FakeWebSocket.CLOSED);
expect(pasteCalls()).toEqual([]);
});
it('live retract-retype is OFF by default: partials never simulate_type', async () => {
render(withI18n(<CaptureWidget />));
const ws = await startSession();
act(() => ws.msg({ type: 'partial', text: 'hel' }));
act(() => ws.msg({ type: 'partial', text: 'hello wor' }));
act(() => ws.msg({ type: 'final', text: 'hello world' }));
act(() => ws.msg({ type: 'final', text: 'hello world' }));
await screen.findByText(/Pasted/);
// Committed final went through the paste path; no keystroke storms.
expect(typeCalls()).toEqual([]);
expect(pasteCalls().length).toBeGreaterThan(0);
});
it('the LS_LIVE_TYPING pref opts back into word-by-word typing', async () => {
localStorage.setItem('omni_capture_live_typing', '1');
render(withI18n(<CaptureWidget />));
const ws = await startSession();
act(() => ws.msg({ type: 'partial', text: 'hello' }));
await waitFor(() => expect(typeCalls().length).toBeGreaterThan(0));
expect(typeCalls()[0][1]).toEqual({ text: 'hello', backspaces: 0 });
});
it('a refined EOF summary is not re-pasted as a new utterance', async () => {
render(withI18n(<CaptureWidget />));
const ws = await startSession();
// Two per-utterance commits paste live…
act(() => ws.msg({ type: 'final', text: 'Hello world.' }));
act(() => ws.msg({ type: 'final', text: 'Second bit.' }));
// …then the EOF summary arrives with an LLM-refined variant. Its raw
// `text` equals the committed join, so it must finalise — never paste
// the whole (refined) transcript a third time.
act(() =>
ws.msg({
type: 'final',
text: 'Hello world. Second bit.',
refined_text: 'Hello world, second bit.',
}),
);
await screen.findByText(/Pasted/);
expect(pasteCalls().map(([, a]) => a.text)).toEqual(['Hello world.', 'Second bit.']);
});
it('renders the one-time Accessibility setup state when the mount probe fails', async () => {
mocks.holder.a11y = false;
render(withI18n(<CaptureWidget />));
await screen.findByText(/Allow Accessibility/);
expect(screen.getByText('Open Settings')).toBeInTheDocument();
// It does not pretend to record.
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
});
it('waveform bars move from the worklet mic frames', async () => {
const { container } = render(withI18n(<CaptureWidget />));
await startSession();
expect(mocks.holder.onFrame).toBeTypeOf('function');
// Feed ~5 frames of speech-level audio (≈100 ms at 20 ms/frame).
for (let i = 0; i < 5; i++) mocks.holder.onFrame(new Float32Array(320).fill(0.5));
await waitFor(() => {
const bars = container.querySelectorAll('.capture-pill__wave-bar');
expect(bars.length).toBe(12);
const heights = [...bars].map((b) => parseInt(b.style.height, 10));
expect(Math.max(...heights)).toBeGreaterThan(12); // above the silence floor
});
});
});