feat(translate): highlighted Install affordance for uninstalled engines + dismissable/auto-clearing error banner (#847)
Two related Dub-tab translation-flow fixes, one PR. TASK 1 — proactive, highlighted Install affordance in the translate engine selector (replaces "find out only via a translate-time 400"): - FROM-SOURCE lane (activeEngineUnavailable && !enginesSandboxed): the muted install chip is promoted to a HIGHLIGHTED brand-accent Install button, still wired to handleInstallEngine(translateProvider) with the installing/disabled state. Selecting any uninstalled engine surfaces it immediately. - FROZEN lane (enginesSandboxed): pip install is impossible in the read-only, signed packaged env, so the disabled "needs dev install" span becomes an equally highlighted button opening a popover with (1) the exact install command + copy-to-clipboard, (2) one-click "Switch to Argos (bundled, offline)" — the guaranteed importable escape hatch, and (3) a Docs link via the existing Tauri shell.open path. Gated on the existing `sandboxed` flag, not platform. - Single-source install command: new translation_engines.install_command() is the one source of truth; list_engines() stamps `install_command` per engine and BOTH the argos + deep_translator translate-time 400 messages build their command from it, so the proactive button and the 400 can't drift. engines.ts gains `install_command: string | null`. TASK 2 — the translation error banner now dismisses and clears (class fix): - Root cause: handleTranslateAll never cleared dubError, so a stale 400 survived even a successful retry. It now clears at the start of every attempt. - Corrective-action clears (whole class): changing the engine and installing the package both clear dubError (wrapped setTranslateProvider + handleInstallEngine in DubTab). - DubFooter's banner gains a × dismiss and a guarded auto-timeout (skipped while generating so live per-segment errors persist). i18n: 8 new dub.* keys translated across all 21 locales. Docs: new docs/dubbing/translation-engines.md (from-source vs packaged build) linked from the popover Docs button + a troubleshooting cross-reference. Tests: FE regression for both lanes + never-installs-when-sandboxed + banner dismiss/auto-clear; BE regression that list_engines() install_command is embedded verbatim in the dub_translate 400s. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
mergetest
Claude Opus 4.8
parent
66ad03948b
commit
522bbddccf
@@ -0,0 +1,71 @@
|
||||
"""Single-source install command for translation engines.
|
||||
|
||||
The proactive Install affordance in the Dub Engine selector (fed by
|
||||
``list_engines()['install_command']``) and the translate-time 400 error
|
||||
(dub_translate.py) must both read the SAME command string, so a user is never
|
||||
told two different things. These tests fail-before / pass-after the
|
||||
``translation_engines.install_command`` extraction and its use in the 400s.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
||||
|
||||
from services import translation_engines as te
|
||||
|
||||
|
||||
def test_list_engines_emits_install_command():
|
||||
engines = {e["id"]: e for e in te.list_engines()}
|
||||
# deep_translator-backed online engines all share the same command.
|
||||
for eid in ("google", "deepl", "microsoft", "mymemory"):
|
||||
assert engines[eid]["install_command"] == "uv pip install deep_translator", eid
|
||||
assert engines["argos"]["install_command"] == "uv pip install argostranslate"
|
||||
assert engines["openai"]["install_command"] == "uv pip install openai"
|
||||
# NLLB rides on the core `transformers` dep — no separate install line.
|
||||
assert engines["nllb"]["install_command"] is None
|
||||
|
||||
|
||||
def test_install_command_helper_matches_registry():
|
||||
assert te.install_command("google") == "uv pip install deep_translator"
|
||||
assert te.install_command("nllb") is None
|
||||
assert te.install_command("does-not-exist") is None
|
||||
# Accepts a registry entry dict too (used by list_engines).
|
||||
assert te.install_command(te.get_engine("openai")) == "uv pip install openai"
|
||||
|
||||
|
||||
def _translate_400_body(monkeypatch, provider, missing_module):
|
||||
"""Force the optional dep to be unimportable, run one translate, return the
|
||||
400 JSON body. ``sys.modules[name] = None`` makes ``import name`` raise
|
||||
ImportError even when the package is actually installed — deterministic on
|
||||
dev + CI regardless of what's in the venv."""
|
||||
from api.routers.dub_translate import dub_translate
|
||||
from schemas.requests import TranslateRequest, TranslateSegment
|
||||
|
||||
monkeypatch.setitem(sys.modules, missing_module, None)
|
||||
req = TranslateRequest(
|
||||
segments=[TranslateSegment(id="1", text="hello world")],
|
||||
target_lang="es",
|
||||
provider=provider,
|
||||
source_lang="en",
|
||||
)
|
||||
resp = asyncio.run(dub_translate(req))
|
||||
assert resp.status_code == 400, resp
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
def test_deep_translator_400_embeds_registry_install_command(monkeypatch):
|
||||
body = _translate_400_body(monkeypatch, "google", "deep_translator")
|
||||
cmd = te.install_command("google")
|
||||
assert cmd == "uv pip install deep_translator"
|
||||
# The exact command from list_engines appears verbatim in the 400 — they
|
||||
# cannot drift.
|
||||
assert cmd in body["error"], body
|
||||
|
||||
|
||||
def test_argos_400_embeds_registry_install_command(monkeypatch):
|
||||
body = _translate_400_body(monkeypatch, "argos", "argostranslate")
|
||||
cmd = te.install_command("argos")
|
||||
assert cmd == "uv pip install argostranslate"
|
||||
assert cmd in body["error"], body
|
||||
Reference in New Issue
Block a user