fix(dictation): report capture acceptance
This commit is contained in:
@@ -21,8 +21,9 @@ The **Transcriptions** page offers the same recorder as one contextual
|
||||
**Start dictation** action: it appears in the empty state before the first
|
||||
transcript and moves to the page header once history exists. Desktop starts
|
||||
wake the recorder window before dispatch, so a hidden WebView cannot silently
|
||||
miss the request. The in-app action resolves only after the recorder
|
||||
acknowledges delivery; otherwise the page reports the failed start.
|
||||
miss the request. The in-app action confirms listener receipt, then resolves
|
||||
only after microphone startup is accepted. Disabled, rejected, timed-out, or
|
||||
failed starts are reported back on the page.
|
||||
|
||||
Whisper Tiny is the recommended default on macOS, Windows, and Linux. It
|
||||
auto-detects more than 90 languages. Parakeet TDT v3 remains available for its
|
||||
|
||||
@@ -1031,23 +1031,68 @@ pub async fn request_dictation_capture(
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("capture acknowledgement worker failed: {error}"))??;
|
||||
if acknowledged {
|
||||
return Ok(());
|
||||
if !acknowledged {
|
||||
let flags = app.state::<AppFlags>();
|
||||
let cancelled = flags
|
||||
.capture
|
||||
.lock()
|
||||
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
|
||||
.cancel_delivery(delivery_id);
|
||||
if let Some(event) = cancelled.filter(|event| event.name == "tray-dictate") {
|
||||
flags.output.finish_session(event.payload.session_id);
|
||||
}
|
||||
return Err("capture window did not acknowledge the request".into());
|
||||
}
|
||||
|
||||
let outcome_app = app.clone();
|
||||
let completed = tauri::async_runtime::spawn_blocking(move || {
|
||||
wait_for_capture_delivery(
|
||||
|| {
|
||||
let flags = outcome_app.state::<AppFlags>();
|
||||
let capture = flags
|
||||
.capture
|
||||
.lock()
|
||||
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
|
||||
Ok(!capture.completion_ready(delivery_id))
|
||||
},
|
||||
CAPTURE_ACCEPTANCE_TIMEOUT,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("capture acceptance worker failed: {error}"))??;
|
||||
|
||||
let flags = app.state::<AppFlags>();
|
||||
let cancelled = flags
|
||||
if completed {
|
||||
let completion = flags
|
||||
.capture
|
||||
.lock()
|
||||
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
|
||||
.take_completion(delivery_id);
|
||||
return completion
|
||||
.unwrap_or_else(|| Err("capture request completed without an outcome".into()));
|
||||
}
|
||||
if let Some(completion) = flags
|
||||
.capture
|
||||
.lock()
|
||||
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
|
||||
.cancel_delivery(delivery_id);
|
||||
if let Some(event) = cancelled.filter(|event| event.name == "tray-dictate") {
|
||||
.take_completion(delivery_id)
|
||||
{
|
||||
return completion;
|
||||
}
|
||||
if let Some(event) = flags
|
||||
.capture
|
||||
.lock()
|
||||
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
|
||||
.cancel_delivery(delivery_id)
|
||||
.filter(|event| event.name == "tray-dictate")
|
||||
{
|
||||
flags.output.finish_session(event.payload.session_id);
|
||||
}
|
||||
Err("capture window did not acknowledge the request".into())
|
||||
Err("dictation capture did not start in time".into())
|
||||
}
|
||||
|
||||
const CAPTURE_DELIVERY_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const CAPTURE_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const CAPTURE_DELIVERY_POLL: Duration = Duration::from_millis(20);
|
||||
|
||||
fn wait_for_capture_delivery<F>(mut pending: F, timeout: Duration) -> Result<bool, String>
|
||||
@@ -1255,6 +1300,21 @@ pub fn acknowledge_dictation_capture_delivery(
|
||||
capture.acknowledge(registration_id, delivery_id);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn complete_dictation_capture_delivery(
|
||||
app: tauri::AppHandle,
|
||||
registration_id: u64,
|
||||
delivery_id: u64,
|
||||
error: Option<String>,
|
||||
) {
|
||||
let flags = app.state::<AppFlags>();
|
||||
let Ok(mut capture) = flags.capture.lock() else {
|
||||
log::warn!("Dictation capture state lock poisoned");
|
||||
return;
|
||||
};
|
||||
capture.complete(registration_id, delivery_id, error);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn end_dictation_capture_registration(app: tauri::AppHandle, registration_id: u64) {
|
||||
let flags = app.state::<AppFlags>();
|
||||
|
||||
@@ -26,7 +26,7 @@ pub mod watch_folder;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod wayland_shortcut;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::process::Child;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -107,6 +107,12 @@ pub struct CaptureDispatchState {
|
||||
registration_counter: u64,
|
||||
delivery_counter: u64,
|
||||
active_registration: Option<u64>,
|
||||
in_flight: HashMap<u64, CaptureInFlight>,
|
||||
}
|
||||
|
||||
struct CaptureInFlight {
|
||||
event: CaptureEvent,
|
||||
outcome: Option<Result<(), String>>,
|
||||
}
|
||||
|
||||
struct CaptureEnqueue {
|
||||
@@ -122,6 +128,7 @@ impl Default for CaptureDispatchState {
|
||||
registration_counter: 0,
|
||||
delivery_counter: 0,
|
||||
active_registration: None,
|
||||
in_flight: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,10 +185,48 @@ impl CaptureDispatchState {
|
||||
.iter()
|
||||
.position(|event| event.payload.delivery_id == delivery_id)
|
||||
{
|
||||
self.pending.remove(index);
|
||||
if let Some(event) = self.pending.remove(index) {
|
||||
if event.await_result {
|
||||
self.in_flight.insert(
|
||||
delivery_id,
|
||||
CaptureInFlight {
|
||||
event,
|
||||
outcome: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn complete(
|
||||
&mut self,
|
||||
registration_id: u64,
|
||||
delivery_id: u64,
|
||||
error: Option<String>,
|
||||
) {
|
||||
if self.active_registration != Some(registration_id) {
|
||||
return;
|
||||
}
|
||||
if let Some(delivery) = self.in_flight.get_mut(&delivery_id) {
|
||||
delivery.outcome = Some(error.map_or_else(|| Ok(()), Err));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn completion_ready(&self, delivery_id: u64) -> bool {
|
||||
self.in_flight
|
||||
.get(&delivery_id)
|
||||
.is_some_and(|delivery| delivery.outcome.is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn take_completion(&mut self, delivery_id: u64) -> Option<Result<(), String>> {
|
||||
let ready = self.completion_ready(delivery_id);
|
||||
ready
|
||||
.then(|| self.in_flight.remove(&delivery_id))
|
||||
.flatten()
|
||||
.and_then(|delivery| delivery.outcome)
|
||||
}
|
||||
|
||||
pub(crate) fn delivery_pending(&self, delivery_id: u64) -> bool {
|
||||
self.pending
|
||||
.iter()
|
||||
@@ -189,11 +234,16 @@ impl CaptureDispatchState {
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_delivery(&mut self, delivery_id: u64) -> Option<CaptureEvent> {
|
||||
let index = self
|
||||
if let Some(index) = self
|
||||
.pending
|
||||
.iter()
|
||||
.position(|event| event.payload.delivery_id == delivery_id)?;
|
||||
self.pending.remove(index)
|
||||
.position(|event| event.payload.delivery_id == delivery_id)
|
||||
{
|
||||
return self.pending.remove(index);
|
||||
}
|
||||
self.in_flight
|
||||
.remove(&delivery_id)
|
||||
.map(|delivery| delivery.event)
|
||||
}
|
||||
|
||||
pub(crate) fn end_registration(&mut self, registration_id: u64) {
|
||||
@@ -216,6 +266,7 @@ pub(crate) struct DictationCapturePayload {
|
||||
pub(crate) struct CaptureEvent {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) payload: DictationCapturePayload,
|
||||
await_result: bool,
|
||||
}
|
||||
|
||||
pub struct TrayHandle {
|
||||
@@ -232,20 +283,21 @@ fn dictation_capture_event(action: &str, dictating: bool) -> &'static str {
|
||||
}
|
||||
|
||||
pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
|
||||
let _ = dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut);
|
||||
let _ = dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, false);
|
||||
}
|
||||
|
||||
pub(crate) fn request_dictation_capture_delivery(
|
||||
app: &tauri::AppHandle,
|
||||
action: &str,
|
||||
) -> Option<u64> {
|
||||
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut)
|
||||
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, true)
|
||||
}
|
||||
|
||||
fn dispatch_dictation_capture_from(
|
||||
app: &tauri::AppHandle,
|
||||
action: &str,
|
||||
origin: CaptureOrigin,
|
||||
await_result: bool,
|
||||
) -> Option<u64> {
|
||||
let flags = app.state::<AppFlags>();
|
||||
let event = dictation_capture_event(action, flags.dictating.load(Ordering::SeqCst));
|
||||
@@ -277,6 +329,7 @@ fn dispatch_dictation_capture_from(
|
||||
delivery_id: 0,
|
||||
registration_id: 0,
|
||||
},
|
||||
await_result,
|
||||
};
|
||||
let Ok(mut capture) = flags.capture.lock() else {
|
||||
log::warn!("Dictation capture state lock poisoned");
|
||||
@@ -316,6 +369,7 @@ mod dictation_capture_tests {
|
||||
delivery_id: 0,
|
||||
registration_id: 0,
|
||||
},
|
||||
await_result: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +425,31 @@ mod dictation_capture_tests {
|
||||
assert!(state.delivery_pending(second_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn awaited_delivery_preserves_frontend_rejection_for_the_requester() {
|
||||
let mut state = CaptureDispatchState::default();
|
||||
let registration_id = state.begin_registration();
|
||||
state.mark_registration_ready(registration_id);
|
||||
let mut event = capture_event("tray-dictate");
|
||||
event.await_result = true;
|
||||
let delivery_id = state.enqueue(event).delivery_id;
|
||||
|
||||
state.acknowledge(registration_id, delivery_id);
|
||||
assert!(!state.completion_ready(delivery_id));
|
||||
state.complete(
|
||||
registration_id,
|
||||
delivery_id,
|
||||
Some("Dictation is disabled".into()),
|
||||
);
|
||||
assert!(state.completion_ready(delivery_id));
|
||||
|
||||
assert_eq!(
|
||||
state.take_completion(delivery_id),
|
||||
Some(Err("Dictation is disabled".into()))
|
||||
);
|
||||
assert_eq!(state.take_completion(delivery_id), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_listener_cannot_claim_or_clear_a_newer_registration() {
|
||||
let mut state = CaptureDispatchState::default();
|
||||
@@ -797,6 +876,7 @@ pub fn run() {
|
||||
commands::begin_dictation_capture_registration,
|
||||
commands::mark_dictation_capture_ready,
|
||||
commands::acknowledge_dictation_capture_delivery,
|
||||
commands::complete_dictation_capture_delivery,
|
||||
commands::end_dictation_capture_registration,
|
||||
commands::show_dictation_pill,
|
||||
commands::get_launch_as_widget,
|
||||
@@ -1104,12 +1184,14 @@ pub fn run() {
|
||||
app,
|
||||
"stop",
|
||||
CaptureOrigin::Tray,
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
let _ = dispatch_dictation_capture_from(
|
||||
app,
|
||||
"start",
|
||||
CaptureOrigin::Tray,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,13 +711,30 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
const eventRegistrationId = event?.payload?.registrationId;
|
||||
if (eventRegistrationId != null && eventRegistrationId !== registrationId) return false;
|
||||
if (deliveryId != null) {
|
||||
void tauriInvoke('acknowledge_dictation_capture_delivery', {
|
||||
return tauriInvoke('acknowledge_dictation_capture_delivery', {
|
||||
registrationId,
|
||||
deliveryId,
|
||||
}).catch((err) => console.warn('dictation delivery acknowledgement failed:', err));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('dictation delivery acknowledgement failed:', err);
|
||||
})
|
||||
.then(() => true);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const completeDelivery = async (event, error = null) => {
|
||||
const deliveryId = event?.payload?.deliveryId;
|
||||
if (deliveryId == null) return;
|
||||
try {
|
||||
await tauriInvoke('complete_dictation_capture_delivery', {
|
||||
registrationId,
|
||||
deliveryId,
|
||||
error,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('dictation delivery completion failed:', err);
|
||||
}
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
registrationId = await tauriInvoke('begin_dictation_capture_registration');
|
||||
@@ -727,13 +744,19 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
}
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
unlistenStart = await listen('tray-dictate', async (event) => {
|
||||
if (!acknowledgeDelivery(event)) return;
|
||||
const acknowledgement = acknowledgeDelivery(event);
|
||||
if (acknowledgement === false) return;
|
||||
if (acknowledgement !== true) await acknowledgement;
|
||||
const now = Date.now();
|
||||
if (now - nativeEventAtRef.current.start < 150) return;
|
||||
if (now - nativeEventAtRef.current.start < 150) {
|
||||
await completeDelivery(event, 'Duplicate dictation start ignored');
|
||||
return;
|
||||
}
|
||||
nativeEventAtRef.current.start = now;
|
||||
const sessionId = event?.payload?.sessionId;
|
||||
if (!sessionId) {
|
||||
hideWidgetWindow();
|
||||
await completeDelivery(event, 'Dictation output session is missing');
|
||||
return;
|
||||
}
|
||||
await ensureDictationPrefsHydrated();
|
||||
@@ -741,7 +764,8 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
// The hotkey is inert, but Rust has already shown the window.
|
||||
// Put it back rather than leaving an empty capsule on screen.
|
||||
hideWidgetWindow();
|
||||
finishOutputSession(sessionId);
|
||||
await finishOutputSession(sessionId);
|
||||
await completeDelivery(event, 'Dictation is disabled');
|
||||
return;
|
||||
}
|
||||
const sequence = ++nativeStartSequenceRef.current;
|
||||
@@ -757,6 +781,7 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
} catch (err) {
|
||||
console.warn('reject dictation output session failed:', err);
|
||||
}
|
||||
await completeDelivery(event, 'Dictation is already active');
|
||||
return;
|
||||
}
|
||||
const trackHold = modeRef.current === 'hold';
|
||||
@@ -781,11 +806,13 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
clearPendingHold();
|
||||
await finishOutputSession(sessionId);
|
||||
hideWidgetWindow();
|
||||
await completeDelivery(event, `Could not activate dictation output: ${err}`);
|
||||
return;
|
||||
}
|
||||
if (cancelled || sequence !== nativeStartSequenceRef.current || !enabledRef.current) {
|
||||
clearPendingHold();
|
||||
await finishOutputSession(sessionId);
|
||||
await completeDelivery(event, 'Dictation start was cancelled');
|
||||
return;
|
||||
}
|
||||
if (startupWasInFlight) {
|
||||
@@ -793,8 +820,10 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
if (startInFlightRef.current) {
|
||||
outputSessionIdRef.current = sessionId;
|
||||
pendingNativeStartRef.current = { sessionId, trackHold, sequence };
|
||||
await completeDelivery(event, 'Another dictation start is already in progress');
|
||||
} else if (current === 'recording' || current === 'transcribing') {
|
||||
outputSessionIdRef.current = sessionId;
|
||||
await completeDelivery(event);
|
||||
} else if (
|
||||
current === 'idle' ||
|
||||
current === 'done' ||
|
||||
@@ -802,10 +831,14 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
current === 'setup'
|
||||
) {
|
||||
outputSessionIdRef.current = sessionId;
|
||||
startRecordingRef.current?.(trackHold, sessionId);
|
||||
void Promise.resolve(startRecordingRef.current?.(trackHold, sessionId)).then(
|
||||
(accepted) =>
|
||||
completeDelivery(event, accepted ? null : 'Dictation could not start'),
|
||||
);
|
||||
} else {
|
||||
clearPendingHold();
|
||||
await finishOutputSession(sessionId);
|
||||
await completeDelivery(event, 'Dictation could not accept this start');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -814,34 +847,65 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
// in System Settings. A missing grant no longer blocks capture:
|
||||
// native delivery can truthfully fall back to clipboard-only.
|
||||
outputSessionIdRef.current = sessionId;
|
||||
checkAccessibility().then(() => {
|
||||
if (outputSessionIdRef.current !== sessionId) return;
|
||||
startRecordingRef.current?.(modeRef.current === 'hold', sessionId);
|
||||
void checkAccessibility().then(async () => {
|
||||
if (outputSessionIdRef.current !== sessionId) {
|
||||
await completeDelivery(event, 'Dictation output session changed before startup');
|
||||
return;
|
||||
}
|
||||
const accepted = await startRecordingRef.current?.(
|
||||
modeRef.current === 'hold',
|
||||
sessionId,
|
||||
);
|
||||
await completeDelivery(event, accepted ? null : 'Dictation could not start');
|
||||
});
|
||||
return;
|
||||
}
|
||||
const idle = s === 'idle' || s === 'done' || s === 'error';
|
||||
if (modeRef.current === 'toggle') {
|
||||
// Press once to start, again to stop.
|
||||
if (idle) startRecordingRef.current?.(false, sessionId);
|
||||
else if (s === 'recording') stopRecordingRef.current?.();
|
||||
if (idle) {
|
||||
void Promise.resolve(startRecordingRef.current?.(false, sessionId)).then((accepted) =>
|
||||
completeDelivery(event, accepted ? null : 'Dictation could not start'),
|
||||
);
|
||||
return;
|
||||
} else if (s === 'recording') {
|
||||
stopRecordingRef.current?.();
|
||||
await completeDelivery(event);
|
||||
return;
|
||||
}
|
||||
} else if (idle) {
|
||||
// Hold mode: keydown → start.
|
||||
startRecordingRef.current?.(true, sessionId);
|
||||
void Promise.resolve(startRecordingRef.current?.(true, sessionId)).then((accepted) =>
|
||||
completeDelivery(event, accepted ? null : 'Dictation could not start'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await completeDelivery(event, 'Dictation could not start');
|
||||
});
|
||||
unlistenStop = await listen('tray-dictate-stop', async (event) => {
|
||||
if (!acknowledgeDelivery(event)) return;
|
||||
const acknowledgement = acknowledgeDelivery(event);
|
||||
if (acknowledgement === false) return;
|
||||
if (acknowledgement !== true) await acknowledgement;
|
||||
const now = Date.now();
|
||||
if (now - nativeEventAtRef.current.stop < 150) return;
|
||||
if (now - nativeEventAtRef.current.stop < 150) {
|
||||
await completeDelivery(event, 'Duplicate dictation stop ignored');
|
||||
return;
|
||||
}
|
||||
nativeEventAtRef.current.stop = now;
|
||||
await ensureDictationPrefsHydrated();
|
||||
// Only hold mode acts on release; toggle ignores it.
|
||||
let accepted = false;
|
||||
if (modeRef.current === 'hold' && stateRef.current === 'recording') {
|
||||
stopRecordingRef.current?.();
|
||||
accepted = true;
|
||||
} else if (modeRef.current === 'hold' && holdStartRef.current === 'starting') {
|
||||
holdStartRef.current = 'released';
|
||||
accepted = true;
|
||||
}
|
||||
await completeDelivery(
|
||||
event,
|
||||
accepted ? null : 'Dictation is not recording in hold mode',
|
||||
);
|
||||
});
|
||||
await ensureDictationPrefsHydrated();
|
||||
if (cancelled) {
|
||||
@@ -1856,11 +1920,11 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
// newer non-empty lease without launching a second microphone graph.
|
||||
outputSessionIdRef.current = sessionId;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (inTauri() && !sessionId) {
|
||||
hideWidgetWindow();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
startInFlightRef.current = true;
|
||||
if (sessionId) outputSessionIdRef.current = sessionId;
|
||||
@@ -1893,6 +1957,7 @@ export default function CaptureWidget({ onDismiss }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return stateRef.current === 'recording' || stateRef.current === 'transcribing';
|
||||
},
|
||||
[startRecordingImpl],
|
||||
);
|
||||
|
||||
@@ -112,8 +112,11 @@ class FakeWS {
|
||||
|
||||
function pressShortcut() {
|
||||
const handler = eventHandlers['tray-dictate'];
|
||||
if (handler) handler({ payload: { sessionId: 'setup-race-session' } });
|
||||
else eventState.pendingStart = true;
|
||||
if (handler) {
|
||||
handler({
|
||||
payload: { sessionId: 'setup-race-session', deliveryId: 9, registrationId: 1 },
|
||||
});
|
||||
} else eventState.pendingStart = true;
|
||||
}
|
||||
|
||||
let realWebSocket;
|
||||
@@ -128,7 +131,7 @@ beforeEach(() => {
|
||||
if (cmd === 'mark_dictation_capture_ready' && eventState.pendingStart) {
|
||||
eventState.pendingStart = false;
|
||||
return eventHandlers['tray-dictate']?.({
|
||||
payload: { sessionId: 'setup-race-session' },
|
||||
payload: { sessionId: 'setup-race-session', deliveryId: 9, registrationId: 1 },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
@@ -136,6 +139,7 @@ beforeEach(() => {
|
||||
eventState.pendingStart = false;
|
||||
eventUnlisteners.length = 0;
|
||||
FakeWS.instances = [];
|
||||
storeState.dictationEnabled = true;
|
||||
storeState.dictationModelId = 'sherpa-parakeet-v3';
|
||||
realWebSocket = globalThis.WebSocket;
|
||||
globalThis.WebSocket = FakeWS;
|
||||
@@ -192,6 +196,47 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
|
||||
});
|
||||
});
|
||||
|
||||
it('completes an in-page delivery only after microphone startup is accepted', async () => {
|
||||
render(<CaptureWidget />);
|
||||
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
|
||||
|
||||
await eventHandlers['tray-dictate']({
|
||||
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
|
||||
});
|
||||
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
|
||||
expect(invokeMock).not.toHaveBeenCalledWith('complete_dictation_capture_delivery', {
|
||||
registrationId: 1,
|
||||
deliveryId: 9,
|
||||
error: null,
|
||||
});
|
||||
|
||||
micControl.resolve(micStop);
|
||||
await waitFor(() =>
|
||||
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
|
||||
registrationId: 1,
|
||||
deliveryId: 9,
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an in-page delivery when dictation is disabled', async () => {
|
||||
storeState.dictationEnabled = false;
|
||||
render(<CaptureWidget />);
|
||||
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
|
||||
|
||||
await eventHandlers['tray-dictate']({
|
||||
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
|
||||
});
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
|
||||
registrationId: 1,
|
||||
deliveryId: 9,
|
||||
error: 'Dictation is disabled',
|
||||
});
|
||||
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns a PCM-fallback socket failure into a terminal error', async () => {
|
||||
storeState.dictationModelId = 'whisperx';
|
||||
render(<CaptureWidget />);
|
||||
@@ -238,6 +283,11 @@ describe('CaptureWidget — connect-time asr_model_missing during mic setup', ()
|
||||
expect(screen.getByText(/No speech-to-text model/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
|
||||
expect(invokeMock).not.toHaveBeenCalledWith('set_tray_recording', { recording: true });
|
||||
expect(invokeMock).toHaveBeenCalledWith('complete_dictation_capture_delivery', {
|
||||
registrationId: 1,
|
||||
deliveryId: 9,
|
||||
error: 'Dictation could not start',
|
||||
});
|
||||
});
|
||||
|
||||
it('setup REJECTION after the terminal frame must not clobber it with a mic error', async () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_capture_dispatch_wakes_widget_before_emitting_start(lib_rs: str) -> Non
|
||||
)
|
||||
|
||||
|
||||
def test_in_page_capture_waits_for_listener_acknowledgement(commands_rs: str) -> None:
|
||||
def test_in_page_capture_waits_for_listener_acceptance(commands_rs: str) -> None:
|
||||
request = re.search(
|
||||
r"pub async fn request_dictation_capture\(.*?\n\}", commands_rs, re.S
|
||||
)
|
||||
@@ -86,8 +86,11 @@ def test_in_page_capture_waits_for_listener_acknowledgement(commands_rs: str) ->
|
||||
body = request.group(0)
|
||||
assert "request_dictation_capture_delivery" in body
|
||||
assert "wait_for_capture_delivery" in body
|
||||
assert "completion_ready" in body
|
||||
assert "take_completion" in body
|
||||
assert "cancel_delivery" in body
|
||||
assert "capture window did not acknowledge the request" in body
|
||||
assert "dictation capture did not start in time" in body
|
||||
|
||||
|
||||
def test_no_computed_window_target_can_resolve_to_the_widget(lib_rs: str) -> None:
|
||||
|
||||
Reference in New Issue
Block a user