@@ -3,6 +3,7 @@ package tui
33import (
44 "context"
55 "errors"
6+ "fmt"
67 "strings"
78 "testing"
89
@@ -175,6 +176,145 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) {
175176 }
176177}
177178
179+ func TestDictationStreamingPartialWithCaretInsideExistingText (t * testing.T ) {
180+ m := model {}
181+ m .setComposerState (composerState {text : "hello world" , cursor : 5 })
182+ m .dictation .phase = dictRecording
183+ m .dictation .streaming = true
184+
185+ m = m .handleDictationPartial (sttPartialMsg {text : "there" })
186+ if m .composer .text != "hello there world" {
187+ t .Fatalf ("after first partial: %q" , m .composer .text )
188+ }
189+ m = m .handleDictationPartial (sttPartialMsg {text : "there friend" })
190+ m = m .handleDictationPartial (sttPartialMsg {text : "there friend again" })
191+ if m .composer .text != "hello there friend again world" {
192+ t .Fatalf ("after third partial: %q" , m .composer .text )
193+ }
194+ }
195+
196+ func TestDictationStreamingPartialReanchorsRegionAfterComposerShrink (t * testing.T ) {
197+ m := model {}
198+ m .setComposerState (composerState {text : "hello world" , cursor : 11 })
199+ m .dictation .phase = dictRecording
200+ m .dictation .streaming = true
201+ m = m .handleDictationPartial (sttPartialMsg {text : "there" })
202+
203+ // Simulate the user replacing the composer while a later partial is in
204+ // flight. The old live-region bounds now exceed the new composer length.
205+ m .setComposerState (composerState {text : "hi " , cursor : 3 })
206+ m = m .handleDictationPartial (sttPartialMsg {text : "again" })
207+ if m .composer .text != "hi again" {
208+ t .Fatalf ("partial after composer shrink: %q" , m .composer .text )
209+ }
210+ }
211+
212+ func TestDictationStreamingPartialDoesNotClampStaleRegionIntoUserText (t * testing.T ) {
213+ m := model {}
214+ m .setComposerState (composerState {text : "aOLDz" , cursor : 1 })
215+ m .dictation .phase = dictRecording
216+ m .dictation .streaming = true
217+ m = m .handleDictationPartial (sttPartialMsg {text : "first" })
218+
219+ // Replace the composer while another cumulative partial is in flight. The
220+ // old region starts after the same prefix but no longer contains dictation
221+ // text, so its clamped bounds must not consume the user's "b".
222+ m .setComposerState (composerState {text : "ab" , cursor : 2 })
223+ m = m .handleDictationPartial (sttPartialMsg {text : "next" })
224+ if m .composer .text != "ab next" {
225+ t .Fatalf ("stale live region replaced user text: %q" , m .composer .text )
226+ }
227+ }
228+
229+ func TestDictationStreamingCancelDoesNotClampStaleRegionIntoUserText (t * testing.T ) {
230+ m := model {}
231+ m .setComposerState (composerState {text : "aOLDz" , cursor : 1 })
232+ m .dictation .phase = dictRecording
233+ m .dictation .streaming = true
234+ m = m .handleDictationPartial (sttPartialMsg {text : "first" })
235+
236+ m .setComposerState (composerState {text : "ab" , cursor : 2 })
237+ m = m .discardDictationRegion ()
238+ if m .composer .text != "ab" {
239+ t .Fatalf ("discarded stale live region from user text: %q" , m .composer .text )
240+ }
241+ }
242+
243+ func TestDictationStreamingSameLengthRegionEditPreserved (t * testing.T ) {
244+ for _ , partial := range []bool {false , true } {
245+ t .Run (fmt .Sprintf ("later partial=%v" , partial ), func (t * testing.T ) {
246+ m := model {}
247+ m .setComposerState (composerState {text : "hello world" , cursor : 11 })
248+ m .applyStreamingText ("there" )
249+ // Keep the prefix and rune bounds unchanged, but replace precisely
250+ // the rendered region with user text.
251+ m .setComposerState (composerState {text : "hello world MINE!" , cursor : 17 })
252+ if partial {
253+ m .applyStreamingText ("next" )
254+ if ! strings .Contains (m .composer .text , " MINE!" ) {
255+ t .Fatalf ("partial deleted same-length user edit: %q" , m .composer .text )
256+ }
257+ }
258+ m = m .discardDictationRegion ()
259+ if m .composer .text != "hello world MINE!" {
260+ t .Fatalf ("cancel deleted same-length user edit: %q" , m .composer .text )
261+ }
262+ })
263+ }
264+ }
265+
266+ func TestDictationStreamingBackspaceAcrossRegionPreservesUserTextOnCancel (t * testing.T ) {
267+ m := model {}
268+ m .setComposerState (composerState {text : "hello world" , cursor : 11 })
269+ m .dictation .phase = dictRecording
270+ m .dictation .streaming = true
271+ m = m .handleDictationPartial (sttPartialMsg {text : "there" })
272+
273+ // The user backspaces through the live transcript and into their original
274+ // text while newer cumulative partials are still in flight.
275+ m .setComposerState (composerState {text : "hello wor" , cursor : 9 })
276+ m = m .handleDictationPartial (sttPartialMsg {text : "there friend" })
277+ m = m .handleDictationPartial (sttPartialMsg {text : "there friend again" })
278+ if m .composer .text != "hello wor there friend again" {
279+ t .Fatalf ("partials replaced user text after backspace: %q" , m .composer .text )
280+ }
281+
282+ m , _ = m .cancelDictation ()
283+ if m .composer .text != "hello wor" {
284+ t .Fatalf ("cancel did not restore the user's edited text: %q" , m .composer .text )
285+ }
286+ }
287+
288+ func TestDictationStreamingInvalidRegionBoundsReanchor (t * testing.T ) {
289+ tests := []struct {
290+ name string
291+ start int
292+ end int
293+ anchor string
294+ want string
295+ }{
296+ {name : "start past end" , start : 40 , end : 60 , anchor : "a much longer previous composer" , want : "ab partial text" },
297+ {name : "negative start" , start : - 3 , end : 1 , anchor : "" , want : "ab partial text" },
298+ {name : "end past end" , start : 1 , end : 40 , anchor : "a" , want : "ab partial text" },
299+ }
300+
301+ for _ , tt := range tests {
302+ t .Run (tt .name , func (t * testing.T ) {
303+ m := model {}
304+ m .setComposerState (composerState {text : "ab" , cursor : 2 })
305+ m .dictation .regionActive = true
306+ m .dictation .regionStart = tt .start
307+ m .dictation .regionEnd = tt .end
308+ m .dictation .regionAnchor = tt .anchor
309+
310+ m .applyStreamingText ("partial text" )
311+ if m .composer .text != tt .want {
312+ t .Fatalf ("composer = %q, want %q" , m .composer .text , tt .want )
313+ }
314+ })
315+ }
316+ }
317+
178318func TestDictationCanceledStreamRaceDoesNotAutoSubmit (t * testing.T ) {
179319 // Esc can race an already-buffered realtime event: cancelDictation discards
180320 // the live region and resets state synchronously, but the streaming
0 commit comments