@ephia/dova-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -0
- package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
- package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
- package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
- package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
- package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
- package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
- package/dist/Transport-zdeA4Pou.d.ts +63 -0
- package/dist/audio-state-kZ3KSvux.d.ts +39 -0
- package/dist/chunk-35AJK2IO.js +1 -0
- package/dist/chunk-35AJK2IO.js.map +1 -0
- package/dist/chunk-3LXZODL4.js +886 -0
- package/dist/chunk-3LXZODL4.js.map +1 -0
- package/dist/chunk-5IK5TLSK.js +67 -0
- package/dist/chunk-5IK5TLSK.js.map +1 -0
- package/dist/chunk-7E43RY75.js +9 -0
- package/dist/chunk-7E43RY75.js.map +1 -0
- package/dist/chunk-A5UEXJ5R.js +183 -0
- package/dist/chunk-A5UEXJ5R.js.map +1 -0
- package/dist/chunk-AEE554FT.js +51 -0
- package/dist/chunk-AEE554FT.js.map +1 -0
- package/dist/chunk-DIEWY3IT.js +1332 -0
- package/dist/chunk-DIEWY3IT.js.map +1 -0
- package/dist/chunk-EGIAN7FH.js +18 -0
- package/dist/chunk-EGIAN7FH.js.map +1 -0
- package/dist/chunk-EMOEAPVU.js +486 -0
- package/dist/chunk-EMOEAPVU.js.map +1 -0
- package/dist/chunk-IDC7FHIZ.js +40 -0
- package/dist/chunk-IDC7FHIZ.js.map +1 -0
- package/dist/chunk-ITJFN3VM.js +601 -0
- package/dist/chunk-ITJFN3VM.js.map +1 -0
- package/dist/chunk-K24GNU27.js +22 -0
- package/dist/chunk-K24GNU27.js.map +1 -0
- package/dist/chunk-LXMCRXXF.js +778 -0
- package/dist/chunk-LXMCRXXF.js.map +1 -0
- package/dist/chunk-MJCEOOLW.js +122 -0
- package/dist/chunk-MJCEOOLW.js.map +1 -0
- package/dist/chunk-N7U5M3VZ.js +33 -0
- package/dist/chunk-N7U5M3VZ.js.map +1 -0
- package/dist/chunk-PSYX674B.js +27 -0
- package/dist/chunk-PSYX674B.js.map +1 -0
- package/dist/chunk-RFQRV7ML.js +33 -0
- package/dist/chunk-RFQRV7ML.js.map +1 -0
- package/dist/chunk-THNHRV2B.js +18 -0
- package/dist/chunk-THNHRV2B.js.map +1 -0
- package/dist/chunk-VSLGR64U.js +62 -0
- package/dist/chunk-VSLGR64U.js.map +1 -0
- package/dist/chunk-W2ZP674X.js +346 -0
- package/dist/chunk-W2ZP674X.js.map +1 -0
- package/dist/chunk-YWZUMUYE.js +695 -0
- package/dist/chunk-YWZUMUYE.js.map +1 -0
- package/dist/client-options-Uo6jXO8k.d.ts +64 -0
- package/dist/connection-state-Bk33YprE.d.ts +32 -0
- package/dist/core/bindings/index.d.ts +24 -0
- package/dist/core/bindings/index.js +1025 -0
- package/dist/core/bindings/index.js.map +1 -0
- package/dist/core/index.d.ts +383 -0
- package/dist/core/index.js +1284 -0
- package/dist/core/index.js.map +1 -0
- package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
- package/dist/devices/speechmike/index.d.ts +148 -0
- package/dist/devices/speechmike/index.js +40 -0
- package/dist/devices/speechmike/index.js.map +1 -0
- package/dist/headless/index.d.ts +10 -0
- package/dist/headless/index.js +25 -0
- package/dist/headless/index.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +119 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +38 -0
- package/dist/react/index.js +70 -0
- package/dist/react/index.js.map +1 -0
- package/dist/rich-editor/index.d.ts +46 -0
- package/dist/rich-editor/index.js +13 -0
- package/dist/rich-editor/index.js.map +1 -0
- package/dist/schema-B2ycPlNB.d.ts +87 -0
- package/dist/session-APaXR48R.d.ts +12 -0
- package/dist/shared/index.d.ts +16 -0
- package/dist/shared/index.js +30 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/style.css +1093 -0
- package/dist/testing/index.d.ts +84 -0
- package/dist/testing/index.js +36 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-D5SXPSwR.d.ts +32 -0
- package/dist/ui/index.d.ts +30 -0
- package/dist/ui/index.js +34 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
- package/package.json +110 -0
- package/src/core/audio/audio-worklet-source.ts +30 -0
- package/src/core/audio/index.ts +3 -0
- package/src/core/audio/voice-level-meter.test.ts +27 -0
- package/src/core/audio/voice-level-meter.ts +270 -0
- package/src/core/bindings/EphiaBinding.ts +41 -0
- package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
- package/src/core/bindings/SegmentBindingBridge.ts +377 -0
- package/src/core/bindings/TargetBinding.ts +142 -0
- package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
- package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
- package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
- package/src/core/bindings/adapters/index.ts +2 -0
- package/src/core/bindings/binding-factory.ts +78 -0
- package/src/core/bindings/detect-editor-type.ts +87 -0
- package/src/core/bindings/index.ts +13 -0
- package/src/core/bindings/insertion-boundary.test.ts +38 -0
- package/src/core/bindings/insertion-boundary.ts +26 -0
- package/src/core/bindings/native/NativeBinding.test.ts +277 -0
- package/src/core/bindings/native/NativeBinding.ts +239 -0
- package/src/core/bindings/resolver.ts +18 -0
- package/src/core/bindings/targets/codemirror.binding.ts +293 -0
- package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
- package/src/core/bindings/targets/index.ts +10 -0
- package/src/core/bindings/targets/monaco.binding.ts +315 -0
- package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
- package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
- package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
- package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
- package/src/core/bindings/types.ts +41 -0
- package/src/core/client/EphiaAudioClient.ts +654 -0
- package/src/core/client/audio-capture.ts +263 -0
- package/src/core/client/client-options.ts +39 -0
- package/src/core/client/client-state.ts +18 -0
- package/src/core/client/constants.ts +23 -0
- package/src/core/client/session-api.ts +415 -0
- package/src/core/connection/connection-state.ts +78 -0
- package/src/core/connection/index.ts +6 -0
- package/src/core/index.ts +47 -0
- package/src/core/operations/textToDocumentOperations.test.ts +69 -0
- package/src/core/operations/textToDocumentOperations.ts +92 -0
- package/src/core/runtime/DictationRuntime.test.ts +578 -0
- package/src/core/runtime/DictationRuntime.ts +434 -0
- package/src/core/runtime/TranscriptApplier.test.ts +355 -0
- package/src/core/runtime/TranscriptApplier.ts +229 -0
- package/src/core/runtime/index.ts +18 -0
- package/src/core/session/index.ts +2 -0
- package/src/core/session/session-machine.test.ts +16 -0
- package/src/core/session/session-machine.ts +59 -0
- package/src/core/targets/EditorContextCollector.ts +71 -0
- package/src/core/targets/TargetManager.test.ts +194 -0
- package/src/core/targets/TargetManager.ts +194 -0
- package/src/core/targets/index.ts +10 -0
- package/src/core/text-processing/index.ts +11 -0
- package/src/core/text-processing/overlap.test.ts +35 -0
- package/src/core/text-processing/overlap.ts +101 -0
- package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
- package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
- package/src/core/transcript/client-transcript.reducer.ts +366 -0
- package/src/core/transcript/client-transcript.state.ts +25 -0
- package/src/core/transcript/index.ts +19 -0
- package/src/core/transcript/transcript.assembler.test.ts +205 -0
- package/src/core/transcript/transcript.assembler.ts +152 -0
- package/src/core/transcript/transcript.reducer.test.ts +199 -0
- package/src/core/transcript/transcript.reducer.ts +771 -0
- package/src/core/transcript/transcript.state.ts +123 -0
- package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
- package/src/core/transport/LiveKitTransport.ts +459 -0
- package/src/core/transport/MockTransport.ts +231 -0
- package/src/core/transport/Transport.ts +82 -0
- package/src/debug/sdk-debug-collector.ts +79 -0
- package/src/devices/index.ts +2 -0
- package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
- package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
- package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
- package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
- package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
- package/src/devices/speechmike/browser.ts +80 -0
- package/src/devices/speechmike/constants.ts +74 -0
- package/src/devices/speechmike/dictation-support-loader.ts +81 -0
- package/src/devices/speechmike/index.ts +11 -0
- package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
- package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
- package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
- package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
- package/src/devices/speechmike/speechmike-button-router.ts +73 -0
- package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
- package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
- package/src/devices/speechmike/types.ts +96 -0
- package/src/dictation_support.d.ts +31 -0
- package/src/global.d.ts +10 -0
- package/src/headless/createEphiaClient.ts +220 -0
- package/src/headless/index.ts +18 -0
- package/src/index.ts +89 -0
- package/src/react/EphiaAuto.tsx +87 -0
- package/src/react/components/EphiaDictationButton.tsx +88 -0
- package/src/react/components/EphiaStatusBar.tsx +59 -0
- package/src/react/components/EphiaTextarea.tsx +295 -0
- package/src/react/ephia-react.css +318 -0
- package/src/react/hooks/targets/index.ts +3 -0
- package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
- package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
- package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
- package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
- package/src/react/hooks/useEphia.ts +367 -0
- package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
- package/src/react/hooks/useEphiaServerEvent.ts +33 -0
- package/src/react/hooks/useEphiaTarget.ts +47 -0
- package/src/react/hooks/useEphiaTranscript.ts +22 -0
- package/src/react/index.ts +58 -0
- package/src/react/provider/EphiaContext.ts +63 -0
- package/src/react/provider/EphiaInternalContext.ts +32 -0
- package/src/react/provider/EphiaProvider.tsx +373 -0
- package/src/react/registry/binding-factory.ts +7 -0
- package/src/react/registry/detect-editor-type.ts +2 -0
- package/src/react/registry/events.ts +37 -0
- package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
- package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
- package/src/react/registry/registries/TargetRegistry.ts +327 -0
- package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
- package/src/react/registry/registries/index.ts +5 -0
- package/src/react/store/create-ephia-store.ts +36 -0
- package/src/react/store/types.ts +41 -0
- package/src/react/utils/flash-range.ts +24 -0
- package/src/react/utils/index.ts +1 -0
- package/src/rich-editor/adapters/tiptap.test.ts +86 -0
- package/src/rich-editor/adapters/tiptap.ts +23 -0
- package/src/rich-editor/index.ts +3 -0
- package/src/rich-editor/types.ts +24 -0
- package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
- package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
- package/src/shared/config/endpoint.test.ts +45 -0
- package/src/shared/config/endpoint.ts +39 -0
- package/src/shared/config/schema.ts +32 -0
- package/src/shared/effective-text.ts +13 -0
- package/src/shared/errors/EphiaSdkError.ts +54 -0
- package/src/shared/errors/messages.ts +40 -0
- package/src/shared/index.ts +27 -0
- package/src/shared/state/audio-state.ts +45 -0
- package/src/shared/state/index.ts +2 -0
- package/src/shared/store/document-store.ts +32 -0
- package/src/shared/store/index.ts +2 -0
- package/src/shared/types/editors.ts +28 -0
- package/src/shared/types/session.ts +12 -0
- package/src/style.css +2 -0
- package/src/testing/index.tsx +60 -0
- package/src/ui/assets/ephia-logo.svg +4 -0
- package/src/ui/components/EphiaLogo.tsx +77 -0
- package/src/ui/index.ts +24 -0
- package/src/ui/primitives/Button.tsx +53 -0
- package/src/ui/primitives/Spinner.tsx +21 -0
- package/src/ui/primitives/index.ts +5 -0
- package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
- package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
- package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
- package/src/ui/recorder/appearance.ts +9 -0
- package/src/ui/recorder/index.ts +8 -0
- package/src/ui/theme.css +775 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interface abstraite du transport Ephia Audio.
|
|
3
|
+
*
|
|
4
|
+
* LiveKit est une implémentation. MockTransport en est une autre.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { EphiaAudioEvent, EphiaClientMessage, EphiaServerEvent } from "ephia-protocol";
|
|
8
|
+
import type { EphiaSdkError } from "../../shared/errors/EphiaSdkError";
|
|
9
|
+
|
|
10
|
+
export type TransportConnectParams = {
|
|
11
|
+
livekitUrl: string;
|
|
12
|
+
token: string;
|
|
13
|
+
roomName: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type TransportConnectionStatus =
|
|
17
|
+
| "idle"
|
|
18
|
+
| "connecting"
|
|
19
|
+
| "connected"
|
|
20
|
+
| "reconnecting"
|
|
21
|
+
| "reconnected"
|
|
22
|
+
| "disconnected"
|
|
23
|
+
| "error";
|
|
24
|
+
|
|
25
|
+
export type TransportState = {
|
|
26
|
+
status: TransportConnectionStatus;
|
|
27
|
+
roomName?: string;
|
|
28
|
+
participantIdentity?: string;
|
|
29
|
+
connectionQuality?: "unknown" | "poor" | "good" | "excellent";
|
|
30
|
+
krispActive?: boolean;
|
|
31
|
+
localAudioPublished: boolean;
|
|
32
|
+
localAudioMuted: boolean;
|
|
33
|
+
reconnectCount: number;
|
|
34
|
+
lastError?: EphiaSdkError;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface Transport {
|
|
38
|
+
/** Connecte le transport. */
|
|
39
|
+
connect(params: TransportConnectParams): Promise<void>;
|
|
40
|
+
|
|
41
|
+
/** Déconnecte proprement. */
|
|
42
|
+
disconnect(reason?: string): Promise<void>;
|
|
43
|
+
|
|
44
|
+
/** Publie une piste audio. */
|
|
45
|
+
publishAudio(track: MediaStreamTrack, options?: { enableNoiseFilter?: boolean }): Promise<void>;
|
|
46
|
+
|
|
47
|
+
/** Dé-publie l'audio local. */
|
|
48
|
+
unpublishAudio(): Promise<void>;
|
|
49
|
+
|
|
50
|
+
/** Envoie un message client vers le serveur. */
|
|
51
|
+
sendMessage(message: EphiaClientMessage): Promise<void>;
|
|
52
|
+
|
|
53
|
+
/** Écoute les events Ephia. Retourne un unsubscriber. */
|
|
54
|
+
onEvent(callback: (event: EphiaAudioEvent) => void): () => void;
|
|
55
|
+
|
|
56
|
+
/** Écoute les events serveur V2 bruts (sans inférence). Retourne un unsubscriber. */
|
|
57
|
+
onServerEvent(callback: (event: EphiaServerEvent) => void): () => void;
|
|
58
|
+
|
|
59
|
+
/** Écoute les changements d'état transport. Retourne un unsubscriber. */
|
|
60
|
+
onTransportState(callback: (state: TransportState) => void): () => void;
|
|
61
|
+
|
|
62
|
+
/** Écoute les erreurs transport. Retourne un unsubscriber. */
|
|
63
|
+
onError(callback: (error: EphiaSdkError) => void): () => void;
|
|
64
|
+
|
|
65
|
+
/** Retourne l'état courant. */
|
|
66
|
+
getState(): TransportState;
|
|
67
|
+
|
|
68
|
+
/** Publication audio locale (pour VAD gate mute/unmute). */
|
|
69
|
+
getLocalAudioPublication?(): {
|
|
70
|
+
mute(): Promise<unknown>;
|
|
71
|
+
unmute(): Promise<unknown>;
|
|
72
|
+
} | null;
|
|
73
|
+
|
|
74
|
+
/** Pré-chauffe la connexion WebRTC (DNS + TLS). Non bloquant. Optionnel. */
|
|
75
|
+
prepareConnection?(url: string, token?: string): Promise<void>;
|
|
76
|
+
|
|
77
|
+
/** Appel RPC vers le serveur. */
|
|
78
|
+
performRpc?(method: string, payload: unknown, timeout?: number): Promise<string>;
|
|
79
|
+
|
|
80
|
+
/** Change le périphérique actif. */
|
|
81
|
+
switchActiveDevice?(kind: MediaDeviceKind, deviceId: string): Promise<boolean>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collecteur de debug interne pour l'Ephia SDK.
|
|
3
|
+
* Active uniquement en environnement de développement (Next.js dev).
|
|
4
|
+
* Expose window.__ephia_sdk_debug__ pour inspection externe.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface SdkDebugEntry {
|
|
8
|
+
ts: number;
|
|
9
|
+
source: string;
|
|
10
|
+
type: string;
|
|
11
|
+
payload: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SdkDebugStateSnapshot {
|
|
15
|
+
ts: number;
|
|
16
|
+
source: string;
|
|
17
|
+
state: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SdkDebugCollector {
|
|
21
|
+
events: SdkDebugEntry[];
|
|
22
|
+
rawEvents: SdkDebugEntry[];
|
|
23
|
+
states: Record<string, SdkDebugStateSnapshot>;
|
|
24
|
+
pushEvent(source: string, type: string, payload: unknown): void;
|
|
25
|
+
pushRawEvent(source: string, type: string, payload: unknown): void;
|
|
26
|
+
setState(source: string, state: unknown): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isDev(): boolean {
|
|
30
|
+
if (typeof window === "undefined") return false;
|
|
31
|
+
try {
|
|
32
|
+
return process.env.NODE_ENV === "development";
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let collector: SdkDebugCollector | null = null;
|
|
39
|
+
|
|
40
|
+
export function getSdkDebugCollector(): SdkDebugCollector | null {
|
|
41
|
+
if (!isDev()) return null;
|
|
42
|
+
if (!collector) {
|
|
43
|
+
collector = {
|
|
44
|
+
events: [],
|
|
45
|
+
rawEvents: [],
|
|
46
|
+
states: {},
|
|
47
|
+
pushEvent(source, type, payload) {
|
|
48
|
+
const entry: SdkDebugEntry = { ts: performance.now(), source, type, payload };
|
|
49
|
+
this.events.push(entry);
|
|
50
|
+
if (this.events.length > 3000) this.events.shift();
|
|
51
|
+
},
|
|
52
|
+
pushRawEvent(source, type, payload) {
|
|
53
|
+
const entry: SdkDebugEntry = { ts: performance.now(), source, type, payload };
|
|
54
|
+
this.rawEvents.push(entry);
|
|
55
|
+
if (this.rawEvents.length > 3000) this.rawEvents.shift();
|
|
56
|
+
},
|
|
57
|
+
setState(source, state) {
|
|
58
|
+
this.states[source] = { ts: performance.now(), source, state };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
(window as unknown as Record<string, unknown>).__ephia_sdk_debug__ = collector;
|
|
62
|
+
}
|
|
63
|
+
return collector;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Helper: pousse un event brut sans importer le collector explicitement. */
|
|
67
|
+
export function dbgRawEvent(source: string, type: string, payload: unknown): void {
|
|
68
|
+
getSdkDebugCollector()?.pushRawEvent(source, type, payload);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Helper: pousse un event parsé. */
|
|
72
|
+
export function dbgEvent(source: string, type: string, payload: unknown): void {
|
|
73
|
+
getSdkDebugCollector()?.pushEvent(source, type, payload);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Helper: met à jour un snapshot state. */
|
|
77
|
+
export function dbgState(source: string, state: unknown): void {
|
|
78
|
+
getSdkDebugCollector()?.setState(source, state);
|
|
79
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
6
|
+
import { EphiaSpeechMikeProvider } from "../react/EphiaSpeechMikeProvider";
|
|
7
|
+
import { useEphiaSpeechMike } from "../react/useEphiaSpeechMike";
|
|
8
|
+
import { EphiaInternalContext } from "../../../react/provider/EphiaInternalContext";
|
|
9
|
+
import { createEphiaStore } from "../../../react/store/create-ephia-store";
|
|
10
|
+
import type { EphiaInternalContextValue } from "../../../react/provider/EphiaInternalContext";
|
|
11
|
+
|
|
12
|
+
function mediaDevice(label: string, deviceId: string): MediaDeviceInfo {
|
|
13
|
+
return {
|
|
14
|
+
deviceId,
|
|
15
|
+
groupId: `${deviceId}-group`,
|
|
16
|
+
kind: "audioinput",
|
|
17
|
+
label,
|
|
18
|
+
toJSON: () => ({}),
|
|
19
|
+
} as MediaDeviceInfo;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function installMediaDeviceEventTarget(devices: MediaDeviceInfo[]): void {
|
|
23
|
+
const target = new EventTarget();
|
|
24
|
+
vi.stubGlobal("navigator", {
|
|
25
|
+
mediaDevices: {
|
|
26
|
+
enumerateDevices: vi.fn(async () => devices),
|
|
27
|
+
addEventListener: target.addEventListener.bind(target),
|
|
28
|
+
removeEventListener: target.removeEventListener.bind(target),
|
|
29
|
+
dispatchEvent: target.dispatchEvent.bind(target),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function HookProbe(): React.ReactElement {
|
|
35
|
+
const speechMike = useEphiaSpeechMike();
|
|
36
|
+
return (
|
|
37
|
+
<output
|
|
38
|
+
data-testid="speechmike"
|
|
39
|
+
data-hid={speechMike.status.hid}
|
|
40
|
+
data-audio={speechMike.status.audio}
|
|
41
|
+
data-device-id={speechMike.audioInputDeviceId ?? ""}
|
|
42
|
+
>
|
|
43
|
+
{speechMike.isReady ? "ready" : "not-ready"}
|
|
44
|
+
</output>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createInternalContext(setPreferred: (deviceId: string | undefined) => void): EphiaInternalContextValue {
|
|
49
|
+
return {
|
|
50
|
+
store: createEphiaStore(),
|
|
51
|
+
clientRef: {
|
|
52
|
+
current: {
|
|
53
|
+
setPreferredAudioInputDeviceId: setPreferred,
|
|
54
|
+
},
|
|
55
|
+
} as EphiaInternalContextValue["clientRef"],
|
|
56
|
+
targetManagerRef: { current: null } as EphiaInternalContextValue["targetManagerRef"],
|
|
57
|
+
clientEpoch: 0,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("EphiaSpeechMikeProvider", () => {
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
cleanup();
|
|
64
|
+
vi.unstubAllGlobals();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns a safe hook fallback outside the provider", () => {
|
|
68
|
+
render(<HookProbe />);
|
|
69
|
+
|
|
70
|
+
const output = screen.getByTestId("speechmike");
|
|
71
|
+
expect(output.getAttribute("data-hid")).toBe("unsupported");
|
|
72
|
+
expect(output.getAttribute("data-audio")).toBe("idle");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("resolves a SpeechMike audio device and applies it to the internal client", async () => {
|
|
76
|
+
const setPreferred = vi.fn();
|
|
77
|
+
installMediaDeviceEventTarget([
|
|
78
|
+
mediaDevice("Built-in Microphone", "builtin"),
|
|
79
|
+
mediaDevice("Philips SpeechMike Premium", "speechmike"),
|
|
80
|
+
]);
|
|
81
|
+
const internal = createInternalContext(setPreferred);
|
|
82
|
+
|
|
83
|
+
render(
|
|
84
|
+
<EphiaInternalContext.Provider value={internal}>
|
|
85
|
+
<EphiaSpeechMikeProvider autoConnect={false}>
|
|
86
|
+
<HookProbe />
|
|
87
|
+
</EphiaSpeechMikeProvider>
|
|
88
|
+
</EphiaInternalContext.Provider>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
(navigator.mediaDevices as unknown as EventTarget).dispatchEvent(new Event("devicechange"));
|
|
92
|
+
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(setPreferred).toHaveBeenCalledWith("speechmike");
|
|
95
|
+
expect(screen.getByTestId("speechmike").getAttribute("data-audio")).toBe("ready");
|
|
96
|
+
expect(screen.getByTestId("speechmike").getAttribute("data-device-id")).toBe("speechmike");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
listAudioInputDevices,
|
|
6
|
+
resolveSpeechMikeAudioInput,
|
|
7
|
+
scoreSpeechMikeAudioDevice,
|
|
8
|
+
} from "../speechmike-audio-resolver";
|
|
9
|
+
|
|
10
|
+
function mediaDevice(
|
|
11
|
+
label: string,
|
|
12
|
+
deviceId = label.toLowerCase().replace(/\s+/g, "-"),
|
|
13
|
+
kind: MediaDeviceKind = "audioinput",
|
|
14
|
+
): MediaDeviceInfo {
|
|
15
|
+
return {
|
|
16
|
+
deviceId,
|
|
17
|
+
groupId: `${deviceId}-group`,
|
|
18
|
+
kind,
|
|
19
|
+
label,
|
|
20
|
+
toJSON: () => ({}),
|
|
21
|
+
} as MediaDeviceInfo;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stubMediaDevices(devices: MediaDeviceInfo[], stream?: MediaStream): void {
|
|
25
|
+
vi.stubGlobal("navigator", {
|
|
26
|
+
mediaDevices: {
|
|
27
|
+
enumerateDevices: vi.fn(async () => devices),
|
|
28
|
+
getUserMedia: vi.fn(async () => stream ?? createStream()),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createStream(): MediaStream {
|
|
34
|
+
const track = { stop: vi.fn() } as unknown as MediaStreamTrack;
|
|
35
|
+
return {
|
|
36
|
+
getTracks: () => [track],
|
|
37
|
+
} as unknown as MediaStream;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("SpeechMikeAudioResolver", () => {
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("scores SpeechMike, Philips, dictation, and microphone labels", () => {
|
|
46
|
+
expect(scoreSpeechMikeAudioDevice(mediaDevice("SpeechMike Premium"))).toBe(100);
|
|
47
|
+
expect(scoreSpeechMikeAudioDevice(mediaDevice("Philips USB"))).toBe(60);
|
|
48
|
+
expect(scoreSpeechMikeAudioDevice(mediaDevice("Dictation source"))).toBe(40);
|
|
49
|
+
expect(scoreSpeechMikeAudioDevice(mediaDevice("Built-in Microphone"))).toBe(10);
|
|
50
|
+
expect(scoreSpeechMikeAudioDevice(mediaDevice("Speaker", "speaker", "audiooutput"))).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("selects the highest scoring audio input", async () => {
|
|
54
|
+
stubMediaDevices([
|
|
55
|
+
mediaDevice("Built-in Microphone", "builtin"),
|
|
56
|
+
mediaDevice("Philips SpeechMike Premium", "speechmike"),
|
|
57
|
+
mediaDevice("Dictation microphone", "dictation"),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
await expect(resolveSpeechMikeAudioInput()).resolves.toMatchObject({
|
|
61
|
+
deviceId: "speechmike",
|
|
62
|
+
label: "Philips SpeechMike Premium",
|
|
63
|
+
score: 160,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns null when no audio input matches", async () => {
|
|
68
|
+
stubMediaDevices([mediaDevice("USB Headset", "headset")]);
|
|
69
|
+
|
|
70
|
+
await expect(resolveSpeechMikeAudioInput()).resolves.toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("lists only audio input devices", async () => {
|
|
74
|
+
stubMediaDevices([
|
|
75
|
+
mediaDevice("SpeechMike", "mic"),
|
|
76
|
+
mediaDevice("Speaker", "speaker", "audiooutput"),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
await expect(listAudioInputDevices()).resolves.toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("requests permission only when requested and closes the probe stream", async () => {
|
|
83
|
+
const stop = vi.fn();
|
|
84
|
+
const stream = {
|
|
85
|
+
getTracks: () => [{ stop }],
|
|
86
|
+
} as unknown as MediaStream;
|
|
87
|
+
stubMediaDevices([mediaDevice("SpeechMike", "speechmike")], stream);
|
|
88
|
+
|
|
89
|
+
await resolveSpeechMikeAudioInput();
|
|
90
|
+
expect(navigator.mediaDevices.getUserMedia).not.toHaveBeenCalled();
|
|
91
|
+
|
|
92
|
+
await resolveSpeechMikeAudioInput({ requestPermission: true });
|
|
93
|
+
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: true });
|
|
94
|
+
expect(stop).toHaveBeenCalledTimes(1);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from "vitest";
|
|
2
|
+
import { SpeechMikeButtonRouter } from "../speechmike-button-router";
|
|
3
|
+
import type { DictationSupportEnum } from "../dictation-support-loader";
|
|
4
|
+
|
|
5
|
+
const BUTTONS = {
|
|
6
|
+
1: "RECORD",
|
|
7
|
+
2: "PLAY",
|
|
8
|
+
4: "F3",
|
|
9
|
+
8: "F1",
|
|
10
|
+
16: "UNKNOWN",
|
|
11
|
+
RECORD: 1,
|
|
12
|
+
PLAY: 2,
|
|
13
|
+
F3: 4,
|
|
14
|
+
F1: 8,
|
|
15
|
+
UNKNOWN: 16,
|
|
16
|
+
} as unknown as DictationSupportEnum;
|
|
17
|
+
|
|
18
|
+
describe("SpeechMikeButtonRouter", () => {
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.useRealTimers();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("maps known button bitmasks to default actions", () => {
|
|
24
|
+
expect(new SpeechMikeButtonRouter(BUTTONS).parse(1)?.actions).toEqual([
|
|
25
|
+
"record.toggle",
|
|
26
|
+
]);
|
|
27
|
+
expect(new SpeechMikeButtonRouter(BUTTONS).parse(2)?.actions).toEqual([
|
|
28
|
+
"play.toggle",
|
|
29
|
+
]);
|
|
30
|
+
expect(new SpeechMikeButtonRouter(BUTTONS).parse(4)?.actions).toEqual([
|
|
31
|
+
"audio.submit",
|
|
32
|
+
]);
|
|
33
|
+
expect(new SpeechMikeButtonRouter(BUTTONS).parse(8)?.actions).toEqual([
|
|
34
|
+
"audio.discard",
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("debounces repeated button events", () => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
vi.setSystemTime(1_000);
|
|
41
|
+
const router = new SpeechMikeButtonRouter(BUTTONS, {}, 150);
|
|
42
|
+
|
|
43
|
+
expect(router.parse(1)?.actions).toEqual(["record.toggle"]);
|
|
44
|
+
vi.setSystemTime(1_100);
|
|
45
|
+
expect(router.parse(1)).toBeNull();
|
|
46
|
+
vi.setSystemTime(1_200);
|
|
47
|
+
expect(router.parse(1)?.actions).toEqual(["record.toggle"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("applies custom mapping over defaults", () => {
|
|
51
|
+
const router = new SpeechMikeButtonRouter(BUTTONS, {
|
|
52
|
+
RECORD: "record.start",
|
|
53
|
+
F3: "none",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(router.parse(1)?.actions).toEqual(["record.start"]);
|
|
57
|
+
expect(new SpeechMikeButtonRouter(BUTTONS, { F3: "none" }).parse(4)?.actions).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns no actions for unknown buttons and empty masks", () => {
|
|
61
|
+
const router = new SpeechMikeButtonRouter(BUTTONS);
|
|
62
|
+
|
|
63
|
+
expect(router.parse(16)).toMatchObject({ actions: [], rawBitMask: 16 });
|
|
64
|
+
expect(router.parse(0)).toMatchObject({ actions: [], rawBitMask: 0 });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { SpeechMikeDeviceManager } from "../speechmike-device-manager";
|
|
5
|
+
import type {
|
|
6
|
+
DictationButtonCallback,
|
|
7
|
+
DictationDeviceCallback,
|
|
8
|
+
DictationDeviceManagerLike,
|
|
9
|
+
DictationSupportRuntime,
|
|
10
|
+
} from "../dictation-support-loader";
|
|
11
|
+
|
|
12
|
+
const BUTTONS = {
|
|
13
|
+
1: "RECORD",
|
|
14
|
+
RECORD: 1,
|
|
15
|
+
} as unknown as DictationSupportRuntime["ButtonEvent"];
|
|
16
|
+
|
|
17
|
+
class FakeDictationDeviceManager implements DictationDeviceManagerLike {
|
|
18
|
+
devices: unknown[] = [];
|
|
19
|
+
init = vi.fn(async () => {});
|
|
20
|
+
requestDevice = vi.fn(async () => {
|
|
21
|
+
this.devices = [{ productName: "SpeechMike Premium" }];
|
|
22
|
+
return this.devices;
|
|
23
|
+
});
|
|
24
|
+
getDevices = vi.fn(() => this.devices);
|
|
25
|
+
disconnect = vi.fn();
|
|
26
|
+
buttonListeners = new Set<DictationButtonCallback>();
|
|
27
|
+
connectedListeners = new Set<DictationDeviceCallback>();
|
|
28
|
+
disconnectedListeners = new Set<DictationDeviceCallback>();
|
|
29
|
+
|
|
30
|
+
addButtonEventListener = vi.fn((cb: DictationButtonCallback) => {
|
|
31
|
+
this.buttonListeners.add(cb);
|
|
32
|
+
});
|
|
33
|
+
removeButtonEventListener = vi.fn((cb: DictationButtonCallback) => {
|
|
34
|
+
this.buttonListeners.delete(cb);
|
|
35
|
+
});
|
|
36
|
+
addDeviceConnectedEventListener = vi.fn((cb: DictationDeviceCallback) => {
|
|
37
|
+
this.connectedListeners.add(cb);
|
|
38
|
+
});
|
|
39
|
+
removeDeviceConnectedEventListener = vi.fn((cb: DictationDeviceCallback) => {
|
|
40
|
+
this.connectedListeners.delete(cb);
|
|
41
|
+
});
|
|
42
|
+
addDeviceDisconnectedEventListener = vi.fn((cb: DictationDeviceCallback) => {
|
|
43
|
+
this.disconnectedListeners.add(cb);
|
|
44
|
+
});
|
|
45
|
+
removeDeviceDisconnectedEventListener = vi.fn((cb: DictationDeviceCallback) => {
|
|
46
|
+
this.disconnectedListeners.delete(cb);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runtimeFor(instance: FakeDictationDeviceManager): DictationSupportRuntime {
|
|
51
|
+
return {
|
|
52
|
+
ButtonEvent: BUTTONS,
|
|
53
|
+
DictationDeviceManager: class {
|
|
54
|
+
constructor() {
|
|
55
|
+
return instance;
|
|
56
|
+
}
|
|
57
|
+
} as unknown as new () => DictationDeviceManagerLike,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stubNavigator(options: {
|
|
62
|
+
hidDevices?: unknown[];
|
|
63
|
+
mediaDevices?: MediaDeviceInfo[];
|
|
64
|
+
connect?: Set<(event: Event) => void>;
|
|
65
|
+
disconnect?: Set<(event: Event) => void>;
|
|
66
|
+
} = {}): void {
|
|
67
|
+
const connect = options.connect ?? new Set<(event: Event) => void>();
|
|
68
|
+
const disconnect = options.disconnect ?? new Set<(event: Event) => void>();
|
|
69
|
+
|
|
70
|
+
vi.stubGlobal("navigator", {
|
|
71
|
+
hid: {
|
|
72
|
+
getDevices: vi.fn(async () => options.hidDevices ?? []),
|
|
73
|
+
addEventListener: vi.fn((type: "connect" | "disconnect", cb: (event: Event) => void) => {
|
|
74
|
+
(type === "connect" ? connect : disconnect).add(cb);
|
|
75
|
+
}),
|
|
76
|
+
removeEventListener: vi.fn((type: "connect" | "disconnect", cb: (event: Event) => void) => {
|
|
77
|
+
(type === "connect" ? connect : disconnect).delete(cb);
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
mediaDevices: {
|
|
81
|
+
enumerateDevices: vi.fn(async () => options.mediaDevices ?? []),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mediaDevice(label: string): MediaDeviceInfo {
|
|
87
|
+
return {
|
|
88
|
+
deviceId: label,
|
|
89
|
+
groupId: label,
|
|
90
|
+
kind: "audioinput",
|
|
91
|
+
label,
|
|
92
|
+
toJSON: () => ({}),
|
|
93
|
+
} as MediaDeviceInfo;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("SpeechMikeDeviceManager", () => {
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
vi.unstubAllGlobals();
|
|
99
|
+
vi.useRealTimers();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("reports unsupported when WebHID is absent", async () => {
|
|
103
|
+
vi.stubGlobal("navigator", { mediaDevices: {} });
|
|
104
|
+
const statuses: string[] = [];
|
|
105
|
+
const manager = new SpeechMikeDeviceManager({
|
|
106
|
+
loadRuntime: async () => runtimeFor(new FakeDictationDeviceManager()),
|
|
107
|
+
});
|
|
108
|
+
manager.on("status", (status) => statuses.push(status));
|
|
109
|
+
|
|
110
|
+
await manager.initialize();
|
|
111
|
+
|
|
112
|
+
expect(statuses).toContain("unsupported");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("reports driver_missing when dictation_support cannot load", async () => {
|
|
116
|
+
stubNavigator();
|
|
117
|
+
const errors: string[] = [];
|
|
118
|
+
const statuses: string[] = [];
|
|
119
|
+
const manager = new SpeechMikeDeviceManager({
|
|
120
|
+
loadRuntime: async () => {
|
|
121
|
+
throw Object.assign(new Error("missing"), { code: "speechmike.driver_missing" });
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
manager.on("status", (status) => statuses.push(status));
|
|
125
|
+
manager.on("error", (error) => errors.push(error.code));
|
|
126
|
+
|
|
127
|
+
await manager.initialize();
|
|
128
|
+
|
|
129
|
+
expect(errors).toEqual(["speechmike.driver_missing"]);
|
|
130
|
+
expect(statuses).toContain("driver_missing");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("silently restores an already authorized device", async () => {
|
|
134
|
+
const instance = new FakeDictationDeviceManager();
|
|
135
|
+
instance.devices = [{ productName: "SpeechMike Premium" }];
|
|
136
|
+
stubNavigator({ hidDevices: [{ productName: "SpeechMike Premium" }] });
|
|
137
|
+
const devices: unknown[] = [];
|
|
138
|
+
const statuses: string[] = [];
|
|
139
|
+
const manager = new SpeechMikeDeviceManager({
|
|
140
|
+
loadRuntime: async () => runtimeFor(instance),
|
|
141
|
+
});
|
|
142
|
+
manager.on("device", (device) => devices.push(device));
|
|
143
|
+
manager.on("status", (status) => statuses.push(status));
|
|
144
|
+
|
|
145
|
+
await manager.initialize();
|
|
146
|
+
|
|
147
|
+
expect(instance.init).toHaveBeenCalled();
|
|
148
|
+
expect(devices[0]).toBe(instance.devices[0]);
|
|
149
|
+
expect(statuses).toContain("connected");
|
|
150
|
+
manager.dispose();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("reports present_unauthorized when only physical/audio presence is visible", async () => {
|
|
154
|
+
const instance = new FakeDictationDeviceManager();
|
|
155
|
+
stubNavigator({ mediaDevices: [mediaDevice("Philips SpeechMike")] });
|
|
156
|
+
const statuses: string[] = [];
|
|
157
|
+
const manager = new SpeechMikeDeviceManager({
|
|
158
|
+
loadRuntime: async () => runtimeFor(instance),
|
|
159
|
+
});
|
|
160
|
+
manager.on("status", (status) => statuses.push(status));
|
|
161
|
+
|
|
162
|
+
await manager.initialize();
|
|
163
|
+
|
|
164
|
+
expect(statuses).toContain("present_unauthorized");
|
|
165
|
+
manager.dispose();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("requests explicit authorization and attaches the granted device", async () => {
|
|
169
|
+
const instance = new FakeDictationDeviceManager();
|
|
170
|
+
stubNavigator({ hidDevices: [{ productName: "SpeechMike Premium" }] });
|
|
171
|
+
const statuses: string[] = [];
|
|
172
|
+
const manager = new SpeechMikeDeviceManager({
|
|
173
|
+
loadRuntime: async () => runtimeFor(instance),
|
|
174
|
+
});
|
|
175
|
+
manager.on("status", (status) => statuses.push(status));
|
|
176
|
+
|
|
177
|
+
await manager.requestAuthorization();
|
|
178
|
+
|
|
179
|
+
expect(instance.requestDevice).toHaveBeenCalled();
|
|
180
|
+
expect(manager.getCurrentDevice()).toBe(instance.devices[0]);
|
|
181
|
+
expect(statuses).toContain("authorization_required");
|
|
182
|
+
expect(statuses).toContain("connected");
|
|
183
|
+
manager.dispose();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("cleans up listeners and disconnects on dispose", async () => {
|
|
187
|
+
const instance = new FakeDictationDeviceManager();
|
|
188
|
+
stubNavigator({ hidDevices: [{ productName: "SpeechMike Premium" }] });
|
|
189
|
+
const manager = new SpeechMikeDeviceManager({
|
|
190
|
+
loadRuntime: async () => runtimeFor(instance),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await manager.initialize();
|
|
194
|
+
manager.dispose();
|
|
195
|
+
|
|
196
|
+
expect(instance.removeButtonEventListener).toHaveBeenCalled();
|
|
197
|
+
expect(instance.removeDeviceConnectedEventListener).toHaveBeenCalled();
|
|
198
|
+
expect(instance.removeDeviceDisconnectedEventListener).toHaveBeenCalled();
|
|
199
|
+
expect(instance.disconnect).toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { SpeechMikeLedController } from "../speechmike-led-controller";
|
|
5
|
+
|
|
6
|
+
describe("SpeechMikeLedController", () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.useRealTimers();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("uses batch setLeds when available", async () => {
|
|
12
|
+
const setLeds = vi.fn(async () => {});
|
|
13
|
+
const controller = new SpeechMikeLedController(() => ({ setLeds }));
|
|
14
|
+
|
|
15
|
+
await controller.setBatch({ 1: 3, 2: 0 });
|
|
16
|
+
|
|
17
|
+
expect(setLeds).toHaveBeenCalledWith({ 1: 3, 2: 0 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("falls back to individual setLed calls when batch fails", async () => {
|
|
21
|
+
const setLeds = vi.fn(async () => {
|
|
22
|
+
throw new Error("batch failed");
|
|
23
|
+
});
|
|
24
|
+
const setLed = vi.fn(async () => {});
|
|
25
|
+
const controller = new SpeechMikeLedController(() => ({ setLeds, setLed }));
|
|
26
|
+
|
|
27
|
+
await controller.setBatch({ 1: 3, 2: 0 });
|
|
28
|
+
|
|
29
|
+
expect(setLed).toHaveBeenCalledWith(1, 3);
|
|
30
|
+
expect(setLed).toHaveBeenCalledWith(2, 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("no-ops without a connected device", async () => {
|
|
34
|
+
const controller = new SpeechMikeLedController(() => null);
|
|
35
|
+
|
|
36
|
+
await expect(controller.setBatch({ 1: 3 })).resolves.toBeUndefined();
|
|
37
|
+
await expect(controller.setLed(1, 3)).resolves.toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns false when an individual LED operation times out", async () => {
|
|
41
|
+
vi.useFakeTimers();
|
|
42
|
+
const controller = new SpeechMikeLedController(() => ({
|
|
43
|
+
setLed: vi.fn(() => new Promise<void>(() => {})),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const result = controller.setLed(1, 3);
|
|
47
|
+
vi.advanceTimersByTime(501);
|
|
48
|
+
|
|
49
|
+
await expect(result).resolves.toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("clear queues the off intent", async () => {
|
|
53
|
+
const setLeds = vi.fn(async () => {});
|
|
54
|
+
const controller = new SpeechMikeLedController(() => ({ setLeds }));
|
|
55
|
+
|
|
56
|
+
controller.clear();
|
|
57
|
+
await vi.waitFor(() => expect(setLeds).toHaveBeenCalled());
|
|
58
|
+
|
|
59
|
+
const firstCall = setLeds.mock.calls[0] as unknown as
|
|
60
|
+
| [Record<number, number>]
|
|
61
|
+
| undefined;
|
|
62
|
+
expect(firstCall?.[0]).toMatchObject({
|
|
63
|
+
0: 0,
|
|
64
|
+
1: 0,
|
|
65
|
+
9: 0,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|