Add the Translate transcript panel with its own exports
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a0748b5fa6
commit
c110c9203f
+24
-1
@@ -10,6 +10,7 @@ import { ModelPicker } from "./components/ModelPicker";
|
||||
import { StorageManager } from "./components/StorageManager";
|
||||
import { TranscriptView } from "./components/TranscriptView";
|
||||
import { TranscriptionOptions } from "./components/TranscriptionOptions";
|
||||
import { TranslatePanel } from "./components/TranslatePanel";
|
||||
import { decodeAudioFile } from "./lib/audio";
|
||||
import { classifyError, type ClassifiedError } from "./lib/errors";
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import { useAuth } from "./lib/useAuth";
|
||||
import { LIVE_TUNING, LiveTranscriber, type LiveTranscribeResult } from "./lib/live";
|
||||
import { startMicCapture, type MicCapture } from "./lib/mic";
|
||||
import { getDownloadedModels } from "./lib/modelCache";
|
||||
import { TRANSLATION_MODEL_IDS } from "./lib/translation";
|
||||
import { registerServiceWorker } from "./pwa/register";
|
||||
import {
|
||||
AUTO_LANGUAGE,
|
||||
@@ -68,6 +70,8 @@ interface AppErrorState {
|
||||
}
|
||||
|
||||
const MODEL_IDS = MODELS.map((model) => model.id);
|
||||
// Downloaded badges cover the Opus-MT models too (transcript translation, D-WS4b).
|
||||
const CACHED_MODEL_IDS = [...MODEL_IDS, ...TRANSLATION_MODEL_IDS];
|
||||
|
||||
export function App() {
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
@@ -183,7 +187,7 @@ export function App() {
|
||||
|
||||
function refreshDownloaded() {
|
||||
// Downloaded badges (D-005): query Cache Storage for cached weights.
|
||||
getDownloadedModels(MODEL_IDS).then(setDownloaded, () => {});
|
||||
getDownloadedModels(CACHED_MODEL_IDS).then(setDownloaded, () => {});
|
||||
}
|
||||
|
||||
// Terminate the worker when the app unmounts (also keeps HMR tidy).
|
||||
@@ -1165,6 +1169,25 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Text translation of the finished transcript (D-WS4b) — any engine,
|
||||
file or live; secondary to the transcript itself. */}
|
||||
{result && !liveActive && (
|
||||
<TranslatePanel
|
||||
result={result}
|
||||
sourceLanguageHint={
|
||||
!model?.multilingual
|
||||
? "en"
|
||||
: taskForModel(modelId, task) === "translate"
|
||||
? "en"
|
||||
: language
|
||||
}
|
||||
downloaded={downloaded}
|
||||
onModelCached={refreshDownloaded}
|
||||
exportName={fileName}
|
||||
disabled={busy || liveActive}
|
||||
/>
|
||||
)}
|
||||
|
||||
{applyUpdate && (
|
||||
<div className="update-toast" role="status">
|
||||
<span>A new version of Whisper Web is available.</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ModelStorageInfo,
|
||||
} from "../lib/modelCache";
|
||||
import { MODELS, getModel } from "../lib/models";
|
||||
import { TRANSLATION_MODEL_IDS, getTranslationModelById } from "../lib/translation";
|
||||
|
||||
interface StorageManagerProps {
|
||||
/** Downloaded-badge set from App — changes re-measure an open panel. */
|
||||
@@ -15,7 +16,14 @@ interface StorageManagerProps {
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
const MODEL_IDS = MODELS.map((model) => model.id);
|
||||
// Whisper weights plus the Opus-MT translation models (D-WS4b).
|
||||
const MODEL_IDS = [...MODELS.map((model) => model.id), ...TRANSLATION_MODEL_IDS];
|
||||
|
||||
function displayName(modelId: string): string {
|
||||
const translation = getTranslationModelById(modelId);
|
||||
if (translation) return `Translation, ${translation.label}`;
|
||||
return getModel(modelId)?.displayName ?? modelId;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
|
||||
@@ -82,7 +90,7 @@ export function StorageManager({ downloaded, disabled, onChanged }: StorageManag
|
||||
<li key={entry.modelId} className="storage-row">
|
||||
<div className="storage-model">
|
||||
<span className="storage-name">
|
||||
{getModel(entry.modelId)?.displayName ?? entry.modelId}
|
||||
{displayName(entry.modelId)}
|
||||
</span>
|
||||
<code className="storage-id">{entry.modelId}</code>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,13 @@ interface TranscriptViewProps {
|
||||
* "talk.txt"/"talk.srt"); null falls back to "transcript.*".
|
||||
*/
|
||||
exportName?: string | null;
|
||||
/** Heading once the result is final; defaults to "Transcript". */
|
||||
title?: string;
|
||||
/**
|
||||
* Inserted before each export extension ("en" → "talk.en.txt",
|
||||
* "talk.en.srt") so a translation's exports sit beside the original's.
|
||||
*/
|
||||
exportSuffix?: string;
|
||||
}
|
||||
|
||||
/** Trigger a browser download of `contents` as `filename`. */
|
||||
@@ -62,7 +69,10 @@ export function TranscriptView({
|
||||
progress = null,
|
||||
placeholder = "Listening for the first words…",
|
||||
exportName = null,
|
||||
title = "Transcript",
|
||||
exportSuffix,
|
||||
}: TranscriptViewProps) {
|
||||
const ext = (extension: string) => (exportSuffix ? `${exportSuffix}.${extension}` : extension);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const prevResultRef = useRef<typeof result>(null);
|
||||
@@ -102,7 +112,7 @@ export function TranscriptView({
|
||||
<section className="transcript">
|
||||
<div className="transcript-header">
|
||||
<h2 ref={headingRef} tabIndex={-1}>
|
||||
{result ? "Transcript" : activeLabel}
|
||||
{result ? title : activeLabel}
|
||||
{percent !== null && (
|
||||
<span className="transcript-percent"> {percent}%</span>
|
||||
)}
|
||||
@@ -115,7 +125,7 @@ export function TranscriptView({
|
||||
onClick={() =>
|
||||
download(
|
||||
transcriptToTxt(result),
|
||||
exportFilename(exportName, "txt"),
|
||||
exportFilename(exportName, ext("txt")),
|
||||
"text/plain",
|
||||
)
|
||||
}
|
||||
@@ -130,7 +140,7 @@ export function TranscriptView({
|
||||
onClick={() =>
|
||||
download(
|
||||
transcriptToSrt(result.chunks),
|
||||
exportFilename(exportName, "srt"),
|
||||
exportFilename(exportName, ext("srt")),
|
||||
"application/x-subrip",
|
||||
)
|
||||
}
|
||||
@@ -146,7 +156,7 @@ export function TranscriptView({
|
||||
onClick={() =>
|
||||
download(
|
||||
transcriptToTimestampedTxt(result.chunks),
|
||||
exportFilename(exportName, "timestamps.txt"),
|
||||
exportFilename(exportName, ext("timestamps.txt")),
|
||||
"text/plain",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { classifyError, type ClassifiedError } from "../lib/errors";
|
||||
import { formatModelSize } from "../lib/models";
|
||||
import {
|
||||
TRANSLATION_MODELS,
|
||||
assembleTranslation,
|
||||
buildTranslationUnits,
|
||||
guessSourceLanguage,
|
||||
type TranslationModel,
|
||||
type TranslationUnit,
|
||||
} from "../lib/translation";
|
||||
import type { TranscriptChunk } from "../worker/protocol";
|
||||
import { isTranslateWorkerMessage, type TranslateRequest } from "../worker/translateProtocol";
|
||||
import { TranscriptView } from "./TranscriptView";
|
||||
|
||||
interface TranscriptResult {
|
||||
text: string;
|
||||
chunks: TranscriptChunk[];
|
||||
}
|
||||
|
||||
/** Where a translation run is; `idle` covers "never started" and "finished". */
|
||||
export type TranslatePhase =
|
||||
| { kind: "idle" }
|
||||
| { kind: "starting" }
|
||||
| { kind: "downloading"; loaded: number; total: number }
|
||||
| { kind: "translating"; done: number; total: number };
|
||||
|
||||
/**
|
||||
* Plain-language status for a running translation (PRODUCT.md: say what is
|
||||
* happening, in everyday words). Null when nothing is running.
|
||||
*/
|
||||
export function translateStatusText(phase: TranslatePhase, cached: boolean): string | null {
|
||||
switch (phase.kind) {
|
||||
case "idle":
|
||||
return null;
|
||||
case "starting":
|
||||
return cached ? "Loading the translation model…" : "Starting the download…";
|
||||
case "downloading": {
|
||||
const percent = phase.total > 0 ? Math.min(100, Math.round((phase.loaded / phase.total) * 100)) : 0;
|
||||
return `Downloading the translation model… ${percent}%`;
|
||||
}
|
||||
case "translating":
|
||||
return phase.total === 0
|
||||
? "Translating…"
|
||||
: `Translating… ${phase.done} of ${phase.total} sentence${phase.total === 1 ? "" : "s"}`;
|
||||
}
|
||||
}
|
||||
|
||||
interface TranslatePanelProps {
|
||||
/** The finished transcript to translate (any engine, file or live). */
|
||||
result: TranscriptResult;
|
||||
/**
|
||||
* Language Whisper was told the audio is in ("da", "en"), "en" when
|
||||
* Whisper itself translated, or undefined/"auto" — seeds the direction.
|
||||
*/
|
||||
sourceLanguageHint?: string;
|
||||
/** Hub ids with cached weights (D-005) — decides the download wording. */
|
||||
downloaded: ReadonlySet<string>;
|
||||
/** Called once a model finished loading, to refresh downloaded badges. */
|
||||
onModelCached: () => void;
|
||||
/** Source file name for export names ("talk.m4a" → "talk.en.srt"). */
|
||||
exportName: string | null;
|
||||
/** True while a transcription or live session is running. */
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text translation of a finished transcript with Opus-MT (D-WS4b): a
|
||||
* secondary disclosure under the transcript, separate from Whisper's own
|
||||
* "Translate to English" task. Runs on-device in its own worker; the model
|
||||
* is downloaded only when the user clicks Translate (D-020).
|
||||
*/
|
||||
export function TranslatePanel({
|
||||
result,
|
||||
sourceLanguageHint,
|
||||
downloaded,
|
||||
onModelCached,
|
||||
exportName,
|
||||
disabled,
|
||||
}: TranslatePanelProps) {
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const runRef = useRef(0);
|
||||
const [modelId, setModelId] = useState(() => defaultModel(result, sourceLanguageHint).id);
|
||||
const [phase, setPhase] = useState<TranslatePhase>({ kind: "idle" });
|
||||
const [error, setError] = useState<ClassifiedError | null>(null);
|
||||
const [translation, setTranslation] = useState<{
|
||||
model: TranslationModel;
|
||||
result: TranscriptResult;
|
||||
} | null>(null);
|
||||
|
||||
const model = TRANSLATION_MODELS.find((m) => m.id === modelId) ?? TRANSLATION_MODELS[0]!;
|
||||
const cached = downloaded.has(model.id);
|
||||
const running = phase.kind !== "idle";
|
||||
const empty = result.text.trim() === "" && result.chunks.every((c) => c.text.trim() === "");
|
||||
|
||||
function stopWorker() {
|
||||
runRef.current++;
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
}
|
||||
|
||||
// A new transcript (or model switch upstream) invalidates everything here.
|
||||
useEffect(() => {
|
||||
stopWorker();
|
||||
setPhase({ kind: "idle" });
|
||||
setError(null);
|
||||
setTranslation(null);
|
||||
setModelId(defaultModel(result, sourceLanguageHint).id);
|
||||
// Keyed on the transcript only: the language hint seeds the default for
|
||||
// a new transcript but must not wipe a translation when it changes.
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => stopWorker, []);
|
||||
|
||||
function translate() {
|
||||
if (running || empty) return;
|
||||
const run = ++runRef.current;
|
||||
const units: TranslationUnit[] = buildTranslationUnits(result);
|
||||
const target = model;
|
||||
setError(null);
|
||||
setTranslation(null);
|
||||
setPhase({ kind: "starting" });
|
||||
|
||||
// Separate worker, bundled and served at a stable URL like the Whisper
|
||||
// worker (src/index.ts, scripts/build.ts). Reused across runs.
|
||||
const worker = (workerRef.current ??= new Worker("translate.worker.js", { type: "module" }));
|
||||
const requestId = `translate-${run}`;
|
||||
const fail = (raw: unknown, stage: "translate" | "worker-crash") => {
|
||||
if (runRef.current !== run) return;
|
||||
setPhase({ kind: "idle" });
|
||||
const offline = typeof navigator !== "undefined" && !navigator.onLine;
|
||||
// A crash (e.g. OOM tab-kill) leaves nothing to reuse.
|
||||
if (stage === "worker-crash") stopWorker();
|
||||
setError(classifyError(raw, { stage: "translate", offline }));
|
||||
};
|
||||
worker.onmessage = (event: MessageEvent<unknown>) => {
|
||||
const message = event.data;
|
||||
if (runRef.current !== run || !isTranslateWorkerMessage(message)) return;
|
||||
if (message.requestId !== requestId) return;
|
||||
switch (message.type) {
|
||||
case "translate-download":
|
||||
setPhase({ kind: "downloading", loaded: message.loaded, total: message.total });
|
||||
break;
|
||||
case "translate-progress":
|
||||
if (message.done === 0) onModelCached();
|
||||
setPhase({ kind: "translating", done: message.done, total: message.total });
|
||||
break;
|
||||
case "translate-result":
|
||||
try {
|
||||
setTranslation({ model: target, result: assembleTranslation(units, message.texts) });
|
||||
setPhase({ kind: "idle" });
|
||||
} catch (assembleError) {
|
||||
fail(assembleError, "translate");
|
||||
}
|
||||
break;
|
||||
case "translate-error":
|
||||
fail(message.message, "translate");
|
||||
break;
|
||||
}
|
||||
};
|
||||
worker.onerror = (event) => fail(event.message ?? "", "worker-crash");
|
||||
const request: TranslateRequest = {
|
||||
type: "translate",
|
||||
requestId,
|
||||
modelId: target.id,
|
||||
units: units.map((unit) => unit.text),
|
||||
};
|
||||
worker.postMessage(request);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
stopWorker();
|
||||
setPhase({ kind: "idle" });
|
||||
}
|
||||
|
||||
const status = translateStatusText(phase, cached);
|
||||
const progressValue =
|
||||
phase.kind === "downloading"
|
||||
? { value: phase.loaded, max: Math.max(phase.total, 1) }
|
||||
: phase.kind === "translating"
|
||||
? { value: phase.done, max: Math.max(phase.total, 1) }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<details className="translate-panel">
|
||||
<summary>Translate transcript…</summary>
|
||||
<div className="translate-controls">
|
||||
<label className="translate-direction">
|
||||
Direction
|
||||
<select
|
||||
value={model.id}
|
||||
disabled={running || disabled}
|
||||
onChange={(event) => setModelId(event.target.value)}
|
||||
>
|
||||
{TRANSLATION_MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{running ? (
|
||||
<button type="button" className="translate-cancel" onClick={cancel}>
|
||||
Cancel
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="translate-button"
|
||||
onClick={translate}
|
||||
disabled={disabled || empty}
|
||||
>
|
||||
{translation?.model.id === model.id ? "Translate again" : "Translate"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="translate-hint">
|
||||
{cached
|
||||
? "Translation model downloaded — works offline. "
|
||||
: `Downloads a translation model once (~${formatModelSize(model.approxBytes)}), then works offline. `}
|
||||
Runs on this device; the text never leaves it. Machine translation — check
|
||||
names and important details.
|
||||
</p>
|
||||
<p className="translate-hint translate-model-id">
|
||||
Model: <code>{model.id}</code> (Opus-MT)
|
||||
</p>
|
||||
{status && (
|
||||
<div className="translate-progress" role="status">
|
||||
<span className="translate-status">{status}</span>
|
||||
{progressValue && (
|
||||
<progress
|
||||
value={progressValue.value}
|
||||
max={progressValue.max}
|
||||
aria-label="Translation progress"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="error translate-error" role="alert">
|
||||
<p className="error-message">{error.message}</p>
|
||||
{error.suggestion && <p className="error-suggestion">{error.suggestion}</p>}
|
||||
{error.retryable && (
|
||||
<button type="button" className="error-retry-button" onClick={translate}>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
{translation && (
|
||||
<TranscriptView
|
||||
partialText=""
|
||||
result={translation.result}
|
||||
transcribing={false}
|
||||
title={`Translation (${translation.model.targetLabel})`}
|
||||
exportName={exportName}
|
||||
exportSuffix={translation.model.target}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultModel(result: TranscriptResult, hint?: string): TranslationModel {
|
||||
const source = guessSourceLanguage(result.text, hint);
|
||||
return TRANSLATION_MODELS.find((m) => m.source === source) ?? TRANSLATION_MODELS[0]!;
|
||||
}
|
||||
+119
@@ -1006,3 +1006,122 @@ code {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Transcript translation (D-WS4b): a secondary disclosure under the
|
||||
transcript, same panel language as .storage-manager.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.translate-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.translate-panel summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.translate-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.translate-direction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.translate-direction select {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
background: var(--surface-raised);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.translate-button,
|
||||
.translate-cancel {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
/* The panel's one action reads as the action: accent outline, like a
|
||||
focused copy button, without competing with the transcript above. */
|
||||
.translate-button {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.translate-button:hover:enabled {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.translate-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.translate-cancel:hover {
|
||||
border-color: var(--danger-border);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.translate-hint {
|
||||
margin: 0.6rem 0 0;
|
||||
max-width: 65ch;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.translate-model-id {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.translate-model-id code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.translate-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.translate-status {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.translate-progress progress {
|
||||
flex: 1 1 10rem;
|
||||
height: 6px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.translate-error {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { TranslatePanel, translateStatusText } from "../src/components/TranslatePanel";
|
||||
|
||||
// D-WS4b: the panel's copy, before any worker exists (static render).
|
||||
const danish = { text: "Det er ikke noget med at vi skal på ferie.", chunks: [] };
|
||||
|
||||
function render(downloaded: string[] = [], hint?: string) {
|
||||
return renderToStaticMarkup(
|
||||
<TranslatePanel
|
||||
result={danish}
|
||||
sourceLanguageHint={hint}
|
||||
downloaded={new Set(downloaded)}
|
||||
onModelCached={() => {}}
|
||||
exportName="talk.m4a"
|
||||
disabled={false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TranslatePanel", () => {
|
||||
test("is a secondary disclosure named in plain words", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("<details");
|
||||
expect(html).toContain("Translate transcript…");
|
||||
});
|
||||
|
||||
test("states the one-time download size before anything downloads (D-020)", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("Downloads a translation model once (~113 MB)");
|
||||
expect(html).toContain("never leaves");
|
||||
});
|
||||
|
||||
test("says it works offline once the model is cached", () => {
|
||||
expect(render(["Xenova/opus-mt-da-en"])).toContain("Translation model downloaded — works offline");
|
||||
});
|
||||
|
||||
test("defaults the direction from the transcript's language", () => {
|
||||
expect(render()).toMatch(/<option value="Xenova\/opus-mt-da-en" selected="">/);
|
||||
expect(render([], "en")).toMatch(/<option value="Xenova\/opus-mt-en-da" selected="">/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateStatusText", () => {
|
||||
test("idle says nothing", () => {
|
||||
expect(translateStatusText({ kind: "idle" }, false)).toBeNull();
|
||||
});
|
||||
|
||||
test("never claims a download for a cached model (D-033 spirit)", () => {
|
||||
expect(translateStatusText({ kind: "starting" }, true)).toBe("Loading the translation model…");
|
||||
expect(translateStatusText({ kind: "starting" }, false)).toBe("Starting the download…");
|
||||
});
|
||||
|
||||
test("download percent and sentence counts", () => {
|
||||
expect(translateStatusText({ kind: "downloading", loaded: 40, total: 100 }, false)).toBe(
|
||||
"Downloading the translation model… 40%",
|
||||
);
|
||||
expect(translateStatusText({ kind: "translating", done: 12, total: 80 }, true)).toBe(
|
||||
"Translating… 12 of 80 sentences",
|
||||
);
|
||||
expect(translateStatusText({ kind: "translating", done: 0, total: 1 }, true)).toBe(
|
||||
"Translating… 0 of 1 sentence",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user