diff --git a/openless-all/app/scripts/windows-package-msvc.test.mjs b/openless-all/app/scripts/windows-package-msvc.test.mjs index ce5aa4ec..374fa036 100644 --- a/openless-all/app/scripts/windows-package-msvc.test.mjs +++ b/openless-all/app/scripts/windows-package-msvc.test.mjs @@ -11,6 +11,7 @@ const launcherPath = join(scriptsDir, "windows-package-msvc.cmd"); const ciWorkflowPath = join(repoRoot, ".github", "workflows", "release-tauri.yml"); const imeBuildPath = join(scriptsDir, "windows-ime-build.ps1"); const imeInstallSmokePath = join(scriptsDir, "windows-ime-install-smoke.ps1"); +const realAsrInsertionSmokePath = join(scriptsDir, "windows-real-asr-insertion-smoke.ps1"); const imeRegisterPath = join(scriptsDir, "windows-ime-register.ps1"); const imeUnregisterPath = join(scriptsDir, "windows-ime-unregister.ps1"); const imeSolutionPath = join(appRoot, "windows-ime", "OpenLessIme.sln"); @@ -26,6 +27,7 @@ const launcher = readFileSync(launcherPath, "utf8"); const ciWorkflow = readFileSync(ciWorkflowPath, "utf8"); const imeBuild = readFileSync(imeBuildPath, "utf8"); const imeInstallSmoke = readFileSync(imeInstallSmokePath, "utf8"); +const realAsrInsertionSmoke = readFileSync(realAsrInsertionSmokePath, "utf8"); const imeRegister = readFileSync(imeRegisterPath, "utf8"); const imeUnregister = readFileSync(imeUnregisterPath, "utf8"); const imeSolution = readFileSync(imeSolutionPath, "utf8"); @@ -105,8 +107,11 @@ assert.match(imeTextService, /TF_ES_ASYNC \| TF_ES_READWRITE/, "IME should retry assert.match(imeTextService, /WaitForSingleObject/, "IME pipe submit should wait for async edit-session completion"); assert.match(imeEditSession, /SetEvent/, "IME edit session should signal async completion back to the pipe submitter"); assert.match(imeEditSession, /Collapse\(edit_cookie, TF_ANCHOR_END\)/, "IME should collapse the committed range to its end after insertion"); -assert.match(imeEditSession, /SetSelection\(edit_cookie, 1, &selection\)/, "IME should move the caret to the end of inserted text"); -assert.match(imeEditSession, /TF_AE_END/, "IME should make the end of the committed text the active selection end"); +assert.match(imeEditSession, /SetSelection\(edit_cookie, 1, &selection\)/, "IME should move the caret to the end of inserted text"); +assert.match(imeEditSession, /TF_AE_END/, "IME should make the end of the committed text the active selection end"); +const insertAtSelectionCalls = imeEditSession.match(/\bInsertTextAtSelection\s*\(/g) ?? []; +assert.equal(insertAtSelectionCalls.length, 1, "IME should submit the dictated text to the host exactly once"); +assert.doesNotMatch(imeEditSession, /TF_IAS_QUERYONLY/, "IME should not preflight the full text through host text stores before committing it"); assert.match(wixFragment, /DirectoryRef Id="INSTALLDIR"/, "WiX fragment should install into the app directory"); assert.match(wixFragment, /Component Id="OpenLessImeDllX64Component"/, "WiX fragment should define the x64 TSF DLL component"); @@ -145,6 +150,7 @@ assert.match(imeInstallSmoke, /Join-ProcessArguments/, "install smoke should quo assert.match(imeInstallSmoke, /\$commandLine = Join-ProcessArguments \$ArgumentList/, "install smoke should build a single quoted command line"); assert.match(imeInstallSmoke, /Start-Process -FilePath \$FilePath -ArgumentList \$commandLine/, "install smoke should pass a single quoted command line to Start-Process"); assert.match(imeInstallSmoke, /OpenLessImeSubmit/, "install smoke should preserve TSF backend context"); +assert.match(realAsrInsertionSmoke, /\$targetTextForComparison -cne \$finalTextForComparison/, "real insertion smoke should reject duplicate or partial text in initially empty targets"); assert.match(imeInstallSmoke, /Software\\Classes\\CLSID\\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D\}\\InprocServer32/, "install smoke should check x64 COM registration"); assert.match(imeInstallSmoke, /Software\\WOW6432Node\\Classes\\CLSID\\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D\}\\InprocServer32/, "install smoke should check x86 COM registration"); assert.match(imeInstallSmoke, /LanguageProfile\\0x00000804\\\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E\}/, "install smoke should check the TSF language profile"); diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 index d058e24a..0c29af84 100644 --- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 @@ -1016,7 +1016,13 @@ try { if ([string]::IsNullOrWhiteSpace($targetText)) { throw "$Target readback is empty." } - if (-not $targetText.Contains($latest.finalText)) { + $targetTextForComparison = $targetText.Replace("`r`n", "`n") + $finalTextForComparison = ([string]$latest.finalText).Replace("`r`n", "`n") + $targetStartsEmpty = $Target -in @("notepad", "browser", "win32edit") + if ($targetStartsEmpty -and $targetTextForComparison -cne $finalTextForComparison) { + throw "$Target readback does not exactly match latest finalText in an initially empty target; duplicate or partial insertion detected (expected length=$($finalTextForComparison.Length), actual length=$($targetTextForComparison.Length))." + } + if (-not $targetStartsEmpty -and -not $targetText.Contains($latest.finalText)) { if ($targetText.Contains($clipboardSentinel)) { throw "$Target readback contains the pre-dictation clipboard sentinel instead of latest finalText." } diff --git a/openless-all/app/windows-ime/src/edit_session.cpp b/openless-all/app/windows-ime/src/edit_session.cpp index 1960dc25..762afa0c 100644 --- a/openless-all/app/windows-ime/src/edit_session.cpp +++ b/openless-all/app/windows-ime/src/edit_session.cpp @@ -101,38 +101,30 @@ HRESULT OpenLessEditSession::InsertText(TfEditCookie edit_cookie) { return hr; } - ITfRange* query_range = nullptr; - hr = insert_at_selection->InsertTextAtSelection( - edit_cookie, TF_IAS_QUERYONLY, text_.c_str(), - static_cast(text_.size()), &query_range); - if (query_range != nullptr) { - query_range->Release(); - query_range = nullptr; - } - - if (SUCCEEDED(hr) && cancellation_ && cancellation_->load()) { - hr = HRESULT_FROM_WIN32(ERROR_CANCELLED); + if (cancellation_ && cancellation_->load()) { + insert_at_selection->Release(); + return HRESULT_FROM_WIN32(ERROR_CANCELLED); } - if (SUCCEEDED(hr)) { - ITfRange* committed_range = nullptr; - hr = insert_at_selection->InsertTextAtSelection( - edit_cookie, 0, text_.c_str(), static_cast(text_.size()), - &committed_range); - if (committed_range != nullptr) { - if (SUCCEEDED(hr)) { - const HRESULT collapse_hr = - committed_range->Collapse(edit_cookie, TF_ANCHOR_END); - if (SUCCEEDED(collapse_hr)) { - TF_SELECTION selection = {}; - selection.range = committed_range; - selection.style.ase = TF_AE_END; - selection.style.fInterimChar = FALSE; - (void)context_->SetSelection(edit_cookie, 1, &selection); - } + // Commit in a single call. Some Chromium-backed text stores surface a + // full-text QUERYONLY preflight as an edit and then duplicate the real commit. + ITfRange* committed_range = nullptr; + hr = insert_at_selection->InsertTextAtSelection( + edit_cookie, 0, text_.c_str(), static_cast(text_.size()), + &committed_range); + if (committed_range != nullptr) { + if (SUCCEEDED(hr)) { + const HRESULT collapse_hr = + committed_range->Collapse(edit_cookie, TF_ANCHOR_END); + if (SUCCEEDED(collapse_hr)) { + TF_SELECTION selection = {}; + selection.range = committed_range; + selection.style.ase = TF_AE_END; + selection.style.fInterimChar = FALSE; + (void)context_->SetSelection(edit_cookie, 1, &selection); } - committed_range->Release(); } + committed_range->Release(); } insert_at_selection->Release();