@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.
Files changed (247) hide show
  1. package/README.md +89 -0
  2. package/dist/EphiaBinding-BvRmlqqC.d.ts +36 -0
  3. package/dist/EphiaFloatingButton-CxiF86VW.d.ts +65 -0
  4. package/dist/EphiaTextarea-B4_CAVUg.d.ts +183 -0
  5. package/dist/NativeBinding-ChG0GeSz.d.ts +53 -0
  6. package/dist/TargetBinding-BKGQwUMc.d.ts +89 -0
  7. package/dist/TiptapBinding-B-agfV2H.d.ts +45 -0
  8. package/dist/Transport-zdeA4Pou.d.ts +63 -0
  9. package/dist/audio-state-kZ3KSvux.d.ts +39 -0
  10. package/dist/chunk-35AJK2IO.js +1 -0
  11. package/dist/chunk-35AJK2IO.js.map +1 -0
  12. package/dist/chunk-3LXZODL4.js +886 -0
  13. package/dist/chunk-3LXZODL4.js.map +1 -0
  14. package/dist/chunk-5IK5TLSK.js +67 -0
  15. package/dist/chunk-5IK5TLSK.js.map +1 -0
  16. package/dist/chunk-7E43RY75.js +9 -0
  17. package/dist/chunk-7E43RY75.js.map +1 -0
  18. package/dist/chunk-A5UEXJ5R.js +183 -0
  19. package/dist/chunk-A5UEXJ5R.js.map +1 -0
  20. package/dist/chunk-AEE554FT.js +51 -0
  21. package/dist/chunk-AEE554FT.js.map +1 -0
  22. package/dist/chunk-DIEWY3IT.js +1332 -0
  23. package/dist/chunk-DIEWY3IT.js.map +1 -0
  24. package/dist/chunk-EGIAN7FH.js +18 -0
  25. package/dist/chunk-EGIAN7FH.js.map +1 -0
  26. package/dist/chunk-EMOEAPVU.js +486 -0
  27. package/dist/chunk-EMOEAPVU.js.map +1 -0
  28. package/dist/chunk-IDC7FHIZ.js +40 -0
  29. package/dist/chunk-IDC7FHIZ.js.map +1 -0
  30. package/dist/chunk-ITJFN3VM.js +601 -0
  31. package/dist/chunk-ITJFN3VM.js.map +1 -0
  32. package/dist/chunk-K24GNU27.js +22 -0
  33. package/dist/chunk-K24GNU27.js.map +1 -0
  34. package/dist/chunk-LXMCRXXF.js +778 -0
  35. package/dist/chunk-LXMCRXXF.js.map +1 -0
  36. package/dist/chunk-MJCEOOLW.js +122 -0
  37. package/dist/chunk-MJCEOOLW.js.map +1 -0
  38. package/dist/chunk-N7U5M3VZ.js +33 -0
  39. package/dist/chunk-N7U5M3VZ.js.map +1 -0
  40. package/dist/chunk-PSYX674B.js +27 -0
  41. package/dist/chunk-PSYX674B.js.map +1 -0
  42. package/dist/chunk-RFQRV7ML.js +33 -0
  43. package/dist/chunk-RFQRV7ML.js.map +1 -0
  44. package/dist/chunk-THNHRV2B.js +18 -0
  45. package/dist/chunk-THNHRV2B.js.map +1 -0
  46. package/dist/chunk-VSLGR64U.js +62 -0
  47. package/dist/chunk-VSLGR64U.js.map +1 -0
  48. package/dist/chunk-W2ZP674X.js +346 -0
  49. package/dist/chunk-W2ZP674X.js.map +1 -0
  50. package/dist/chunk-YWZUMUYE.js +695 -0
  51. package/dist/chunk-YWZUMUYE.js.map +1 -0
  52. package/dist/client-options-Uo6jXO8k.d.ts +64 -0
  53. package/dist/connection-state-Bk33YprE.d.ts +32 -0
  54. package/dist/core/bindings/index.d.ts +24 -0
  55. package/dist/core/bindings/index.js +1025 -0
  56. package/dist/core/bindings/index.js.map +1 -0
  57. package/dist/core/index.d.ts +383 -0
  58. package/dist/core/index.js +1284 -0
  59. package/dist/core/index.js.map +1 -0
  60. package/dist/createEphiaClient-BhdZ183V.d.ts +69 -0
  61. package/dist/devices/speechmike/index.d.ts +148 -0
  62. package/dist/devices/speechmike/index.js +40 -0
  63. package/dist/devices/speechmike/index.js.map +1 -0
  64. package/dist/headless/index.d.ts +10 -0
  65. package/dist/headless/index.js +25 -0
  66. package/dist/headless/index.js.map +1 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +119 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/react/index.d.ts +38 -0
  71. package/dist/react/index.js +70 -0
  72. package/dist/react/index.js.map +1 -0
  73. package/dist/rich-editor/index.d.ts +46 -0
  74. package/dist/rich-editor/index.js +13 -0
  75. package/dist/rich-editor/index.js.map +1 -0
  76. package/dist/schema-B2ycPlNB.d.ts +87 -0
  77. package/dist/session-APaXR48R.d.ts +12 -0
  78. package/dist/shared/index.d.ts +16 -0
  79. package/dist/shared/index.js +30 -0
  80. package/dist/shared/index.js.map +1 -0
  81. package/dist/style.css +1093 -0
  82. package/dist/testing/index.d.ts +84 -0
  83. package/dist/testing/index.js +36 -0
  84. package/dist/testing/index.js.map +1 -0
  85. package/dist/types-D5SXPSwR.d.ts +32 -0
  86. package/dist/ui/index.d.ts +30 -0
  87. package/dist/ui/index.js +34 -0
  88. package/dist/ui/index.js.map +1 -0
  89. package/dist/useEphiaSpeechMike-CjD7DWnh.d.ts +64 -0
  90. package/package.json +110 -0
  91. package/src/core/audio/audio-worklet-source.ts +30 -0
  92. package/src/core/audio/index.ts +3 -0
  93. package/src/core/audio/voice-level-meter.test.ts +27 -0
  94. package/src/core/audio/voice-level-meter.ts +270 -0
  95. package/src/core/bindings/EphiaBinding.ts +41 -0
  96. package/src/core/bindings/SegmentBindingBridge.test.ts +422 -0
  97. package/src/core/bindings/SegmentBindingBridge.ts +377 -0
  98. package/src/core/bindings/TargetBinding.ts +142 -0
  99. package/src/core/bindings/adapters/NativeAdapter.test.ts +85 -0
  100. package/src/core/bindings/adapters/NativeAdapter.ts +216 -0
  101. package/src/core/bindings/adapters/ProseMirrorAdapter.ts +231 -0
  102. package/src/core/bindings/adapters/index.ts +2 -0
  103. package/src/core/bindings/binding-factory.ts +78 -0
  104. package/src/core/bindings/detect-editor-type.ts +87 -0
  105. package/src/core/bindings/index.ts +13 -0
  106. package/src/core/bindings/insertion-boundary.test.ts +38 -0
  107. package/src/core/bindings/insertion-boundary.ts +26 -0
  108. package/src/core/bindings/native/NativeBinding.test.ts +277 -0
  109. package/src/core/bindings/native/NativeBinding.ts +239 -0
  110. package/src/core/bindings/resolver.ts +18 -0
  111. package/src/core/bindings/targets/codemirror.binding.ts +293 -0
  112. package/src/core/bindings/targets/contenteditable.binding.ts +452 -0
  113. package/src/core/bindings/targets/index.ts +10 -0
  114. package/src/core/bindings/targets/monaco.binding.ts +315 -0
  115. package/src/core/bindings/targets/tiptap.binding.test.ts +417 -0
  116. package/src/core/bindings/targets/tiptap.binding.ts +1192 -0
  117. package/src/core/bindings/tiptap/TiptapBinding.test.ts +63 -0
  118. package/src/core/bindings/tiptap/TiptapBinding.ts +464 -0
  119. package/src/core/bindings/types.ts +41 -0
  120. package/src/core/client/EphiaAudioClient.ts +654 -0
  121. package/src/core/client/audio-capture.ts +263 -0
  122. package/src/core/client/client-options.ts +39 -0
  123. package/src/core/client/client-state.ts +18 -0
  124. package/src/core/client/constants.ts +23 -0
  125. package/src/core/client/session-api.ts +415 -0
  126. package/src/core/connection/connection-state.ts +78 -0
  127. package/src/core/connection/index.ts +6 -0
  128. package/src/core/index.ts +47 -0
  129. package/src/core/operations/textToDocumentOperations.test.ts +69 -0
  130. package/src/core/operations/textToDocumentOperations.ts +92 -0
  131. package/src/core/runtime/DictationRuntime.test.ts +578 -0
  132. package/src/core/runtime/DictationRuntime.ts +434 -0
  133. package/src/core/runtime/TranscriptApplier.test.ts +355 -0
  134. package/src/core/runtime/TranscriptApplier.ts +229 -0
  135. package/src/core/runtime/index.ts +18 -0
  136. package/src/core/session/index.ts +2 -0
  137. package/src/core/session/session-machine.test.ts +16 -0
  138. package/src/core/session/session-machine.ts +59 -0
  139. package/src/core/targets/EditorContextCollector.ts +71 -0
  140. package/src/core/targets/TargetManager.test.ts +194 -0
  141. package/src/core/targets/TargetManager.ts +194 -0
  142. package/src/core/targets/index.ts +10 -0
  143. package/src/core/text-processing/index.ts +11 -0
  144. package/src/core/text-processing/overlap.test.ts +35 -0
  145. package/src/core/text-processing/overlap.ts +101 -0
  146. package/src/core/text-processing/voice-formatting.normalizer.test.ts +132 -0
  147. package/src/core/text-processing/voice-formatting.normalizer.ts +284 -0
  148. package/src/core/transcript/client-transcript.reducer.ts +366 -0
  149. package/src/core/transcript/client-transcript.state.ts +25 -0
  150. package/src/core/transcript/index.ts +19 -0
  151. package/src/core/transcript/transcript.assembler.test.ts +205 -0
  152. package/src/core/transcript/transcript.assembler.ts +152 -0
  153. package/src/core/transcript/transcript.reducer.test.ts +199 -0
  154. package/src/core/transcript/transcript.reducer.ts +771 -0
  155. package/src/core/transcript/transcript.state.ts +123 -0
  156. package/src/core/transport/LiveKitTransport.publish.test.ts +226 -0
  157. package/src/core/transport/LiveKitTransport.ts +459 -0
  158. package/src/core/transport/MockTransport.ts +231 -0
  159. package/src/core/transport/Transport.ts +82 -0
  160. package/src/debug/sdk-debug-collector.ts +79 -0
  161. package/src/devices/index.ts +2 -0
  162. package/src/devices/speechmike/__tests__/EphiaSpeechMikeProvider.test.tsx +99 -0
  163. package/src/devices/speechmike/__tests__/speechmike-audio-resolver.test.ts +96 -0
  164. package/src/devices/speechmike/__tests__/speechmike-button-router.test.ts +66 -0
  165. package/src/devices/speechmike/__tests__/speechmike-device-manager.test.ts +201 -0
  166. package/src/devices/speechmike/__tests__/speechmike-led-controller.test.ts +68 -0
  167. package/src/devices/speechmike/browser.ts +80 -0
  168. package/src/devices/speechmike/constants.ts +74 -0
  169. package/src/devices/speechmike/dictation-support-loader.ts +81 -0
  170. package/src/devices/speechmike/index.ts +11 -0
  171. package/src/devices/speechmike/react/EphiaSpeechMikeContext.ts +34 -0
  172. package/src/devices/speechmike/react/EphiaSpeechMikeProvider.tsx +287 -0
  173. package/src/devices/speechmike/react/useEphiaSpeechMike.ts +26 -0
  174. package/src/devices/speechmike/speechmike-audio-resolver.ts +58 -0
  175. package/src/devices/speechmike/speechmike-button-router.ts +73 -0
  176. package/src/devices/speechmike/speechmike-device-manager.ts +461 -0
  177. package/src/devices/speechmike/speechmike-led-controller.ts +78 -0
  178. package/src/devices/speechmike/types.ts +96 -0
  179. package/src/dictation_support.d.ts +31 -0
  180. package/src/global.d.ts +10 -0
  181. package/src/headless/createEphiaClient.ts +220 -0
  182. package/src/headless/index.ts +18 -0
  183. package/src/index.ts +89 -0
  184. package/src/react/EphiaAuto.tsx +87 -0
  185. package/src/react/components/EphiaDictationButton.tsx +88 -0
  186. package/src/react/components/EphiaStatusBar.tsx +59 -0
  187. package/src/react/components/EphiaTextarea.tsx +295 -0
  188. package/src/react/ephia-react.css +318 -0
  189. package/src/react/hooks/targets/index.ts +3 -0
  190. package/src/react/hooks/targets/useEphiaCodemirror.ts +35 -0
  191. package/src/react/hooks/targets/useEphiaMonaco.ts +35 -0
  192. package/src/react/hooks/targets/useEphiaTiptap.ts +23 -0
  193. package/src/react/hooks/useEphia.lifecycle.test.tsx +389 -0
  194. package/src/react/hooks/useEphia.ts +367 -0
  195. package/src/react/hooks/useEphiaDiscardTarget.ts +53 -0
  196. package/src/react/hooks/useEphiaServerEvent.ts +33 -0
  197. package/src/react/hooks/useEphiaTarget.ts +47 -0
  198. package/src/react/hooks/useEphiaTranscript.ts +22 -0
  199. package/src/react/index.ts +58 -0
  200. package/src/react/provider/EphiaContext.ts +63 -0
  201. package/src/react/provider/EphiaInternalContext.ts +32 -0
  202. package/src/react/provider/EphiaProvider.tsx +373 -0
  203. package/src/react/registry/binding-factory.ts +7 -0
  204. package/src/react/registry/detect-editor-type.ts +2 -0
  205. package/src/react/registry/events.ts +37 -0
  206. package/src/react/registry/registries/CodeMirrorInstanceRegistry.ts +24 -0
  207. package/src/react/registry/registries/MonacoInstanceRegistry.ts +23 -0
  208. package/src/react/registry/registries/TargetRegistry.ts +327 -0
  209. package/src/react/registry/registries/TiptapInstanceRegistry.ts +43 -0
  210. package/src/react/registry/registries/index.ts +5 -0
  211. package/src/react/store/create-ephia-store.ts +36 -0
  212. package/src/react/store/types.ts +41 -0
  213. package/src/react/utils/flash-range.ts +24 -0
  214. package/src/react/utils/index.ts +1 -0
  215. package/src/rich-editor/adapters/tiptap.test.ts +86 -0
  216. package/src/rich-editor/adapters/tiptap.ts +23 -0
  217. package/src/rich-editor/index.ts +3 -0
  218. package/src/rich-editor/types.ts +24 -0
  219. package/src/rich-editor/use-ephia-rich-editor.test.tsx +202 -0
  220. package/src/rich-editor/use-ephia-rich-editor.ts +47 -0
  221. package/src/shared/config/endpoint.test.ts +45 -0
  222. package/src/shared/config/endpoint.ts +39 -0
  223. package/src/shared/config/schema.ts +32 -0
  224. package/src/shared/effective-text.ts +13 -0
  225. package/src/shared/errors/EphiaSdkError.ts +54 -0
  226. package/src/shared/errors/messages.ts +40 -0
  227. package/src/shared/index.ts +27 -0
  228. package/src/shared/state/audio-state.ts +45 -0
  229. package/src/shared/state/index.ts +2 -0
  230. package/src/shared/store/document-store.ts +32 -0
  231. package/src/shared/store/index.ts +2 -0
  232. package/src/shared/types/editors.ts +28 -0
  233. package/src/shared/types/session.ts +12 -0
  234. package/src/style.css +2 -0
  235. package/src/testing/index.tsx +60 -0
  236. package/src/ui/assets/ephia-logo.svg +4 -0
  237. package/src/ui/components/EphiaLogo.tsx +77 -0
  238. package/src/ui/index.ts +24 -0
  239. package/src/ui/primitives/Button.tsx +53 -0
  240. package/src/ui/primitives/Spinner.tsx +21 -0
  241. package/src/ui/primitives/index.ts +5 -0
  242. package/src/ui/recorder/EphiaFloatingButton.tsx +489 -0
  243. package/src/ui/recorder/MinimalProcessingBars.tsx +122 -0
  244. package/src/ui/recorder/StandardIntensityVisualizer.tsx +148 -0
  245. package/src/ui/recorder/appearance.ts +9 -0
  246. package/src/ui/recorder/index.ts +8 -0
  247. package/src/ui/theme.css +775 -0
@@ -0,0 +1,152 @@
1
+ import type { LiveTranscriptChunk } from "./transcript.state";
2
+
3
+ function trimBoundarySpaces(text: string): string {
4
+ return text
5
+ .replace(/\r\n/g, "\n")
6
+ .replace(/\r/g, "\n")
7
+ .replace(/^[ \t]+|[ \t]+$/g, "");
8
+ }
9
+
10
+ /**
11
+ * Joint deux morceaux de texte avec gestion intelligente des espaces et de la ponctuation.
12
+ * Exported pour être réutilisé dans les autres reducers.
13
+ */
14
+ export function joinWithSpacing(prev: string, next: string): string {
15
+ if (!prev) return next;
16
+ if (!next) return prev;
17
+ const lastChar = prev.slice(-1);
18
+ const firstChar = next.charAt(0);
19
+ if (lastChar === " " && firstChar === " ") return prev + next.trimStart();
20
+ if (/\s/.test(lastChar) || /\s/.test(firstChar)) return prev + next;
21
+ if (/[,.;:!?)\]]/.test(firstChar)) return prev + next;
22
+ return prev + " " + next;
23
+ }
24
+
25
+ /**
26
+ * Assemble les chunks en texte cohérent.
27
+ *
28
+ * Règles :
29
+ * - tri par segmentSeq
30
+ * - pas de double espace
31
+ * - espace après ponctuation si manquant
32
+ * - ignorer les chunks error sans texte
33
+ */
34
+ export function assembleTranscript(chunks: LiveTranscriptChunk[]): string {
35
+ const sorted = [...chunks]
36
+ .filter((c) => (c.status === "done" || c.finalizedText) && (c.text || c.finalizedText))
37
+ .sort((a, b) => a.segmentSeq - b.segmentSeq);
38
+
39
+ if (sorted.length === 0) return "";
40
+
41
+ let result = "";
42
+ for (let i = 0; i < sorted.length; i++) {
43
+ const chunk = sorted[i];
44
+ let text = trimBoundarySpaces(chunk.text || chunk.finalizedText || "");
45
+
46
+ if (i === 0) {
47
+ result = text;
48
+ continue;
49
+ }
50
+
51
+ const prev = result;
52
+ const lastChar = prev.slice(-1);
53
+ const firstChar = text.charAt(0);
54
+
55
+ // Règles d'espacement
56
+ if (lastChar.match(/[.!?;,:\)]/) && firstChar.match(/[a-zA-ZÀ-ÿ0-9]/)) {
57
+ result = prev + " " + text;
58
+ } else if (/\s/.test(lastChar) && /\s/.test(firstChar)) {
59
+ result = prev + text.trimStart();
60
+ } else if (
61
+ !/\s/.test(lastChar) &&
62
+ !/\s/.test(firstChar) &&
63
+ firstChar.match(/[a-zA-ZÀ-ÿ0-9]/)
64
+ ) {
65
+ result = prev + " " + text;
66
+ } else {
67
+ result = prev + text;
68
+ }
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * Assemble les chunks commités en document texte.
76
+ *
77
+ * Règles :
78
+ * - tri par segmentSeq
79
+ * - utilise committedText ou text si disponible
80
+ * - ignore les chunks fusionnés (mergedWith présent)
81
+ */
82
+ export function assembleCommittedDocument(
83
+ chunks: Record<string, LiveTranscriptChunk>,
84
+ orderedSegmentIds: string[]
85
+ ): string {
86
+ const sorted = [...orderedSegmentIds]
87
+ .map((id) => chunks[id])
88
+ .filter((c): c is LiveTranscriptChunk => !!c && c.status === "done")
89
+ .filter((c) => !c.mergedWith || Array.isArray(c.mergedWith))
90
+ .sort((a, b) => a.segmentSeq - b.segmentSeq);
91
+
92
+ if (sorted.length === 0) return "";
93
+
94
+ let result = "";
95
+ for (let i = 0; i < sorted.length; i++) {
96
+ const chunk = sorted[i];
97
+ const text = trimBoundarySpaces(chunk.committedText ?? chunk.text ?? "");
98
+ if (!text) continue;
99
+
100
+ if (i === 0) {
101
+ result = text;
102
+ continue;
103
+ }
104
+
105
+ const prev = result;
106
+ const lastChar = prev.slice(-1);
107
+ const firstChar = text.charAt(0);
108
+
109
+ if (lastChar.match(/[.!?;,:\)]/) && firstChar.match(/[a-zA-ZÀ-ÿ0-9]/)) {
110
+ result = prev + " " + text;
111
+ } else if (/\s/.test(lastChar) && /\s/.test(firstChar)) {
112
+ result = prev + text.trimStart();
113
+ } else if (
114
+ !/\s/.test(lastChar) &&
115
+ !/\s/.test(firstChar) &&
116
+ firstChar.match(/[a-zA-ZÀ-ÿ0-9]/)
117
+ ) {
118
+ result = prev + " " + text;
119
+ } else {
120
+ result = prev + text;
121
+ }
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ /**
128
+ * Construit le texte d'affichage complet :
129
+ * finalText + previewText du chunk courant.
130
+ */
131
+ const PUNCTUATION_NO_SPACE = new Set([",", ".", ";", ":", "!", "?", ")", "]", "}"]);
132
+
133
+ export function buildDisplayText(
134
+ finalText: string,
135
+ previewText: string,
136
+ previewContinuesPreviousWord = false
137
+ ): string {
138
+ if (!finalText && !previewText) return "";
139
+ if (!finalText) return previewText;
140
+ if (!previewText) return finalText;
141
+ // trimEnd() supprimerait les \n intentionnels (commandes "à la ligne")
142
+ const prev = finalText.replace(/[ \t]+$/, "");
143
+ const next = previewText.trimStart();
144
+ if (!next) return prev;
145
+ if (previewContinuesPreviousWord) return prev + next;
146
+ const firstChar = next[0];
147
+ if (firstChar && PUNCTUATION_NO_SPACE.has(firstChar)) {
148
+ return prev + next;
149
+ }
150
+ if (prev.endsWith("\n")) return prev + next;
151
+ return prev + " " + next;
152
+ }
@@ -0,0 +1,199 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { liveTranscriptReducer } from "./transcript.reducer";
3
+ import { initialLiveTranscriptState } from "./transcript.state";
4
+ import type { LiveTranscriptAction } from "./transcript.reducer";
5
+ import { PROTOCOL_VERSION, type EphiaAudioEvent } from "ephia-protocol";
6
+
7
+ function makeEnvelope<T extends string>(
8
+ type: T,
9
+ payload: Record<string, unknown>,
10
+ seq = 1
11
+ ): EphiaAudioEvent {
12
+ return {
13
+ protocolVersion: PROTOCOL_VERSION,
14
+ eventId: `00000000-0000-4000-8000-${String(seq).padStart(12, "0")}`,
15
+ sessionId: "test-session",
16
+ type,
17
+ seq,
18
+ timestampMs: Date.now(),
19
+ payload,
20
+ } as unknown as EphiaAudioEvent;
21
+ }
22
+
23
+ function event(type: string, payload: Record<string, unknown>, seq = 1): LiveTranscriptAction {
24
+ return { type: "event", event: makeEnvelope(type, payload, seq) };
25
+ }
26
+
27
+ // ------------------------------------------------------------------
28
+ // segment.audio.closed — conserver le preview jusqu'au committed
29
+ // ------------------------------------------------------------------
30
+
31
+ describe("segment.audio.closed", () => {
32
+ it("keeps preview text visible while waiting for committed", () => {
33
+ let state = initialLiveTranscriptState;
34
+
35
+ state = liveTranscriptReducer(
36
+ state,
37
+ event("segment.opened", { segmentId: "seg1", segmentSeq: 1, startMs: 0 }, 1)
38
+ );
39
+ state = liveTranscriptReducer(
40
+ state,
41
+ event(
42
+ "transcript.preview",
43
+ { segmentId: "seg1", segmentSeq: 1, text: "Bonjour docteur", revision: 1 },
44
+ 2
45
+ )
46
+ );
47
+
48
+ state = liveTranscriptReducer(
49
+ state,
50
+ event(
51
+ "segment.audio.closed",
52
+ { segmentId: "seg1", segmentSeq: 1, endMs: 1000, reason: "vad_end" },
53
+ 3
54
+ )
55
+ );
56
+
57
+ expect(state.chunks["seg1"]?.previewText).toBe("Bonjour docteur");
58
+ expect(state.chunks["seg1"]?.status).toBe("processing");
59
+ expect(state.displayText).toContain("Bonjour docteur");
60
+ });
61
+
62
+ it("enriches preview after audio closed when a later partial arrives", () => {
63
+ let state = initialLiveTranscriptState;
64
+
65
+ state = liveTranscriptReducer(
66
+ state,
67
+ event("segment.opened", { segmentId: "seg1", segmentSeq: 1, startMs: 0 }, 1)
68
+ );
69
+ state = liveTranscriptReducer(
70
+ state,
71
+ event(
72
+ "transcript.preview",
73
+ { segmentId: "seg1", segmentSeq: 1, text: "Bonjour", revision: 1 },
74
+ 2
75
+ )
76
+ );
77
+ state = liveTranscriptReducer(
78
+ state,
79
+ event(
80
+ "segment.audio.closed",
81
+ { segmentId: "seg1", segmentSeq: 1, endMs: 1000, reason: "vad_end" },
82
+ 3
83
+ )
84
+ );
85
+ state = liveTranscriptReducer(
86
+ state,
87
+ event(
88
+ "transcript.preview",
89
+ { segmentId: "seg1", segmentSeq: 1, text: "Bonjour docteur", revision: 6 },
90
+ 4
91
+ )
92
+ );
93
+
94
+ expect(state.chunks["seg1"]?.status).toBe("preview");
95
+ expect(state.chunks["seg1"]?.previewText).toBe("Bonjour docteur");
96
+ expect(state.chunks["seg1"]?.revision).toBe(6);
97
+ expect(state.displayText).toContain("Bonjour docteur");
98
+ expect(state.orderedSegmentIds).toEqual(["seg1"]);
99
+ });
100
+ });
101
+
102
+ // ------------------------------------------------------------------
103
+ // Replace Model: partial ignoré après committed
104
+ // ------------------------------------------------------------------
105
+
106
+ describe("Replace Model — partial after committed", () => {
107
+ it("does not overwrite committed text when a late partial arrives", () => {
108
+ let state = initialLiveTranscriptState;
109
+
110
+ // Segment opened
111
+ state = liveTranscriptReducer(state, event("segment.opened", { segmentId: "seg1", segmentSeq: 1, startMs: 0 }, 1));
112
+ // Committed
113
+ state = liveTranscriptReducer(state, event("transcript.segment.committed", {
114
+ segmentId: "seg1",
115
+ segmentSeq: 1,
116
+ text: "Texte final commité.",
117
+ source: "live",
118
+ }, 2));
119
+
120
+ expect(state.chunks["seg1"]?.status).toBe("done");
121
+ expect(state.chunks["seg1"]?.text).toBe("Texte final commité.");
122
+
123
+ // Late partial — status is already "done", partial should be ignored
124
+ state = liveTranscriptReducer(state, event("transcript.preview", {
125
+ segmentId: "seg1",
126
+ segmentSeq: 1,
127
+ text: "texte partiel tardif",
128
+ revision: 99,
129
+ }, 3));
130
+
131
+ expect(state.chunks["seg1"]?.text).toBe("Texte final commité.");
132
+ expect(state.chunks["seg1"]?.previewText).toBeUndefined();
133
+ });
134
+ });
135
+
136
+ // ------------------------------------------------------------------
137
+ // session.context.reset — conservation des chunks finalisés
138
+ // ------------------------------------------------------------------
139
+
140
+ describe("session.context.reset", () => {
141
+ it("keeps finalized chunks without committedText on contextOnly reset", () => {
142
+ let state = initialLiveTranscriptState;
143
+
144
+ // Simulate a finalized segment arriving via transcript.final
145
+ // (no committedText set, only status="done" + text)
146
+ state = liveTranscriptReducer(
147
+ state,
148
+ event("transcript.final", {
149
+ segmentId: "seg-final",
150
+ segmentSeq: 1,
151
+ text: "Texte finalisé sans committedText.",
152
+ provider: "voxtral-small",
153
+ }, 1)
154
+ );
155
+
156
+ expect(state.chunks["seg-final"]?.status).toBe("done");
157
+ expect(state.chunks["seg-final"]?.committedText).toBeUndefined();
158
+
159
+ // Context-only reset (emitted by the backend when the client stops)
160
+ state = liveTranscriptReducer(
161
+ state,
162
+ event("session.context.reset", { contextOnly: true }, 2)
163
+ );
164
+
165
+ // The finalized chunk must survive the reset
166
+ expect(state.chunks["seg-final"]).toBeDefined();
167
+ expect(state.chunks["seg-final"]?.status).toBe("done");
168
+ expect(state.chunks["seg-final"]?.text).toBe("Texte finalisé sans committedText.");
169
+ expect(state.orderedSegmentIds).toContain("seg-final");
170
+ expect(state.documentText).toContain("Texte finalisé sans committedText.");
171
+ });
172
+ });
173
+
174
+ // ------------------------------------------------------------------
175
+ // Sequence gap detection
176
+ // ------------------------------------------------------------------
177
+
178
+ describe("sequence gap detection", () => {
179
+ it("marks seqValid false when events arrive out of order", () => {
180
+ let state = initialLiveTranscriptState;
181
+
182
+ state = liveTranscriptReducer(state, event("session.ready", { roomName: "room1" }, 1));
183
+ expect(state.diagnostics.seqValid).toBe(true);
184
+
185
+ // Skip seq 2, jump to 3
186
+ state = liveTranscriptReducer(state, event("audio.vad.start", { timestampMs: 0 }, 3));
187
+ expect(state.diagnostics.seqValid).toBe(false);
188
+ });
189
+
190
+ it("keeps seqValid true for monotone consecutive events", () => {
191
+ let state = initialLiveTranscriptState;
192
+
193
+ state = liveTranscriptReducer(state, event("session.ready", { roomName: "room1" }, 1));
194
+ state = liveTranscriptReducer(state, event("audio.vad.start", { timestampMs: 0 }, 2));
195
+ state = liveTranscriptReducer(state, event("audio.vad.end", { timestampMs: 100, durationMs: 100 }, 3));
196
+
197
+ expect(state.diagnostics.seqValid).toBe(true);
198
+ });
199
+ });