@chat21/chat21-web-widget 5.1.34 → 5.2.1

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 (191) hide show
  1. package/.angular-mcp-cache/package.json +1 -0
  2. package/.cursor/angular18-accessibility-auditor-skill.md +442 -0
  3. package/.cursor/mcp.json +15 -0
  4. package/.github/workflows/playwright.yml +27 -0
  5. package/CHANGELOG.md +25 -0
  6. package/Dockerfile +4 -5
  7. package/README.md +1 -1
  8. package/angular.json +21 -3
  9. package/docs/ACCESSIBILITY-STATEMENT.md +388 -0
  10. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_ALIGNMENT.md +60 -0
  11. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +386 -0
  12. package/env.sample +3 -2
  13. package/mocks/voice-websocket-mock/server.cjs +245 -0
  14. package/package.json +10 -3
  15. package/playwright.config.ts +41 -0
  16. package/src/app/app.component.html +2 -2
  17. package/src/app/app.component.scss +25 -14
  18. package/src/app/app.component.spec.ts +21 -6
  19. package/src/app/app.module.ts +13 -0
  20. package/src/app/component/conversation-detail/conversation/conversation.component.html +25 -11
  21. package/src/app/component/conversation-detail/conversation/conversation.component.scss +38 -0
  22. package/src/app/component/conversation-detail/conversation/conversation.component.spec.ts +644 -75
  23. package/src/app/component/conversation-detail/conversation/conversation.component.ts +70 -2
  24. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.html +25 -13
  25. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.spec.ts +123 -5
  26. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.ts +1 -0
  27. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +23 -10
  28. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +18 -0
  29. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +241 -149
  30. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +8 -5
  31. package/src/app/component/conversation-detail/conversation-emojii/conversation-emojii.component.spec.ts +53 -3
  32. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +203 -110
  33. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +212 -1
  34. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +458 -78
  35. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +288 -76
  36. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.html +113 -53
  37. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.scss +12 -4
  38. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.spec.ts +274 -29
  39. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.html +23 -9
  40. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.spec.ts +80 -8
  41. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.html +29 -23
  42. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.spec.ts +185 -16
  43. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.ts +34 -14
  44. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +46 -0
  45. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +83 -0
  46. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +192 -0
  47. package/src/app/component/error-alert/error-alert.component.spec.ts +65 -5
  48. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.html +16 -7
  49. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.scss +21 -0
  50. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.spec.ts +89 -7
  51. package/src/app/component/form/form-builder/form-builder.component.html +1 -1
  52. package/src/app/component/form/form-builder/form-builder.component.spec.ts +163 -21
  53. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.html +8 -4
  54. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.scss +10 -5
  55. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.spec.ts +90 -16
  56. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.ts +26 -0
  57. package/src/app/component/form/inputs/form-label/form-label.component.spec.ts +45 -11
  58. package/src/app/component/form/inputs/form-radio-button/form-radio-button.component.spec.ts +24 -6
  59. package/src/app/component/form/inputs/form-select/form-select.component.spec.ts +14 -5
  60. package/src/app/component/form/inputs/form-text/form-text.component.html +14 -12
  61. package/src/app/component/form/inputs/form-text/form-text.component.scss +11 -1
  62. package/src/app/component/form/inputs/form-text/form-text.component.spec.ts +113 -17
  63. package/src/app/component/form/inputs/form-text/form-text.component.ts +35 -3
  64. package/src/app/component/form/inputs/form-textarea/form-textarea.component.html +13 -11
  65. package/src/app/component/form/inputs/form-textarea/form-textarea.component.scss +6 -5
  66. package/src/app/component/form/inputs/form-textarea/form-textarea.component.spec.ts +149 -13
  67. package/src/app/component/form/inputs/form-textarea/form-textarea.component.ts +26 -0
  68. package/src/app/component/form/prechat-form/prechat-form.component.html +14 -11
  69. package/src/app/component/form/prechat-form/prechat-form.component.spec.ts +102 -10
  70. package/src/app/component/form/prechat-form/prechat-form.component.ts +8 -1
  71. package/src/app/component/form/prechat-form-test-mock.ts +35 -0
  72. package/src/app/component/home/home.component.html +38 -31
  73. package/src/app/component/home/home.component.scss +4 -2
  74. package/src/app/component/home/home.component.spec.ts +226 -11
  75. package/src/app/component/home-conversations/home-conversations.component.html +30 -26
  76. package/src/app/component/home-conversations/home-conversations.component.scss +3 -0
  77. package/src/app/component/home-conversations/home-conversations.component.spec.ts +212 -36
  78. package/src/app/component/last-message/last-message.component.html +15 -9
  79. package/src/app/component/last-message/last-message.component.scss +16 -2
  80. package/src/app/component/last-message/last-message.component.spec.ts +204 -23
  81. package/src/app/component/launcher-button/launcher-button.component.html +8 -13
  82. package/src/app/component/launcher-button/launcher-button.component.spec.ts +104 -8
  83. package/src/app/component/list-all-conversations/list-all-conversations.component.html +12 -17
  84. package/src/app/component/list-all-conversations/list-all-conversations.component.scss +2 -0
  85. package/src/app/component/list-conversations/list-conversations.component.html +22 -22
  86. package/src/app/component/menu-options/menu-options.component.html +30 -20
  87. package/src/app/component/menu-options/menu-options.component.spec.ts +125 -9
  88. package/src/app/component/message/audio/audio.component.html +13 -15
  89. package/src/app/component/message/audio/audio.component.spec.ts +140 -5
  90. package/src/app/component/message/audio/audio.component.ts +1 -5
  91. package/src/app/component/message/audio-sync/audio-sync.component.html +18 -0
  92. package/src/app/component/message/audio-sync/audio-sync.component.scss +65 -0
  93. package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +112 -0
  94. package/src/app/component/message/audio-sync/audio-sync.component.ts +714 -0
  95. package/src/app/component/message/avatar/avatar.component.html +2 -2
  96. package/src/app/component/message/avatar/avatar.component.spec.ts +99 -7
  97. package/src/app/component/message/bubble-message/bubble-message.component.html +41 -51
  98. package/src/app/component/message/bubble-message/bubble-message.component.scss +54 -1
  99. package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +147 -57
  100. package/src/app/component/message/bubble-message/bubble-message.component.ts +95 -13
  101. package/src/app/component/message/buttons/action-button/action-button.component.html +3 -4
  102. package/src/app/component/message/buttons/action-button/action-button.component.spec.ts +49 -5
  103. package/src/app/component/message/buttons/link-button/link-button.component.scss +5 -8
  104. package/src/app/component/message/buttons/link-button/link-button.component.spec.ts +50 -5
  105. package/src/app/component/message/buttons/text-button/text-button.component.spec.ts +44 -5
  106. package/src/app/component/message/carousel/carousel.component.html +29 -16
  107. package/src/app/component/message/carousel/carousel.component.scss +20 -8
  108. package/src/app/component/message/carousel/carousel.component.spec.ts +80 -3
  109. package/src/app/component/message/carousel/carousel.component.ts +16 -0
  110. package/src/app/component/message/frame/frame.component.html +9 -4
  111. package/src/app/component/message/frame/frame.component.spec.ts +34 -15
  112. package/src/app/component/message/frame/frame.component.ts +7 -2
  113. package/src/app/component/message/html/html.component.html +1 -1
  114. package/src/app/component/message/html/html.component.scss +1 -1
  115. package/src/app/component/message/html/html.component.spec.ts +24 -7
  116. package/src/app/component/message/image/image.component.html +12 -10
  117. package/src/app/component/message/image/image.component.scss +16 -0
  118. package/src/app/component/message/image/image.component.spec.ts +101 -15
  119. package/src/app/component/message/image/image.component.ts +90 -51
  120. package/src/app/component/message/info-message/info-message.component.spec.ts +26 -14
  121. package/src/app/component/message/json-sources/json-sources.component.html +6 -5
  122. package/src/app/component/message/json-sources/json-sources.component.scss +26 -18
  123. package/src/app/component/message/json-sources/json-sources.component.ts +41 -0
  124. package/src/app/component/message/like-unlike/like-unlike.component.html +7 -9
  125. package/src/app/component/message/like-unlike/like-unlike.component.spec.ts +31 -3
  126. package/src/app/component/message/return-receipt/return-receipt.component.spec.ts +38 -17
  127. package/src/app/component/message/text/text.component.html +3 -3
  128. package/src/app/component/message/text/text.component.scss +80 -86
  129. package/src/app/component/message/text/text.component.spec.ts +106 -13
  130. package/src/app/component/message-attachment/message-attachment.component.spec.ts +134 -13
  131. package/src/app/component/selection-department/selection-department.component.html +21 -23
  132. package/src/app/component/selection-department/selection-department.component.spec.ts +159 -14
  133. package/src/app/component/selection-department/selection-department.component.ts +8 -1
  134. package/src/app/component/send-button/send-button.component.html +5 -13
  135. package/src/app/component/send-button/send-button.component.spec.ts +2 -2
  136. package/src/app/component/star-rating-widget/star-rating-widget.component.html +51 -81
  137. package/src/app/directives/tooltip.directive.spec.ts +8 -4
  138. package/src/app/modals/confirm-close/confirm-close.component.html +20 -8
  139. package/src/app/modals/confirm-close/confirm-close.component.scss +3 -0
  140. package/src/app/modals/confirm-close/confirm-close.component.spec.ts +13 -4
  141. package/src/app/modals/confirm-close/confirm-close.component.ts +8 -1
  142. package/src/app/pipe/html-entites-encode.pipe.spec.ts +35 -2
  143. package/src/app/pipe/marked.pipe.spec.ts +38 -2
  144. package/src/app/pipe/marked.pipe.ts +51 -41
  145. package/src/app/providers/app-config.service.ts +4 -2
  146. package/src/app/providers/brand.service.spec.ts +23 -2
  147. package/src/app/providers/brand.service.ts +1 -1
  148. package/src/app/providers/global-settings.service.spec.ts +1009 -14
  149. package/src/app/providers/global-settings.service.ts +40 -2
  150. package/src/app/providers/json-sources-parser.service.ts +13 -1
  151. package/src/app/providers/translator.service.ts +24 -7
  152. package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +116 -0
  153. package/src/app/providers/tts-audio-playback-coordinator.service.ts +122 -0
  154. package/src/app/providers/voice/STT&TTS/openai-voice.config.ts +12 -0
  155. package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +156 -0
  156. package/src/app/providers/voice/STT&TTS/speech-provider.abstract.ts +39 -0
  157. package/src/app/providers/voice/audio.types.ts +40 -0
  158. package/src/app/providers/voice/vad.service.spec.ts +28 -0
  159. package/src/app/providers/voice/vad.service.ts +70 -0
  160. package/src/app/providers/voice/voice-streaming.service.spec.ts +23 -0
  161. package/src/app/providers/voice/voice-streaming.service.ts +702 -0
  162. package/src/app/providers/voice/voice-streaming.types.ts +112 -0
  163. package/src/app/providers/voice/voice.service.spec.ts +227 -0
  164. package/src/app/providers/voice/voice.service.ts +969 -0
  165. package/src/app/sass/_variables.scss +2 -0
  166. package/src/app/sass/animations.scss +19 -1
  167. package/src/app/shims/onnxruntime-web-wasm.ts +4 -0
  168. package/src/app/utils/globals.ts +14 -0
  169. package/src/app/utils/utils-resources.ts +1 -1
  170. package/src/assets/i18n/en.json +128 -100
  171. package/src/assets/i18n/es.json +128 -100
  172. package/src/assets/i18n/fr.json +128 -100
  173. package/src/assets/i18n/it.json +128 -98
  174. package/src/assets/onnx/ort-wasm-simd-threaded.mjs +59 -0
  175. package/src/assets/onnx/ort-wasm-simd-threaded.wasm +0 -0
  176. package/src/assets/sounds/keyboard.mp3 +0 -0
  177. package/src/assets/vad/silero_vad_legacy.onnx +0 -0
  178. package/src/assets/vad/vad.worklet.bundle.min.js +1 -0
  179. package/src/chat21-core/models/message.ts +2 -1
  180. package/src/chat21-core/providers/chat-manager.spec.ts +72 -0
  181. package/src/chat21-core/providers/firebase/firebase-conversation-handler.ts +3 -2
  182. package/src/chat21-core/providers/firebase/firebase-init-service.ts +5 -5
  183. package/src/chat21-core/providers/mqtt/mqtt-conversation-handler.ts +12 -0
  184. package/src/chat21-core/providers/scripts/script.service.spec.ts +12 -2
  185. package/src/chat21-core/utils/utils-message.ts +7 -0
  186. package/src/widget-config-template.json +3 -1
  187. package/src/widget-config.json +28 -27
  188. package/tests/widget-form-rich.spec.ts +67 -0
  189. package/tests/widget-index-dev-settings.spec.ts +52 -0
  190. package/tests/widget-twp-iframe.spec.ts +39 -0
  191. package/tsconfig.json +5 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Configurazione sessione WSS /ws/voice.
3
+ *
4
+ * L'URL di connessione porta solo: token, mimeType, sttProvider, ttsProvider (ADR-002).
5
+ * I campi di identità di sessione viaggiano nel config frame JSON inviato subito dopo onopen.
6
+ *
7
+ * Il config frame fonde i campi di routing Chat21 (`sender`, `recipient`, `lang`) con la struttura
8
+ * prodotta da `chat21client.js#sendMessage` (`text`, `type`, `recipient_fullname`, `sender_fullname`,
9
+ * `attributes`, `metadata`, `channel_type`), così il proxy riceve lo stesso payload di un normale
10
+ * messaggio Chat21.
11
+ */
12
+ export interface VoiceStreamingSessionConfig {
13
+ /** JWT auth token — finisce in `?token=` nell'URL. */
14
+ token: string;
15
+ /** Chat21 userId — campo `sender` del config frame. */
16
+ sender: string;
17
+ /** Chat21 conversationId, es. `support-group-<projectId>-<requestId>` — campo `recipient` del config frame. */
18
+ recipient: string;
19
+ /** Codice lingua BCP-47, default `'en'` — campo `lang` del config frame. */
20
+ lang?: string;
21
+ sttProvider?: string;
22
+ ttsProvider?: string;
23
+ /**
24
+ * Base URL del WebSocket *senza* query, incluso path.
25
+ * Esempio: `wss://proxy.example.com/ws/voice` o `ws://127.0.0.1:4587/ws/voice` (mock locale)
26
+ * Se assente, si usa `voiceProxyWsBaseUrl` dal widget config caricato con `AppConfigService.getConfig()`.
27
+ */
28
+ wsBaseUrl?: string;
29
+ /** Default 1000 — intervallo `MediaRecorder.start(timeslice)` in ms */
30
+ timesliceMs?: number;
31
+ /** Se valorizzato, ha precedenza sulle euristiche (es. `audio/webm;codecs=opus`) */
32
+ mimeType?: string;
33
+
34
+ // ── Campi sendMessage (chat21client.js#sendMessage outgoing_message) ──────────────────────────
35
+ /** Testo del messaggio — default `""` per il config frame. Corrisponde a `text` in `sendMessage`. */
36
+ text?: string;
37
+ /** Tipo del messaggio — default `"text"`. Corrisponde a `type` in `sendMessage`. */
38
+ type?: string;
39
+ /** Nome completo del destinatario. Corrisponde a `recipient_fullname` in `sendMessage`. */
40
+ recipient_fullname?: string;
41
+ /** Nome completo del mittente. Corrisponde a `sender_fullname` in `sendMessage`. */
42
+ sender_fullname?: string;
43
+ /** Attributi del messaggio (es. lingua, info utente). Corrisponde a `attributes` in `sendMessage`. */
44
+ attributes?: Record<string, unknown>;
45
+ /** Metadata del messaggio — default `""`. Corrisponde a `metadata` in `sendMessage`. */
46
+ metadata?: unknown;
47
+ /** Tipo di canale (es. `"direct"`). Corrisponde a `channel_type` in `sendMessage`. */
48
+ channel_type?: string;
49
+ }
50
+
51
+ export type VoiceStreamingConnectionState =
52
+ | 'idle'
53
+ | 'connecting'
54
+ | 'open'
55
+ | 'streaming'
56
+ | 'stopping'
57
+ | 'closed'
58
+ | 'error';
59
+
60
+ export interface VoiceStreamingServerMessage {
61
+ /** Originale: text JSON o testo, binary come ArrayBuffer */
62
+ data: string | ArrayBuffer;
63
+ isBinary: boolean;
64
+ }
65
+
66
+ export interface VoiceStreamingStopOptions {
67
+ discard?: boolean;
68
+ /** Dopo `MediaRecorder.stop`, attende un messaggio testuale dal server con l’URL (JSON `url` / `audioUrl` / …) prima di chiudere il socket */
69
+ awaitServerResultUrl?: boolean;
70
+ serverResultTimeoutMs?: number;
71
+ }
72
+
73
+ export interface VoiceStreamingStopResult {
74
+ blob: Blob | null;
75
+ mimeType: string;
76
+ /** Estratto dal messaggio testuale del server al termine dello stream, se `awaitServerResultUrl` e protocollo coerente */
77
+ resultUrl: string | null;
78
+ }
79
+
80
+ /** Messaggio di controllo JSON dal proxy voce (`msg.event`). */
81
+ export type VoiceWsServerEventName =
82
+ | 'ready'
83
+ | 'session_started'
84
+ | 'listening'
85
+ | 'transcript'
86
+ | 'thinking'
87
+ | 'speaking'
88
+ | 'done'
89
+ | 'error';
90
+
91
+ /** Messaggio di controllo JSON dal proxy (`msg.event`); altri campi sono ignorati se non gestiti. */
92
+ export type VoiceWsControlMessage = {
93
+ event: VoiceWsServerEventName;
94
+ requestId?: string;
95
+ text?: string;
96
+ isFinal?: boolean;
97
+ message?: string;
98
+ } & Record<string, unknown>;
99
+
100
+ /** Single word with its karaoke highlight state. */
101
+ export interface VoiceTtsKaraokeWord {
102
+ text: string;
103
+ state: 'future' | 'active' | 'past';
104
+ }
105
+
106
+ /** Emitted on each word transition while TTS plays over WebSocket. */
107
+ export interface VoiceTtsKaraokeFrame {
108
+ /** Full text of the utterance being spoken. */
109
+ text: string;
110
+ words: ReadonlyArray<VoiceTtsKaraokeWord>;
111
+ activeIndex: number;
112
+ }
@@ -0,0 +1,227 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+ import { Subject } from 'rxjs';
3
+ import { NGXLogger } from 'ngx-logger';
4
+
5
+ import { VoiceService } from './voice.service';
6
+ import { VadService } from './vad.service';
7
+ import { VoiceStreamingService } from './voice-streaming.service';
8
+ import { TtsAudioPlaybackCoordinator } from '../tts-audio-playback-coordinator.service';
9
+ import { VoiceWsControlMessage } from './voice-streaming.types';
10
+ import { LoggerInstance } from 'src/chat21-core/providers/logger/loggerInstance';
11
+ import { CustomLogger } from 'src/chat21-core/providers/logger/customLogger';
12
+
13
+ /** MediaStream with at least one audio track (required by initAudioAnalyser). */
14
+ function createAudioMediaStream(): MediaStream {
15
+ const ctx = new AudioContext();
16
+ return ctx.createMediaStreamDestination().stream;
17
+ }
18
+
19
+ describe('VoiceService', () => {
20
+ let service: VoiceService;
21
+ let vadService: jasmine.SpyObj<VadService>;
22
+ let wsControl$: Subject<VoiceWsControlMessage>;
23
+ let ttsBinaryChunk$: Subject<ArrayBuffer>;
24
+ let voiceStreamingMock: jasmine.SpyObj<VoiceStreamingService>;
25
+
26
+ let mockVad: { start: jasmine.Spy; pause: jasmine.Spy; destroy: jasmine.Spy };
27
+
28
+ beforeEach(() => {
29
+ const ngxlogger = jasmine.createSpyObj('NGXLogger', ['log', 'trace', 'debug', 'warn', 'error', 'info']);
30
+ LoggerInstance.setInstance(new CustomLogger(ngxlogger));
31
+
32
+ mockVad = {
33
+ start: jasmine.createSpy('start').and.returnValue(Promise.resolve()),
34
+ pause: jasmine.createSpy('pause').and.returnValue(Promise.resolve()),
35
+ destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()),
36
+ };
37
+ vadService = jasmine.createSpyObj('VadService', ['ensureOnnxRuntimeEnv', 'createMicVad']);
38
+ vadService.ensureOnnxRuntimeEnv.and.returnValue(Promise.resolve());
39
+ vadService.createMicVad.and.returnValue(Promise.resolve(mockVad as any));
40
+
41
+ wsControl$ = new Subject<VoiceWsControlMessage>();
42
+ ttsBinaryChunk$ = new Subject<ArrayBuffer>();
43
+
44
+ voiceStreamingMock = jasmine.createSpyObj<VoiceStreamingService>(
45
+ 'VoiceStreamingService',
46
+ ['start', 'stop', 'setAudioMuted', 'sendPlaybackComplete', 'pauseRecording', 'resumeRecording'],
47
+ );
48
+ voiceStreamingMock.start.and.returnValue(Promise.resolve());
49
+ voiceStreamingMock.stop.and.returnValue(
50
+ Promise.resolve({ blob: null, mimeType: '', resultUrl: null }),
51
+ );
52
+ // Expose the subjects as readonly observables via Object.defineProperty
53
+ Object.defineProperty(voiceStreamingMock, 'wsControl$', { get: () => wsControl$.asObservable() });
54
+ Object.defineProperty(voiceStreamingMock, 'ttsBinaryChunk$', { get: () => ttsBinaryChunk$.asObservable() });
55
+
56
+ const ttsMock = { isTTSPlaying$: { subscribe: () => ({ unsubscribe: () => undefined }) } };
57
+
58
+ TestBed.configureTestingModule({
59
+ providers: [
60
+ VoiceService,
61
+ { provide: VadService, useValue: vadService },
62
+ { provide: VoiceStreamingService, useValue: voiceStreamingMock },
63
+ { provide: TtsAudioPlaybackCoordinator, useValue: ttsMock },
64
+ ],
65
+ });
66
+ service = TestBed.inject(VoiceService);
67
+ });
68
+
69
+ // ── Existing session lifecycle tests ──────────────────────────────────────
70
+
71
+ it('startSession should call ensureOnnxRuntimeEnv', async () => {
72
+ const stream = createAudioMediaStream();
73
+ spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(stream));
74
+
75
+ await service.startSession({});
76
+
77
+ expect(vadService.ensureOnnxRuntimeEnv).toHaveBeenCalled();
78
+ });
79
+
80
+ it('startSession should request mic, create MicVAD, and start', async () => {
81
+ const stream = createAudioMediaStream();
82
+ spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(stream));
83
+
84
+ await service.startSession({
85
+ onRecordingComplete: () => {},
86
+ });
87
+
88
+ expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalled();
89
+ expect(vadService.createMicVad).toHaveBeenCalled();
90
+ expect(mockVad.start).toHaveBeenCalled();
91
+ });
92
+
93
+ it('startSession with voiceIngressStream should not use MicVAD', async () => {
94
+ const stream = createAudioMediaStream();
95
+ spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(stream));
96
+
97
+ await service.startSession({
98
+ voiceIngressStream: { token: 'JWT x', sender: 'user1', recipient: 'support-group-p1-req1' },
99
+ });
100
+
101
+ expect(vadService.ensureOnnxRuntimeEnv).not.toHaveBeenCalled();
102
+ expect(vadService.createMicVad).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it('stopSession should destroy VAD and stop tracks', async () => {
106
+ const track = jasmine.createSpyObj<MediaStreamTrack>('MediaStreamTrack', ['stop']);
107
+ const stream = createAudioMediaStream();
108
+ spyOn(stream, 'getTracks').and.returnValue([track]);
109
+ spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(stream));
110
+
111
+ await service.startSession({ onRecordingComplete: () => {} });
112
+ await service.stopSession();
113
+
114
+ expect(track.stop).toHaveBeenCalled();
115
+ });
116
+
117
+ // ── Playback-gated listening re-enablement tests ──────────────────────────
118
+
119
+ /**
120
+ * Start a WSS session and return a helper that tracks _isAcquisitionBlocked$ emissions.
121
+ */
122
+ async function startWssSession(): Promise<boolean[]> {
123
+ const stream = createAudioMediaStream();
124
+ spyOn(navigator.mediaDevices, 'getUserMedia').and.returnValue(Promise.resolve(stream));
125
+ spyOn(service as any, '_startKeyboardSound').and.stub();
126
+ spyOn(service as any, '_stopKeyboardSound').and.stub();
127
+ const blocked: boolean[] = [];
128
+ service.isAcquisitionBlocked$.subscribe((v) => blocked.push(v));
129
+ await service.startSession({
130
+ voiceIngressStream: { token: 'JWT x', sender: 'user1', recipient: 'support-group-p1-req1' },
131
+ });
132
+ return blocked;
133
+ }
134
+
135
+ it('acquisition stays blocked after _flushTtsUnblock; unblocks only on "listening"', async () => {
136
+ const blocked = await startWssSession();
137
+ const initialLen = blocked.length;
138
+
139
+ // Simulate proxy sequence: speaking → binary audio → done
140
+ wsControl$.next({ event: 'speaking', text: 'hello' } as VoiceWsControlMessage);
141
+ // Emit a tiny audio buffer so _activeTtsSources increments
142
+ ttsBinaryChunk$.next(new ArrayBuffer(4));
143
+ wsControl$.next({ event: 'done' } as VoiceWsControlMessage);
144
+
145
+ // sendPlaybackComplete must NOT have been called yet (audio hasn't ended)
146
+ expect(voiceStreamingMock.sendPlaybackComplete).not.toHaveBeenCalled();
147
+
148
+ // _isAcquisitionBlocked$ must still be true — no premature unblock
149
+ const afterDone = blocked.slice(initialLen);
150
+ expect(afterDone.every((v) => v === true)).toBeTrue();
151
+
152
+ // Now simulate "listening" arriving from proxy
153
+ wsControl$.next({ event: 'listening' } as VoiceWsControlMessage);
154
+
155
+ const afterListening = blocked[blocked.length - 1];
156
+ expect(afterListening).toBeFalse();
157
+ expect(voiceStreamingMock.resumeRecording).toHaveBeenCalled();
158
+ });
159
+
160
+ it('empty-audio path: acquisition stays blocked until "listening"', async () => {
161
+ const blocked = await startWssSession();
162
+ const initialLen = blocked.length;
163
+
164
+ // Simulate done arriving with NO binary audio (_activeTtsSources === 0)
165
+ wsControl$.next({ event: 'speaking', text: 'hello' } as VoiceWsControlMessage);
166
+ wsControl$.next({ event: 'done' } as VoiceWsControlMessage);
167
+
168
+ // Acquisition must still be blocked — proxy hasn't confirmed LISTENING yet
169
+ const afterDone = blocked.slice(initialLen);
170
+ expect(afterDone.every((v) => v === true)).toBeTrue();
171
+
172
+ // Unblock only after proxy confirms
173
+ wsControl$.next({ event: 'listening' } as VoiceWsControlMessage);
174
+ expect(blocked[blocked.length - 1]).toBeFalse();
175
+ });
176
+
177
+ it('"listening" event resumes recording exactly once', async () => {
178
+ await startWssSession();
179
+
180
+ wsControl$.next({ event: 'speaking', text: 'hi' } as VoiceWsControlMessage);
181
+ wsControl$.next({ event: 'done' } as VoiceWsControlMessage);
182
+ wsControl$.next({ event: 'listening' } as VoiceWsControlMessage);
183
+
184
+ expect(voiceStreamingMock.resumeRecording).toHaveBeenCalled();
185
+ expect(voiceStreamingMock.resumeRecording).toHaveBeenCalledTimes(1);
186
+ });
187
+
188
+ // ── Audio preemption tests (SPEC-002) ────────────────────────────────────
189
+
190
+ it('second "speaking" cancels first audio: sendPlaybackComplete called exactly once for the new turn', async () => {
191
+ await startWssSession();
192
+ voiceStreamingMock.sendPlaybackComplete.calls.reset();
193
+
194
+ // First turn: audio chunk arrives → _activeTtsSources = 1 (sync) → done sets _unblockAfterTts
195
+ wsControl$.next({ event: 'speaking', text: 'first' } as VoiceWsControlMessage);
196
+ ttsBinaryChunk$.next(new ArrayBuffer(4)); // _activeTtsSources++ synchronously
197
+ wsControl$.next({ event: 'done' } as VoiceWsControlMessage); // _unblockAfterTts = true
198
+
199
+ // Second turn preempts while first audio is still "playing"
200
+ wsControl$.next({ event: 'speaking', text: 'second' } as VoiceWsControlMessage);
201
+ // _cancelAllTtsAudio() resets _activeTtsSources=0, _unblockAfterTts=false
202
+
203
+ // done with no audio — arms unblock; flush signals proxy once for the new turn
204
+ wsControl$.next({ event: 'done' } as VoiceWsControlMessage);
205
+ (service as any)._flushTtsUnblock(false);
206
+
207
+ expect(voiceStreamingMock.sendPlaybackComplete).toHaveBeenCalledTimes(1);
208
+ });
209
+
210
+ it('second "speaking" resets counters so first audio ending does not trigger spurious sendPlaybackComplete', async () => {
211
+ await startWssSession();
212
+ voiceStreamingMock.sendPlaybackComplete.calls.reset();
213
+
214
+ wsControl$.next({ event: 'speaking', text: 'first' } as VoiceWsControlMessage);
215
+ ttsBinaryChunk$.next(new ArrayBuffer(4));
216
+ wsControl$.next({ event: 'done' } as VoiceWsControlMessage);
217
+
218
+ // Preempt
219
+ wsControl$.next({ event: 'speaking', text: 'second' } as VoiceWsControlMessage);
220
+
221
+ // Simulate first audio's onended firing AFTER the cancel (delayed Web Audio callback).
222
+ (service as any)._onTtsSourceEnded();
223
+
224
+ // _unblockAfterTts was cleared by cancel; no sendPlaybackComplete should fire
225
+ expect(voiceStreamingMock.sendPlaybackComplete).not.toHaveBeenCalled();
226
+ });
227
+ });