diff --git a/CHANGELOG.md b/CHANGELOG.md index f2470c0..7cb7ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- "Download in background" now really does it. The banner used to come back with the next progress update, so the + message never went away; the download itself was never affected. The banner goes and the composer's footer shows + `Downloading models 37%` with a progress bar until the models are there (or the download fails, which shows the + failure). +- Closing the composer no longer matters to a download. The models keep downloading one after the other, a composer + summoned meanwhile joins the running download (progress in its footer, no "download" banner) and does what you asked + when it ends, and a composer that was already closed does nothing at the end (before, a closed composer could start + the microphone when the speech models arrived). + +### Added +- Windows composer geometry is logged so "the window shows only half" can be diagnosed from the log + (`logs/livelingo-*.log` in the data folder; search for `PROBLEM=`). Every line carries the summon number and the + window's whole geometry (size, the size the system gave it, position, cap, screen working area and scale). The page + reports when its visible area stays smaller than its content (`page-cut-off-by-the-window`), and the window reports a + page taller than its cap, a size the system changed, and a position outside the working area. Model downloads log their + start and end. ## [2.1.1] - 2026-11-03 The settings page shows the real version, updates report their progress and result, and Windows has one installer @@ -403,6 +420,7 @@ process-wide task with shared progress and dedup across every UI surface. See `git log v0.1.4` for the pre-CHANGELOG history. +[2.1.1]: https://github.com/AGIBuild/LiveLingo/releases/tag/v2.1.1 [2.1.0]: https://github.com/AGIBuild/LiveLingo/releases/tag/v2.1.0 [2.0.2]: https://github.com/AGIBuild/LiveLingo/releases/tag/v2.0.2 [2.0.1]: https://github.com/AGIBuild/LiveLingo/releases/tag/v2.0.1 diff --git a/README.md b/README.md index 0cf4723..a13346b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ exported as SRT. - Settings, models and logs live in `%LocalAppData%\AGIBuild\LiveLingo` (Windows) or `~/Library/Application Support/LiveLingo` (macOS), outside the installed program, so updating and uninstalling keep them. Delete that folder to remove everything LiveLingo stored. +- If something looks wrong (for example the composer window is cut off), the log in `logs/` of that folder says what the + window did; lines marked `PROBLEM=` are the ones to look at first. The log holds sizes and names of applications, never + what you wrote or said. - Recent drafts are kept in memory only and are gone when the app exits. - The installed app looks for updates on the GitHub releases of this repository (at start and every few hours) and downloads a newer version in the background. Nothing you wrote or said is part of that request. Set diff --git a/src/LiveLingo.Core/Models/InProcessModelDownloadCoordinator.cs b/src/LiveLingo.Core/Models/InProcessModelDownloadCoordinator.cs index a965aca..351d4ed 100644 --- a/src/LiveLingo.Core/Models/InProcessModelDownloadCoordinator.cs +++ b/src/LiveLingo.Core/Models/InProcessModelDownloadCoordinator.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics; using Microsoft.Extensions.Logging; namespace LiveLingo.Core.Models; @@ -55,6 +56,7 @@ public Task StartAsync(ModelDescriptor descriptor) _sessions[descriptor.Id] = session; Publish(session, new ModelDownloadState(descriptor.Id, ModelDownloadStatus.Downloading, 0, null)); + _logger?.LogInformation("Model download started: {ModelId} ({SizeBytes} bytes)", descriptor.Id, descriptor.SizeBytes); // Progress captures the caller's SynchronizationContext so incremental // progress lands on the same scheduler that invoked StartAsync. Terminal @@ -94,10 +96,12 @@ public void NotifyDeleted(string modelId) private async Task RunAsync(ModelDescriptor descriptor, Session session, IProgress progress) { + var started = Stopwatch.GetTimestamp(); try { await _modelManager.EnsureModelAsync(descriptor, progress, session.Cts.Token).ConfigureAwait(false); Publish(session, new ModelDownloadState(descriptor.Id, ModelDownloadStatus.Installed, 100, null)); + _logger?.LogInformation("Model download finished: {ModelId} in {Seconds:0.0} s", descriptor.Id, Stopwatch.GetElapsedTime(started).TotalSeconds); } catch (OperationCanceledException) { diff --git a/src/LiveLingo.Core/Models/ModelDownloadCoordinatorExtensions.cs b/src/LiveLingo.Core/Models/ModelDownloadCoordinatorExtensions.cs new file mode 100644 index 0000000..7a08f8f --- /dev/null +++ b/src/LiveLingo.Core/Models/ModelDownloadCoordinatorExtensions.cs @@ -0,0 +1,19 @@ +namespace LiveLingo.Core.Models; + +public static class ModelDownloadCoordinatorExtensions +{ + /// + /// Downloads the models one after the other and stops at the first that failed or was cancelled. Nothing in it + /// belongs to a window: whoever asks (the composer of this summon or of a later one) gets the same running + /// downloads, and closing the composer neither stops nor restarts them. + /// + public static async Task StartAllAsync(this IModelDownloadCoordinator coordinator, IEnumerable models) + { + foreach (var model in models) + { + await coordinator.StartAsync(model).ConfigureAwait(false); + if (coordinator.GetState(model.Id).Status is ModelDownloadStatus.Failed or ModelDownloadStatus.Cancelled) + return; + } + } +} diff --git a/src/LiveLingo.Desktop/Composer/ComposerGeometryLog.cs b/src/LiveLingo.Desktop/Composer/ComposerGeometryLog.cs new file mode 100644 index 0000000..05feac6 --- /dev/null +++ b/src/LiveLingo.Desktop/Composer/ComposerGeometryLog.cs @@ -0,0 +1,108 @@ +using Avalonia; +using Microsoft.Extensions.Logging; + +namespace LiveLingo.Desktop.Composer; + +/// Where the composer window is and how big, as the window itself sees it. Numbers only: nothing the user wrote. +/// Requested width and height in device-independent pixels. +/// What the system gave the window; differs from the request when the system constrained it. +/// Screen position in physical pixels. +/// The cap set for the screen the window is on, in device-independent pixels. +/// Set while the user's size (edge drag or zoom) overrides the page's; null while the window follows the page. +/// The last height the page asked for, in CSS pixels. +/// Working area of the screen the window is placed on, in physical pixels. +internal readonly record struct ComposerGeometry( + double Width, double Height, Size Client, int X, int Y, double MaxHeight, double? UserWidth, double? UserHeight, + double ContentHeight, bool Moved, bool Wide, double Scaling, PixelRect? Work) +{ + public override string ToString() => + $"window={Width:0.#}x{Height:0.#}dip client={Client.Width:0.#}x{Client.Height:0.#} pos=({X},{Y})px " + + $"max={MaxHeight:0.#} user={Format(UserWidth)}x{Format(UserHeight)} content={ContentHeight:0.#}css " + + $"moved={Moved} wide={Wide} scale={Scaling:0.##} work={(Work is { } w ? $"({w.X},{w.Y},{w.Width}x{w.Height})px" : "unknown")}"; + + private static string Format(double? value) => value is { } v ? v.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture) : "-"; +} + +/// +/// The one place that writes what the composer window does with its size and position. A report that "the window shows +/// only half" is answered from the log: every line carries the number of the summon and the whole geometry, so one +/// summon can be read from its start (what was asked) to the last size the window took, and the two symptoms that can +/// hide the page — the window smaller than the page, and a window that went off screen — are logged as warnings. +/// +internal sealed class ComposerGeometryLog(ILogger logger) +{ + private int _summon; + private long _lastThrottled; + private readonly Dictionary _lastWarned = []; + + public int Summon => _summon; + + public void SummonStarted(string target, ComposerGeometry geometry) => Info("summon", geometry, $"target={target}", newSummon: true); + + public void Info(string what, ComposerGeometry geometry, string detail = "", bool newSummon = false) + { + if (newSummon) + _summon++; + logger.LogInformation("Composer window summon={Summon} event={Event} {Detail} {Geometry}", _summon, what, detail, geometry); + } + + /// Problems repeat while the cause lasts (every size change of a drag): each kind is logged at most once a second. + public void Warn(string what, ComposerGeometry geometry, string detail) + { + var now = Environment.TickCount64; + if (_lastWarned.TryGetValue(what, out var last) && now - last < 1000) + return; + + _lastWarned[what] = now; + logger.LogWarning("Composer window summon={Summon} PROBLEM={Event} {Detail} {Geometry}", _summon, what, detail, geometry); + } + + /// + /// For events that come in bursts (a drag reports the size on every pointer move): the first of a burst is logged, + /// the rest only when a second has passed. Whatever happens next logs the full geometry again, so the final state is + /// never lost. + /// + public void InfoThrottled(string what, ComposerGeometry geometry, string detail = "") + { + var now = Environment.TickCount64; + if (now - _lastThrottled < 1000) + return; + + _lastThrottled = now; + Info(what, geometry, detail); + } + + /// The page asked for a size; flags a window that cannot show it (capped or constrained by the system). + public void Sized(int contentWidth, int contentHeight, ComposerGeometry geometry) + { + var detail = $"page={contentWidth}x{contentHeight}css"; + if (geometry.UserHeight is null) + Info("size", geometry, detail); + else + InfoThrottled("size-while-user-sized", geometry, detail); + + if (geometry.UserHeight is null && contentHeight > geometry.MaxHeight + 1) + Warn("content-taller-than-window-cap", geometry, $"{detail} cap={geometry.MaxHeight:0.#}dip"); + } + + /// The system sized the window differently from what was asked. + public void SystemResized(ComposerGeometry geometry) + { + if (Math.Abs(geometry.Client.Height - geometry.Height) > 1 || Math.Abs(geometry.Client.Width - geometry.Width) > 1) + Warn("system-changed-the-size", geometry, "the window got a different size than it asked for"); + else + InfoThrottled("system-resized", geometry); + } + + /// The window is not entirely inside the working area of its screen (nothing should leave it so). + public void CheckOnScreen(ComposerGeometry geometry) + { + if (geometry.Work is not { } work) + return; + + var right = geometry.X + geometry.Width * geometry.Scaling; + var bottom = geometry.Y + geometry.Height * geometry.Scaling; + if (geometry.X < work.X - 1 || geometry.Y < work.Y - 1 || right > work.Right + 1 || bottom > work.Bottom + 1) + Warn("window-outside-the-working-area", geometry, $"right={right:0} bottom={bottom:0}"); + } +} diff --git a/src/LiveLingo.Desktop/Composer/ComposerTexts.cs b/src/LiveLingo.Desktop/Composer/ComposerTexts.cs index aa54a56..9b94c9f 100644 --- a/src/LiveLingo.Desktop/Composer/ComposerTexts.cs +++ b/src/LiveLingo.Desktop/Composer/ComposerTexts.cs @@ -224,6 +224,9 @@ public string CommitAria(bool armed, bool hasTarget) => // ---- banners ----------------------------------------------------------------- + /// The footer's line for models that are downloading while the banner is out of the way. + public string FooterDownload(double percent) => loc.T("composer.footer.download", Math.Round(percent)); + public string BannerMessage(BannerState banner) => banner.Kind switch { BannerKind.None => "", @@ -243,7 +246,8 @@ public string CommitAria(bool armed, bool hasTarget) => public string BannerActionLabel(BannerState banner, BannerAction action) => action switch { - BannerAction.Dismiss => loc.T(banner.Kind == BannerKind.Downloading ? "composer.banner.background" : "composer.banner.dismiss"), + BannerAction.Dismiss => loc.T("composer.banner.dismiss"), + BannerAction.Background => loc.T("composer.banner.background"), BannerAction.Download => loc.T(banner.Kind == BannerKind.DownloadFailed ? "composer.banner.retry" : "composer.banner.download"), BannerAction.OpenMicrophoneSettings => loc.T("composer.banner.openOs"), BannerAction.OpenInputSettings => loc.T("composer.banner.checkInput"), diff --git a/src/LiveLingo.Desktop/Composer/ComposerTypes.cs b/src/LiveLingo.Desktop/Composer/ComposerTypes.cs index 96a4433..20edfd0 100644 --- a/src/LiveLingo.Desktop/Composer/ComposerTypes.cs +++ b/src/LiveLingo.Desktop/Composer/ComposerTypes.cs @@ -105,6 +105,9 @@ public enum BannerAction { Dismiss, Download, + + /// Keep downloading, but out of the way: the banner goes, the footer shows the progress. + Background, OpenMicrophoneSettings, OpenInputSettings, UseOriginal, @@ -129,7 +132,8 @@ public sealed record BannerState(BannerKind Kind, string Detail = "", double Pro public IReadOnlyList Actions => Kind switch { BannerKind.ModelMissing or BannerKind.DownloadFailed => [BannerAction.Download], - BannerKind.Downloading or BannerKind.FileFailed or BannerKind.NoSpeechFound => [BannerAction.Dismiss], + BannerKind.Downloading => [BannerAction.Background], + BannerKind.FileFailed or BannerKind.NoSpeechFound => [BannerAction.Dismiss], BannerKind.TargetLost => [BannerAction.Retarget, BannerAction.Copy], BannerKind.MicrophoneDenied => [BannerAction.OpenMicrophoneSettings, BannerAction.Dismiss], BannerKind.NoMicrophone or BannerKind.NoSound => [BannerAction.OpenInputSettings, BannerAction.Dismiss], diff --git a/src/LiveLingo.Desktop/Composer/ComposerViewModel.Choices.cs b/src/LiveLingo.Desktop/Composer/ComposerViewModel.Choices.cs index 4a90a56..27773a5 100644 --- a/src/LiveLingo.Desktop/Composer/ComposerViewModel.Choices.cs +++ b/src/LiveLingo.Desktop/Composer/ComposerViewModel.Choices.cs @@ -34,6 +34,7 @@ public void Begin(ComposerStart start) TargetLanguage = start.Target; OnPropertyChanged(nameof(Remembered)); OnPropertyChanged(nameof(RememberFor)); + AttachToRunningDownloads(); } /// The application (or "copy only") whose choices are remembered; null when remembering is off or it is unknown. diff --git a/src/LiveLingo.Desktop/Composer/ComposerViewModel.Session.cs b/src/LiveLingo.Desktop/Composer/ComposerViewModel.Session.cs index f4eab1f..1ea4c41 100644 --- a/src/LiveLingo.Desktop/Composer/ComposerViewModel.Session.cs +++ b/src/LiveLingo.Desktop/Composer/ComposerViewModel.Session.cs @@ -134,6 +134,11 @@ private async Task RunBannerActionAsync(BannerAction action) case BannerAction.Download: await DownloadModelsCommand.ExecuteAsync(null).ConfigureAwait(true); break; + case BannerAction.Background: + _downloadInBackground = true; + Banner = BannerState.None; + Refresh(); + break; case BannerAction.OpenMicrophoneSettings: _platform.MicrophonePermission.OpenSettings(); break; diff --git a/src/LiveLingo.Desktop/Composer/ComposerViewModel.cs b/src/LiveLingo.Desktop/Composer/ComposerViewModel.cs index 57446fa..be8feb0 100644 --- a/src/LiveLingo.Desktop/Composer/ComposerViewModel.cs +++ b/src/LiveLingo.Desktop/Composer/ComposerViewModel.cs @@ -30,6 +30,10 @@ public sealed partial class ComposerViewModel : ObservableObject, IAsyncDisposab private CommitSnapshot? _committed; private bool _applyingDraftText; private bool _wantsVoice; + private bool _disposed; + + /// The user sent the download to the background: the banner stays away until the models are there. + private bool _downloadInBackground; private bool _stopAfterStart; private Task? _startingVoice; private Task? _stoppingVoice; @@ -106,6 +110,9 @@ private void OnPreparingChanged(bool preparing) => [ObservableProperty] private bool _isEditable = true; [ObservableProperty] private bool _modelsReady; + /// Overall download progress (0–100) of the models this composer needs; null when none is downloading. + [ObservableProperty] private double? _downloadProgress; + [ObservableProperty] private bool _isListening; /// True while the speech models are loading after the microphone was pressed (takes a few seconds). @@ -390,17 +397,40 @@ private void Retry(int index) [RelayCommand] private async Task DownloadModelsAsync() { - foreach (var model in WantedModels().Where(model => !_models.HasAllExpectedLocalAssets(model))) + // What the composer needs can grow while the models download (the microphone or an import was asked for), so + // the missing models are worked off until none is left. A round that installed nothing new ends it. + var attempted = ""; + List missing; + while ((missing = WantedModels().Where(model => !_models.HasAllExpectedLocalAssets(model)).ToList()).Count > 0) { - await _downloads.StartAsync(model).ConfigureAwait(true); - if (!_models.HasAllExpectedLocalAssets(model)) + var round = string.Join(',', missing.Select(model => model.Id)); + if (round == attempted) + break; + + attempted = round; + await _downloads.StartAllAsync(missing).ConfigureAwait(true); + + // The composer was closed meanwhile: the downloads went on without it, and nothing here may act for a + // window that is gone (starting the microphone, for one). + if (_disposed) + return; + + if (missing.Select(model => _downloads.GetState(model.Id)).FirstOrDefault( + state => state.Status is ModelDownloadStatus.Failed or ModelDownloadStatus.Cancelled) is { } stopped) { - var error = _downloads.GetState(model.Id).ErrorMessage ?? ""; - Banner = new BannerState(BannerKind.DownloadFailed, error); + ShowDownloadFailure(stopped.ErrorMessage); return; } } + DownloadProgress = null; + _downloadInBackground = false; + if (missing.Count > 0) + { + ShowDownloadFailure(_downloads.GetState(missing[0].Id).ErrorMessage); + return; + } + ModelsReady = IsInstalled(_plan.For(Output)); Banner = BannerState.None; ApplyDraftOutput(); @@ -411,8 +441,34 @@ private async Task DownloadModelsAsync() await StartListeningAsync().ConfigureAwait(true); } + private void ShowDownloadFailure(string? error) + { + DownloadProgress = null; + _downloadInBackground = false; + Banner = new BannerState(BannerKind.DownloadFailed, error ?? ""); + } + + /// + /// A summon that finds the models of its output already downloading (the composer of an earlier summon started + /// them) joins them: the footer shows the progress, and what the user asked for happens when they are done. + /// + private void AttachToRunningDownloads() + { + var wanted = WantedModels(); + if (!wanted.Any(model => _downloads.GetState(model.Id).IsDownloading)) + return; + + _downloadInBackground = true; + DownloadProgress = OverallProgress(wanted); + if (Banner.Kind is BannerKind.ModelMissing) + Banner = BannerState.None; + _ = DownloadModelsCommand.ExecuteAsync(null); + Refresh(); + } + public async ValueTask DisposeAsync() { + _disposed = true; _session.Changed -= OnDraftChanged; _downloads.StateChanged -= OnDownloadStateChanged; _voice.NoSignalChanged -= OnNoSignalChanged; @@ -547,8 +603,13 @@ private void OnDownloadStateChanged(ModelDownloadState state) void Apply() { - if (state.Status == ModelDownloadStatus.Downloading) - Banner = new BannerState(BannerKind.Downloading, "", OverallProgress(wanted)); + if (_disposed || state.Status != ModelDownloadStatus.Downloading) + return; + + var progress = OverallProgress(wanted); + DownloadProgress = progress; + if (!_downloadInBackground) + Banner = new BannerState(BannerKind.Downloading, "", progress); } if (_notifyOn is null) @@ -593,7 +654,7 @@ private void Refresh() OnPropertyChanged(nameof(CanEditRows)); var needsModels = !ModelsReady && Output != ComposerOutput.Original; - if (needsModels && Banner.Kind is BannerKind.None) + if (needsModels && Banner.Kind is BannerKind.None && DownloadProgress is null) Banner = new BannerState(BannerKind.ModelMissing, MissingSizeLabel()); } diff --git a/src/LiveLingo.Desktop/Composer/ComposerWindow.cs b/src/LiveLingo.Desktop/Composer/ComposerWindow.cs index 2ba9cd4..535aedb 100644 --- a/src/LiveLingo.Desktop/Composer/ComposerWindow.cs +++ b/src/LiveLingo.Desktop/Composer/ComposerWindow.cs @@ -26,6 +26,7 @@ internal sealed class ComposerWindow : Window private readonly WebSurface _web; private readonly IWindowMover _mover; private readonly ILogger _logger; + private readonly ComposerGeometryLog _geometryLog; private ComposerWebSession? _session; private TargetWindowInfo? _target; private TaskCompletionSource? _dismissed; @@ -45,6 +46,7 @@ public ComposerWindow(ILogger logger, IWindowMover mover, ModelUsage modelUsage) { _modelUsage = modelUsage; _logger = logger; + _geometryLog = new ComposerGeometryLog(logger); _mover = mover; _web = new WebSurface(WebPage.ComposerEntry, logger); Width = NarrowWidth; @@ -59,6 +61,20 @@ public ComposerWindow(ILogger logger, IWindowMover mover, ModelUsage modelUsage) WindowStartupLocation = WindowStartupLocation.Manual; Title = "LiveLingo"; Content = _web.View; + Resized += (_, _) => + { + if (_session is not null) + _geometryLog.SystemResized(Geometry()); + }; + } + + /// The window's geometry as it is now, for the log. + private ComposerGeometry Geometry() + { + var screen = CurrentScreen(); + return new ComposerGeometry( + Width, Height, ClientSize, Position.X, Position.Y, MaxHeight, _userWidth, _userHeight, _contentHeight, + _moved, _session?.Wide ?? false, screen?.Scaling ?? RenderScaling, screen?.WorkingArea); } public bool IsPresenting => _session is not null; @@ -91,6 +107,7 @@ public Task PresentAsync(ComposerViewModel vm, ComposerTexts texts, RecentDrafts _session.Dragged += OnDrag; _session.Resized += OnResize; _session.Zoomed += OnZoom; + _session.Clipped += OnClipped; vm.Completed += Dismiss; vm.CloseRequested += Dismiss; @@ -99,11 +116,19 @@ public Task PresentAsync(ComposerViewModel vm, ComposerTexts texts, RecentDrafts ApplyWidth(); LimitToScreen(); Place(); + _geometryLog.SummonStarted( + target is null ? "none" : $"{target.ProcessName} rect=({target.Left},{target.Top},{target.Width}x{target.Height})", + Geometry()); if (_web.IsReady) _session.Start(); return _dismissed.Task; } + /// The page is cut off by the window: the one symptom of "only half of the composer is visible", logged with everything the window knows. + private void OnClipped(ClippedIntent clipped) => _geometryLog.Warn( + "page-cut-off-by-the-window", Geometry(), + $"viewport={clipped.ViewportWidth}x{clipped.ViewportHeight}css page={clipped.ContentWidth}x{clipped.ContentHeight}css"); + /// The same as the close button: the hotkey pressed again. public void RequestHide() => _session?.Vm.HideCommand.Execute(null); @@ -115,6 +140,9 @@ private void OnSize(SizeIntent size) if (_userHeight is null) Height = size.Height; Place(); + var geometry = Geometry(); + _geometryLog.Sized(size.Width, size.Height, geometry); + _geometryLog.CheckOnScreen(geometry); if (_placed) return; @@ -157,6 +185,7 @@ private void OnResize(ResizeIntent resize) Height = height; _session.SetFixedHeight(true); } + _geometryLog.InfoThrottled("user-resize", Geometry(), $"asked={resize.Width?.ToString() ?? "-"}x{resize.Height?.ToString() ?? "-"}css"); } /// Like zooming a window by its title bar: a large window that shows much text, and back to the size that fits the content. @@ -174,6 +203,7 @@ private void OnZoom() ApplyWidth(); Height = _contentHeight; Place(); + _geometryLog.Info("unzoom", Geometry()); return; } @@ -186,6 +216,7 @@ private void OnZoom() Height = zoomed.Height; Position = new PixelPoint(zoomed.X, zoomed.Y); _session.SetFixedHeight(true); + _geometryLog.Info("zoom", Geometry()); } /// @@ -202,6 +233,9 @@ private void OnDrag() _moved = true; LimitToScreen(); Place(); + var geometry = Geometry(); + _geometryLog.Info("dragged", geometry); + _geometryLog.CheckOnScreen(geometry); } private void Dismiss() @@ -210,6 +244,7 @@ private void Dismiss() if (session is null) return; + _geometryLog.Info("dismiss", Geometry()); _session = null; session.Vm.Completed -= Dismiss; session.Vm.CloseRequested -= Dismiss; diff --git a/src/LiveLingo.Desktop/Resources/i18n/en-US.json b/src/LiveLingo.Desktop/Resources/i18n/en-US.json index 4f9092b..623dc14 100644 --- a/src/LiveLingo.Desktop/Resources/i18n/en-US.json +++ b/src/LiveLingo.Desktop/Resources/i18n/en-US.json @@ -137,6 +137,7 @@ "composer.banner.modelMissing": "A local model must be downloaded first (about {0}). Nothing is downloaded until you agree.", "composer.banner.downloading": "Downloading models {0}% · you can keep typing.", + "composer.footer.download": "Downloading models {0}%", "composer.banner.downloadFailed": "Download failed: {0}", "composer.banner.targetLost": "The input box is gone and the draft is kept. To avoid writing into the wrong window, nothing is written automatically.", "composer.banner.writeFailed": "Writing failed: {0}. The result is kept; you can copy it.", diff --git a/src/LiveLingo.Desktop/Resources/i18n/zh-CN.json b/src/LiveLingo.Desktop/Resources/i18n/zh-CN.json index 899f0ae..2ca3944 100644 --- a/src/LiveLingo.Desktop/Resources/i18n/zh-CN.json +++ b/src/LiveLingo.Desktop/Resources/i18n/zh-CN.json @@ -137,6 +137,7 @@ "composer.banner.modelMissing": "需要先下载本地模型(约 {0})。只有你同意后才会下载。", "composer.banner.downloading": "模型下载中 {0}% · 现在可以直接打字。", + "composer.footer.download": "模型后台下载中 {0}%", "composer.banner.downloadFailed": "下载失败:{0}", "composer.banner.targetLost": "原输入框已失效,草稿已保留。为避免写进错误的窗口,不会自动写入。", "composer.banner.writeFailed": "写入失败:{0}。结果已保留,可以复制。", diff --git a/src/LiveLingo.Desktop/Web/Assets/app/10-bridge.js b/src/LiveLingo.Desktop/Web/Assets/app/10-bridge.js index b172d8c..c6309e9 100644 --- a/src/LiveLingo.Desktop/Web/Assets/app/10-bridge.js +++ b/src/LiveLingo.Desktop/Web/Assets/app/10-bridge.js @@ -13,6 +13,30 @@ // Content size in CSS pixels; the window follows it. Only changes are sent. let lastSize = ''; let queued = false; + let content = { width: 0, height: 0 }; + let clipTimer = 0; + let clippedKey = ''; + + // A page whose visible area stays smaller than its content is cut off by the window: the host cannot see that from + // the sizes it was told, so the page reports it (once per distinct case) for the log. The wait lets a window that is + // being resized settle first. + function watchClipping() { + clearTimeout(clipTimer); + clipTimer = setTimeout(() => { + const viewport = { width: window.innerWidth, height: window.innerHeight }; + if (viewport.width + 1 >= content.width && viewport.height + 1 >= content.height) { + clippedKey = ''; + return; + } + const key = `${viewport.width}x${viewport.height}/${content.width}x${content.height}`; + if (key === clippedKey) return; + clippedKey = key; + LL.send('clipped', { + viewportWidth: viewport.width, viewportHeight: viewport.height, + contentWidth: content.width, contentHeight: content.height + }); + }, 300); + } function measure() { queued = false; @@ -30,9 +54,15 @@ const key = width + 'x' + height; if (key === lastSize || width < 1 || height < 1) return; lastSize = key; + content = { width, height }; + watchClipping(); LL.send('size', { width, height }); } + window.addEventListener('resize', () => { + if (content.width > 0) watchClipping(); + }); + LL.measure = () => { if (queued) return; queued = true; diff --git a/src/LiveLingo.Desktop/Web/Assets/app/20-composer-parts.js b/src/LiveLingo.Desktop/Web/Assets/app/20-composer-parts.js index 8707986..30fcdd3 100644 --- a/src/LiveLingo.Desktop/Web/Assets/app/20-composer-parts.js +++ b/src/LiveLingo.Desktop/Web/Assets/app/20-composer-parts.js @@ -107,6 +107,8 @@

${f.status.text}

+ ${f.download && html` + ${f.download.text}`}
public event Action? Zoomed; + + /// The page reports that it is cut off by the window. + public event Action? Clipped; + public event Action? ModelInfoRequested; public event Action? DraftCleared; @@ -74,6 +78,7 @@ public void Handle(WebIntent intent) case DragIntent: Dragged?.Invoke(); break; case ResizeIntent resize: Resized?.Invoke(resize); break; case ZoomIntent: Zoomed?.Invoke(); break; + case ClippedIntent clipped: Clipped?.Invoke(clipped); break; case SourceIntent source: _pageText = source.Text; SourceRev = source.Rev; diff --git a/src/LiveLingo.Desktop/Web/ComposerViewState.cs b/src/LiveLingo.Desktop/Web/ComposerViewState.cs index 6b0de5a..1f1df0f 100644 --- a/src/LiveLingo.Desktop/Web/ComposerViewState.cs +++ b/src/LiveLingo.Desktop/Web/ComposerViewState.cs @@ -43,9 +43,12 @@ internal sealed record MicState(bool Recording, string Aria, bool Enabled); internal sealed record StatusState(string Text, string Tone); +/// The models downloading in the background: the footer shows it so nobody waits without knowing. +internal sealed record DownloadState(string Text, int Percent); + internal sealed record CommitState(string Label, bool Enabled, bool Armed, bool Written, string Aria); -internal sealed record FooterState(MicState Mic, StatusState Status, bool CopyEnabled, CommitState Commit); +internal sealed record FooterState(MicState Mic, StatusState Status, DownloadState? Download, bool CopyEnabled, CommitState Commit); internal sealed record LayoutState(bool Wide); @@ -140,6 +143,9 @@ public static ComposerViewState From(ComposerViewModel vm, ComposerTexts texts, !vm.IsListening && (vm.Status == ComposerStatus.Failed || vm.Output == ComposerOutput.Clean && vm.StatusInfo.PolishOutcome == LiveLingo.Core.Drafting.PolishOutcome.KeptPreview) ? "warn" : "muted"), + vm.DownloadProgress is { } download && vm.Banner.Kind != BannerKind.Downloading + ? new DownloadState(texts.FooterDownload(download), (int)Math.Round(download)) + : null, vm.CanCopy, new CommitState( texts.CommitLabel(vm.IsWritten, armed, vm.HasTarget, targetName), diff --git a/src/LiveLingo.Desktop/Web/ComposerWebSession.cs b/src/LiveLingo.Desktop/Web/ComposerWebSession.cs index fd2efa7..b7b726e 100644 --- a/src/LiveLingo.Desktop/Web/ComposerWebSession.cs +++ b/src/LiveLingo.Desktop/Web/ComposerWebSession.cs @@ -80,6 +80,7 @@ public ComposerWebSession( _intents.Dragged += () => Dragged?.Invoke(); _intents.Resized += resize => Resized?.Invoke(resize); _intents.Zoomed += () => Zoomed?.Invoke(); + _intents.Clipped += clipped => Clipped?.Invoke(clipped); _intents.ModelInfoRequested += () => _bridge.Send("toast", new ToastData(ModelDetails(), 10000)); _intents.DraftCleared += () => _bridge.Send("focus", new FocusData("source")); } @@ -98,6 +99,9 @@ public ComposerWebSession( /// The user asked for the large window, or for the size that follows the content again. public event Action? Zoomed; + /// The page is cut off by the window (a diagnostic; the window logs it). + public event Action? Clipped; + /// Tells the page how tall its panel may get on the screen it is on; beyond that the page scrolls inside. public void LimitPanelHeight(int cssPixels) { diff --git a/src/LiveLingo.Desktop/Web/WebIntent.cs b/src/LiveLingo.Desktop/Web/WebIntent.cs index 04ee027..94d3d6a 100644 --- a/src/LiveLingo.Desktop/Web/WebIntent.cs +++ b/src/LiveLingo.Desktop/Web/WebIntent.cs @@ -16,6 +16,7 @@ namespace LiveLingo.Desktop.Web; [JsonDerivedType(typeof(DragIntent), "drag")] [JsonDerivedType(typeof(ResizeIntent), "resize")] [JsonDerivedType(typeof(ZoomIntent), "zoom")] +[JsonDerivedType(typeof(ClippedIntent), "clipped")] [JsonDerivedType(typeof(SourceIntent), "source")] [JsonDerivedType(typeof(MicToggleIntent), "micToggle")] [JsonDerivedType(typeof(OutputIntent), "output")] @@ -154,6 +155,17 @@ internal sealed record ResizeIntent(int? Width, int? Height) : WebIntent /// Toggles the window between the size that follows its content and a large one that shows as much text as the screen allows. internal sealed record ZoomIntent : WebIntent; +/// +/// A diagnostic, not an action: the page's visible area stayed smaller than the page's content (it is cut off). +/// Sizes in CSS pixels. The host only logs it, with the window's own geometry next to it. +/// +internal sealed record ClippedIntent(int ViewportWidth, int ViewportHeight, int ContentWidth, int ContentHeight) : WebIntent +{ + protected override bool IsValid => + ViewportWidth is >= 0 and <= 100_000 && ViewportHeight is >= 0 and <= 100_000 + && ContentWidth is >= 0 and <= 100_000 && ContentHeight is >= 0 and <= 100_000; +} + /// The source text and the page's own revision of it (design 5.3). internal sealed record SourceIntent(string Text, int Rev) : WebIntent { diff --git a/tests/LiveLingo.Core.Tests/Models/ModelDownloadCoordinatorExtensionsTests.cs b/tests/LiveLingo.Core.Tests/Models/ModelDownloadCoordinatorExtensionsTests.cs new file mode 100644 index 0000000..d6ecce1 --- /dev/null +++ b/tests/LiveLingo.Core.Tests/Models/ModelDownloadCoordinatorExtensionsTests.cs @@ -0,0 +1,56 @@ +using LiveLingo.Core.Models; +using NSubstitute; + +namespace LiveLingo.Core.Tests.Models; + +public sealed class ModelDownloadCoordinatorExtensionsTests +{ + private static readonly ModelDescriptor[] Models = [ModelCatalog.Cleaner, ModelCatalog.TranslatorFast, ModelCatalog.SpeechVad]; + + private static IModelDownloadCoordinator Coordinator(Func statusAfter, List started) + { + var coordinator = Substitute.For(); + coordinator.StartAsync(Arg.Any()).Returns(call => + { + started.Add(call.Arg().Id); + return Task.CompletedTask; + }); + coordinator.GetState(Arg.Any()).Returns(call => + new ModelDownloadState(call.Arg(), statusAfter(call.Arg()), 100, null)); + return coordinator; + } + + [Fact] + public async Task TheModels_AreDownloadedOneAfterTheOther_InOrder() + { + var started = new List(); + var coordinator = Coordinator(_ => ModelDownloadStatus.Installed, started); + + await coordinator.StartAllAsync(Models); + + Assert.Equal(Models.Select(model => model.Id), started); + } + + [Theory] + [InlineData(ModelDownloadStatus.Failed)] + [InlineData(ModelDownloadStatus.Cancelled)] + public async Task AModelThatFailedOrWasCancelled_StopsTheRest(ModelDownloadStatus status) + { + var started = new List(); + var coordinator = Coordinator(id => id == Models[1].Id ? status : ModelDownloadStatus.Installed, started); + + await coordinator.StartAllAsync(Models); + + Assert.Equal([Models[0].Id, Models[1].Id], started); + } + + [Fact] + public async Task NothingToDownload_DoesNothing() + { + var started = new List(); + + await Coordinator(_ => ModelDownloadStatus.Installed, started).StartAllAsync([]); + + Assert.Empty(started); + } +} diff --git a/tests/LiveLingo.Desktop.Tests/Composer/ComposerBackgroundDownloadTests.cs b/tests/LiveLingo.Desktop.Tests/Composer/ComposerBackgroundDownloadTests.cs new file mode 100644 index 0000000..6811086 --- /dev/null +++ b/tests/LiveLingo.Desktop.Tests/Composer/ComposerBackgroundDownloadTests.cs @@ -0,0 +1,166 @@ +using LiveLingo.Core.Models; +using LiveLingo.Core.Translation; +using LiveLingo.Desktop.Composer; +using NSubstitute; + +namespace LiveLingo.Desktop.Tests.Composer; + +/// +/// Downloads belong to the application, not to a composer window: sending one to the background keeps the progress +/// in sight, and closing the window neither stops it nor lets the closed composer act when it ends. +/// +public sealed class ComposerBackgroundDownloadTests +{ + private static void Report(Harness h, ModelDescriptor model, double percent) + { + var state = new ModelDownloadState(model.Id, ModelDownloadStatus.Downloading, percent, null); + h.Downloads.GetState(model.Id).Returns(state); + h.Downloads.StateChanged += NSubstitute.Raise.Event>(state); + } + + [Fact] + public async Task SendingTheDownloadToTheBackground_HidesTheBanner_AndItStaysHidden() + { + await using var h = new Harness(modelsInstalled: false); + Report(h, h.TranslatorModel, 30); + Assert.Equal(BannerKind.Downloading, h.Vm.Banner.Kind); + + await h.Vm.RunBannerActionCommand.ExecuteAsync(BannerAction.Background); + Report(h, h.TranslatorModel, 60); + h.Vm.SourceText = "先检查缓存。"; + + Assert.Equal(BannerKind.None, h.Vm.Banner.Kind); + Assert.InRange(h.Vm.DownloadProgress ?? -1, 1, 99); + } + + [Fact] + public async Task WhileDownloadingInTheBackground_TheFooterCarriesTheProgress_NotTheBanner() + { + await using var h = new Harness(modelsInstalled: false); + Report(h, h.TranslatorModel, 30); + Assert.Equal(BannerKind.Downloading, h.Vm.Banner.Kind); + Assert.NotNull(h.Vm.DownloadProgress); + + await h.Vm.RunBannerActionCommand.ExecuteAsync(BannerAction.Background); + + Assert.Equal(BannerKind.None, h.Vm.Banner.Kind); + Assert.NotNull(h.Vm.DownloadProgress); + } + + [Fact] + public async Task WhenTheDownloadEnds_TheProgressGoesAway() + { + await using var h = new Harness(modelsInstalled: false); + h.Downloads.StartAsync(Arg.Any()).Returns(call => + { + h.Models.HasAllExpectedLocalAssets(call.Arg()).Returns(true); + return Task.CompletedTask; + }); + Report(h, h.TranslatorModel, 50); + await h.Vm.RunBannerActionCommand.ExecuteAsync(BannerAction.Background); + + await h.Vm.DownloadModelsCommand.ExecuteAsync(null); + + Assert.Null(h.Vm.DownloadProgress); + Assert.Equal(BannerKind.None, h.Vm.Banner.Kind); + Assert.True(h.Vm.ModelsReady); + } + + [Fact] + public async Task ADownloadThatFails_ShowsTheFailure_EvenAfterGoingToTheBackground() + { + await using var h = new Harness(modelsInstalled: false); + h.Downloads.GetState(Arg.Any()).Returns(call => + new ModelDownloadState(call.Arg(), ModelDownloadStatus.Failed, 0, "disk full")); + h.Downloads.StartAsync(Arg.Any()).Returns(Task.CompletedTask); + Report(h, h.TranslatorModel, 50); + await h.Vm.RunBannerActionCommand.ExecuteAsync(BannerAction.Background); + + await h.Vm.DownloadModelsCommand.ExecuteAsync(null); + + Assert.Equal(BannerKind.DownloadFailed, h.Vm.Banner.Kind); + Assert.Null(h.Vm.DownloadProgress); + } + + [Fact] + public async Task ClosingTheComposerMidDownload_LetsTheDownloadFinish_AndTheClosedComposerDoesNothing() + { + var h = new Harness(modelsInstalled: false, output: ComposerOutput.Original); + var gate = new TaskCompletionSource(); + h.Downloads.StartAsync(Arg.Any()).Returns(async call => + { + await gate.Task; + h.Models.HasAllExpectedLocalAssets(call.Arg()).Returns(true); + }); + await h.Vm.StartListeningAsync(); + var download = h.Vm.DownloadModelsCommand.ExecuteAsync(null); + + await h.Vm.DisposeAsync(); + gate.SetResult(); + await download; + + foreach (var model in ModelCatalog.Speech) + await h.Downloads.Received(1).StartAsync(model); + Assert.Equal(0, h.Voice.Starts); + Assert.False(h.Vm.IsListening); + await h.Cleanup.DisposeAsync(); + await h.Scheduler.DisposeAsync(); + } + + [Fact] + public async Task ModelsThatBecomeNeededMidDownload_AreDownloadedToo_WithoutAFalseFailure() + { + await using var h = new Harness(modelsInstalled: false); + var gate = new TaskCompletionSource(); + var first = true; + h.Downloads.StartAsync(Arg.Any()).Returns(async call => + { + var model = call.Arg(); + if (first) + { + first = false; + await gate.Task; + } + h.Models.HasAllExpectedLocalAssets(model).Returns(true); + }); + var download = h.Vm.DownloadModelsCommand.ExecuteAsync(null); + + await h.Vm.StartListeningAsync(); + gate.SetResult(); + await download; + + foreach (var model in ModelCatalog.Speech) + await h.Downloads.Received(1).StartAsync(model); + Assert.NotEqual(BannerKind.DownloadFailed, h.Vm.Banner.Kind); + Assert.True(h.Vm.IsListening); + } + + [Fact] + public async Task ADownloadThatInstallsNothing_EndsWithAFailure_NotAnEndlessLoop() + { + await using var h = new Harness(modelsInstalled: false); + h.Downloads.StartAsync(Arg.Any()).Returns(Task.CompletedTask); + + await h.Vm.DownloadModelsCommand.ExecuteAsync(null); + + Assert.Equal(BannerKind.DownloadFailed, h.Vm.Banner.Kind); + Assert.Null(h.Vm.DownloadProgress); + Assert.False(h.Vm.ModelsReady); + } + + [Fact] + public async Task ASummonThatFindsTheDownloadRunning_JoinsItInTheBackground() + { + await using var h = new Harness(modelsInstalled: false); + h.Downloads.GetState(h.TranslatorModel.Id).Returns( + new ModelDownloadState(h.TranslatorModel.Id, ModelDownloadStatus.Downloading, 40, null)); + h.Downloads.StartAsync(Arg.Any()).Returns(new TaskCompletionSource().Task); + + h.Vm.Begin(new ComposerStart( + ComposerOutput.Translate, TargetLanguage.English, ComposerOutput.Clean, TargetLanguage.English, null)); + + Assert.Equal(BannerKind.None, h.Vm.Banner.Kind); + Assert.NotNull(h.Vm.DownloadProgress); + await h.Downloads.Received(1).StartAsync(h.Runtime); + } +} diff --git a/tests/LiveLingo.Desktop.Tests/Composer/ComposerGeometryLogTests.cs b/tests/LiveLingo.Desktop.Tests/Composer/ComposerGeometryLogTests.cs new file mode 100644 index 0000000..00da91a --- /dev/null +++ b/tests/LiveLingo.Desktop.Tests/Composer/ComposerGeometryLogTests.cs @@ -0,0 +1,99 @@ +using Avalonia; +using LiveLingo.Desktop.Composer; +using LiveLingo.Desktop.Tests.Web; + +namespace LiveLingo.Desktop.Tests.Composer; + +public sealed class ComposerGeometryLogTests +{ + private static ComposerGeometry Geometry( + double height = 560, double clientHeight = 560, int y = 200, double maxHeight = 900, double? userHeight = null) => + new(656, height, new Size(656, clientHeight), 400, y, maxHeight, null, userHeight, 540, false, false, 1.25, + new PixelRect(0, 0, 1920, 1040)); + + private readonly ListLogger _lines = new(); + + private ComposerGeometryLog Log() => new(_lines); + + [Fact] + public void EachSummon_HasItsOwnNumber_AndEveryLineCarriesTheGeometry() + { + var log = Log(); + + log.SummonStarted("Slack rect=(10,10,800x600)", Geometry()); + log.Sized(620, 540, Geometry()); + log.SummonStarted("none", Geometry()); + + Assert.Equal(3, _lines.Lines.Count); + Assert.Contains("summon=1 event=summon target=Slack", _lines.Lines[0]); + Assert.Contains("summon=1 event=size page=620x540css", _lines.Lines[1]); + Assert.Contains("summon=2 event=summon target=none", _lines.Lines[2]); + Assert.All(_lines.Lines, line => + { + Assert.Contains("window=656x560dip", line); + Assert.Contains("work=(0,0,1920x1040)px", line); + Assert.Contains("scale=1.25", line); + }); + } + + [Fact] + public void AWindowCapLowerThanThePage_IsAWarning_OnlyWhileTheWindowFollowsThePage() + { + var log = Log(); + + log.Sized(620, 1200, Geometry(maxHeight: 900)); + log.Sized(620, 1200, Geometry(maxHeight: 900, userHeight: 700)); + + Assert.Single(_lines.Lines, line => line.StartsWith("Warning:") && line.Contains("PROBLEM=content-taller-than-window-cap")); + } + + [Fact] + public void ASystemThatChangedTheSize_IsAWarning_AndAnObeyedRequestIsNot() + { + var log = Log(); + + log.SystemResized(Geometry(height: 560, clientHeight: 560)); + log.SystemResized(Geometry(height: 560, clientHeight: 280)); + + Assert.Equal(["Information", "Warning"], _lines.Lines.Select(line => line[..line.IndexOf(':')])); + Assert.Contains("PROBLEM=system-changed-the-size", _lines.Lines[1]); + Assert.Contains("client=656x280", _lines.Lines[1]); + } + + [Theory] + [InlineData(200, false)] + [InlineData(700, true)] + public void AWindowBelowTheWorkingArea_IsAWarning(int y, bool outside) + { + var log = Log(); + + log.CheckOnScreen(Geometry(y: y)); + + Assert.Equal(outside, _lines.Lines.Any(line => line.Contains("PROBLEM=window-outside-the-working-area"))); + } + + [Fact] + public void ABurst_IsLoggedOnce() + { + var log = Log(); + + for (var i = 0; i < 20; i++) + log.InfoThrottled("user-resize", Geometry(), "asked=700x-css"); + + Assert.Single(_lines.Lines); + } + + [Fact] + public void ARepeatedProblem_IsLoggedOnceAWhile_AndADifferentOneStillIs() + { + var log = Log(); + + for (var i = 0; i < 20; i++) + log.SystemResized(Geometry(height: 560, clientHeight: 280)); + log.Sized(620, 1200, Geometry(maxHeight: 900)); + + Assert.Equal( + ["system-changed-the-size", "content-taller-than-window-cap"], + _lines.Lines.Where(line => line.StartsWith("Warning:")).Select(line => line.Split("PROBLEM=")[1].Split(' ')[0])); + } +} diff --git a/tests/LiveLingo.Desktop.Tests/Composer/ComposerHarness.cs b/tests/LiveLingo.Desktop.Tests/Composer/ComposerHarness.cs index 4bab504..5c1357c 100644 --- a/tests/LiveLingo.Desktop.Tests/Composer/ComposerHarness.cs +++ b/tests/LiveLingo.Desktop.Tests/Composer/ComposerHarness.cs @@ -216,6 +216,7 @@ public Harness( Scheduler = new TranslationScheduler(Session, Translator, TargetLanguage.English, TimeSpan.FromHours(1)); Cleanup = new CleanupScheduler(Session, Cleaner, [], contextChars: 300); Models.HasAllExpectedLocalAssets(Arg.Any()).Returns(modelsInstalled); + Downloads.GetState(Arg.Any()).Returns(call => new ModelDownloadState(call.Arg(), ModelDownloadStatus.Idle, 0, null)); Platform.TextInjector.Returns(Injector); Platform.Clipboard.Returns(Clipboard); Platform.WindowTracker.Returns(Tracker); diff --git a/tests/LiveLingo.Desktop.Tests/Composer/ComposerTextsTests.cs b/tests/LiveLingo.Desktop.Tests/Composer/ComposerTextsTests.cs index e33fbb3..cad3cdd 100644 --- a/tests/LiveLingo.Desktop.Tests/Composer/ComposerTextsTests.cs +++ b/tests/LiveLingo.Desktop.Tests/Composer/ComposerTextsTests.cs @@ -210,7 +210,7 @@ public void BannerMessage_IsLocalized(BannerKind kind, string detail, double pro [Theory] [InlineData(BannerKind.ModelMissing, BannerTone.Info, new[] { BannerAction.Download })] - [InlineData(BannerKind.Downloading, BannerTone.Info, new[] { BannerAction.Dismiss })] + [InlineData(BannerKind.Downloading, BannerTone.Info, new[] { BannerAction.Background })] [InlineData(BannerKind.DownloadFailed, BannerTone.Bad, new[] { BannerAction.Download })] [InlineData(BannerKind.TargetLost, BannerTone.Bad, new[] { BannerAction.Retarget, BannerAction.Copy })] [InlineData(BannerKind.MicrophoneDenied, BannerTone.Warn, new[] { BannerAction.OpenMicrophoneSettings, BannerAction.Dismiss })] @@ -227,7 +227,7 @@ public void Banner_HasAToneAndTheActionsOfThePrototype(BannerKind kind, BannerTo [Theory] [InlineData(BannerAction.Dismiss, BannerKind.NoSound, "[composer.banner.dismiss]")] - [InlineData(BannerAction.Dismiss, BannerKind.Downloading, "[composer.banner.background]")] + [InlineData(BannerAction.Background, BannerKind.Downloading, "[composer.banner.background]")] [InlineData(BannerAction.Download, BannerKind.ModelMissing, "[composer.banner.download]")] [InlineData(BannerAction.Download, BannerKind.DownloadFailed, "[composer.banner.retry]")] [InlineData(BannerAction.OpenMicrophoneSettings, BannerKind.MicrophoneDenied, "[composer.banner.openOs]")] diff --git a/tests/LiveLingo.Desktop.Tests/Web/ComposerIntentsTests.cs b/tests/LiveLingo.Desktop.Tests/Web/ComposerIntentsTests.cs index 932d7c4..d8b6ecb 100644 --- a/tests/LiveLingo.Desktop.Tests/Web/ComposerIntentsTests.cs +++ b/tests/LiveLingo.Desktop.Tests/Web/ComposerIntentsTests.cs @@ -117,6 +117,18 @@ public async Task Size_IsPassedOnToTheWindow() Assert.Equal((656, 321), (seen!.Width, seen.Height)); } + [Fact] + public async Task APageThatIsCutOff_IsReportedToTheWindowForTheLog() + { + await using var rig = new Rig(); + ClippedIntent? seen = null; + rig.Sut.Clipped += clipped => seen = clipped; + + rig.Do(new ClippedIntent(656, 300, 656, 560) { V = 1 }); + + Assert.Equal((656, 300, 656, 560), (seen!.ViewportWidth, seen.ViewportHeight, seen.ContentWidth, seen.ContentHeight)); + } + [Fact] public async Task AnOutputThatIsNotOffered_IsRefused() { diff --git a/tests/LiveLingo.Desktop.Tests/Web/ComposerScenarios.cs b/tests/LiveLingo.Desktop.Tests/Web/ComposerScenarios.cs index 3d10b53..f300a46 100644 --- a/tests/LiveLingo.Desktop.Tests/Web/ComposerScenarios.cs +++ b/tests/LiveLingo.Desktop.Tests/Web/ComposerScenarios.cs @@ -115,6 +115,13 @@ internal static class ComposerScenarios return State(h); }, ["downloading"] = () => WithBanner(new BannerState(BannerKind.Downloading, "", 46)), + ["download-background"] = async () => + { + await using var h = new Harness(modelsInstalled: false); + h.Vm.Banner = BannerState.None; + h.Vm.DownloadProgress = 46; + return State(h); + }, ["download-failed"] = () => WithBanner(new BannerState(BannerKind.DownloadFailed, "network error")), ["slow"] = Slow, ["timeline"] = () => Imported(Segments("今天的周会", "下周发布", "风险有两个")), diff --git a/tests/LiveLingo.Desktop.Tests/Web/WebIntentTests.cs b/tests/LiveLingo.Desktop.Tests/Web/WebIntentTests.cs index 62bb321..4237cdc 100644 --- a/tests/LiveLingo.Desktop.Tests/Web/WebIntentTests.cs +++ b/tests/LiveLingo.Desktop.Tests/Web/WebIntentTests.cs @@ -47,6 +47,7 @@ public sealed class WebIntentTests [InlineData("""{"v":1,"name":"forgetKey"}""", typeof(ForgetKeyIntent))] [InlineData("""{"v":1,"name":"removeLegacyModels"}""", typeof(RemoveLegacyModelsIntent))] [InlineData("""{"v":1,"name":"checkUpdates"}""", typeof(CheckUpdatesIntent))] + [InlineData("""{"v":1,"name":"clipped","viewportWidth":656,"viewportHeight":300,"contentWidth":656,"contentHeight":560}""", typeof(ClippedIntent))] [InlineData("""{"v":1,"name":"restartToUpdate"}""", typeof(RestartToUpdateIntent))] [InlineData("""{"v":1,"name":"openDownloads"}""", typeof(OpenDownloadsIntent))] [InlineData("""{"v":1,"name":"restoreDefaults"}""", typeof(RestoreDefaultsIntent))] @@ -94,6 +95,7 @@ public void Parse_ReadsTheFieldsOfAnIntent() [InlineData("""{"v":1,"name":"banner","action":"deleteEverything"}""", "JsonException")] [InlineData("""{"v":1,"name":"retry","i":-1}""", "invalid")] [InlineData("""{"v":1,"name":"size","width":0,"height":300}""", "invalid")] + [InlineData("""{"v":1,"name":"clipped","viewportWidth":-1,"viewportHeight":300,"contentWidth":656,"contentHeight":560}""", "invalid")] [InlineData("""{"v":1,"name":"size","width":656,"height":99999}""", "invalid")] [InlineData("""{"v":1,"name":"drag","x":1}""", "JsonException")] [InlineData("""{"v":1,"name":"resize"}""", "JsonException")] diff --git a/tests/LiveLingo.Web.Tests/ComposerRenderTests.cs b/tests/LiveLingo.Web.Tests/ComposerRenderTests.cs index de66c35..d1ae7a8 100644 --- a/tests/LiveLingo.Web.Tests/ComposerRenderTests.cs +++ b/tests/LiveLingo.Web.Tests/ComposerRenderTests.cs @@ -283,6 +283,18 @@ public async Task DownloadingBanner_ShowsAProgressBar(string engine) await using var page = await browsers.OpenAsync(engine, "downloading"); Assert.Equal(1, await page.Page.Locator("#banner progress").CountAsync()); + Assert.Equal(0, await page.Page.Locator("#download").CountAsync()); + } + + [Theory] + [MemberData(nameof(Engines))] + public async Task ADownloadInTheBackground_ShowsItsProgressInTheFooter_AndNoBanner(string engine) + { + await using var page = await browsers.OpenAsync(engine, "download-background"); + + Assert.Equal(0, await page.Page.Locator("#banner").CountAsync()); + Assert.Equal("46", await page.Page.Locator("#download progress").GetAttributeAsync("value")); + Assert.Contains("46%", await page.Page.Locator("#download").InnerTextAsync()); } [Theory] diff --git a/tests/LiveLingo.Web.Tests/Fixtures/clean-ready.json b/tests/LiveLingo.Web.Tests/Fixtures/clean-ready.json index a86fce2..679feed 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/clean-ready.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/clean-ready.json @@ -81,6 +81,7 @@ "text": "1 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/clearing.json b/tests/LiveLingo.Web.Tests/Fixtures/clearing.json index 4834eb0..2c6e952 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/clearing.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/clearing.json @@ -81,6 +81,7 @@ "text": "正在停止录音并清空…", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/cloud-ctrl-enter.json b/tests/LiveLingo.Web.Tests/Fixtures/cloud-ctrl-enter.json index 84e934e..b6069e1 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/cloud-ctrl-enter.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/cloud-ctrl-enter.json @@ -95,6 +95,7 @@ "text": "2 句 · 含云端处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/copy-only.json b/tests/LiveLingo.Web.Tests/Fixtures/copy-only.json index bf01d65..5b48e86 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/copy-only.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/copy-only.json @@ -95,6 +95,7 @@ "text": "2 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "复制", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/download-background.json b/tests/LiveLingo.Web.Tests/Fixtures/download-background.json new file mode 100644 index 0000000..a980d0e --- /dev/null +++ b/tests/LiveLingo.Web.Tests/Fixtures/download-background.json @@ -0,0 +1,237 @@ +{ + "rev": 1, + "dest": { + "text": "Slack", + "kind": "target" + }, + "privacy": { + "text": "本地", + "cloud": false, + "aria": "全部本地处理" + }, + "output": { + "value": "translate", + "pill": "→ English", + "globe": true, + "menu": [ + { + "id": "clean", + "label": "保持中文(整理)", + "checked": false, + "enabled": true + }, + { + "id": "translate", + "label": "翻译成 English", + "checked": true, + "enabled": true + }, + { + "id": "original", + "label": "原话(不整理)", + "checked": false, + "enabled": true + } + ], + "swap": "语言:中文 → English\u3000⇄ 互换", + "note": null + }, + "banner": null, + "source": { + "mode": "edit", + "text": "", + "sourceRev": 1, + "editable": true, + "label": "原话", + "routeChip": false, + "hint": true, + "lines": [], + "resume": null, + "remembered": null, + "canClear": false + }, + "result": { + "visible": false, + "label": "将写入 · English", + "rows": [], + "editText": "" + }, + "footer": { + "mic": { + "recording": false, + "aria": "开始听写(⌘D)", + "enabled": true + }, + "status": { + "text": "", + "tone": "muted" + }, + "download": { + "text": "模型后台下载中 46%", + "percent": 46 + }, + "copyEnabled": false, + "commit": { + "label": "写入 Slack", + "enabled": false, + "armed": false, + "written": false, + "aria": "写入" + } + }, + "layout": { + "wide": false + }, + "options": { + "animations": true, + "theme": "Auto", + "commitKey": "enter", + "commitKeyCap": "↵", + "newlineKeyCap": "⇧↵", + "modifier": "meta", + "shortcuts": [ + { + "id": "dictate", + "code": "KeyD", + "shift": false, + "cap": "⌘D" + }, + { + "id": "clean", + "code": "Digit1", + "shift": false, + "cap": "⌘1" + }, + { + "id": "translate", + "code": "Digit2", + "shift": false, + "cap": "⌘2" + }, + { + "id": "original", + "code": "Digit3", + "shift": false, + "cap": "⌘3" + }, + { + "id": "swap", + "code": "KeyL", + "shift": false, + "cap": "⌘L" + }, + { + "id": "copy", + "code": "KeyC", + "shift": true, + "cap": "⇧⌘C" + }, + { + "id": "new", + "code": "KeyN", + "shift": false, + "cap": "⌘N" + }, + { + "id": "recent", + "code": "KeyR", + "shift": false, + "cap": "⌘R" + }, + { + "id": "import", + "code": "KeyO", + "shift": false, + "cap": "⌘O" + }, + { + "id": "export", + "code": "KeyS", + "shift": false, + "cap": "⌘S" + }, + { + "id": "zoom", + "code": "KeyE", + "shift": false, + "cap": "⌘E" + }, + { + "id": "settings", + "code": "Comma", + "shift": false, + "cap": "⌘," + } + ] + }, + "menus": { + "target": [ + { + "id": "target", + "label": "Slack 输入框", + "checked": true, + "enabled": true + }, + { + "id": "copyOnly", + "label": "没有输入框 · 仅复制", + "checked": false, + "enabled": true + } + ], + "more": [ + { + "id": "new", + "label": "新草稿", + "checked": false, + "enabled": true + }, + { + "id": "recent", + "label": "最近草稿", + "checked": false, + "enabled": true + }, + { + "id": "import", + "label": "导入录音…", + "checked": false, + "enabled": true + }, + { + "id": "export", + "label": "导出…", + "checked": false, + "enabled": true + }, + { + "id": "wide", + "label": "宽屏对照", + "checked": false, + "enabled": true + }, + { + "id": "whole", + "label": "整段处理(保留格式)", + "checked": false, + "enabled": true + }, + { + "id": "settings", + "label": "设置", + "checked": false, + "enabled": true + } + ] + }, + "dialogs": { + "recent": [], + "canRestore": true, + "export": { + "txt": true, + "markdown": true, + "srt": false, + "last": null + } + } +} diff --git a/tests/LiveLingo.Web.Tests/Fixtures/download-failed.json b/tests/LiveLingo.Web.Tests/Fixtures/download-failed.json index 5b95b52..6c3036b 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/download-failed.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/download-failed.json @@ -77,6 +77,7 @@ "text": "", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/downloading.json b/tests/LiveLingo.Web.Tests/Fixtures/downloading.json index 8b24049..7e04063 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/downloading.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/downloading.json @@ -43,7 +43,7 @@ "progress": 46, "actions": [ { - "id": "dismiss", + "id": "background", "label": "后台下载" } ] @@ -77,6 +77,7 @@ "text": "", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/editing.json b/tests/LiveLingo.Web.Tests/Fixtures/editing.json index 1fb4ade..139cdee 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/editing.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/editing.json @@ -95,6 +95,7 @@ "text": "2 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/empty.json b/tests/LiveLingo.Web.Tests/Fixtures/empty.json index 844e7ca..5a9889f 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/empty.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/empty.json @@ -66,6 +66,7 @@ "text": "", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/export-last.json b/tests/LiveLingo.Web.Tests/Fixtures/export-last.json index e91bce5..0e3395e 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/export-last.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/export-last.json @@ -95,6 +95,7 @@ "text": "2 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/gate-warning.json b/tests/LiveLingo.Web.Tests/Fixtures/gate-warning.json index 8b2587c..4ac3294 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/gate-warning.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/gate-warning.json @@ -95,6 +95,7 @@ "text": "2 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/listening.json b/tests/LiveLingo.Web.Tests/Fixtures/listening.json index f8a7d2b..53360fd 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/listening.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/listening.json @@ -92,6 +92,7 @@ "text": "正在听 · 0:00 · 点麦克风或按 ⌘D 结束", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/mic-denied.json b/tests/LiveLingo.Web.Tests/Fixtures/mic-denied.json index fe4cddc..c0bdc9c 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/mic-denied.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/mic-denied.json @@ -81,6 +81,7 @@ "text": "", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/model-missing.json b/tests/LiveLingo.Web.Tests/Fixtures/model-missing.json index 414aa61..688e9c4 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/model-missing.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/model-missing.json @@ -106,6 +106,7 @@ "text": "正在翻译…", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/no-cleanup.json b/tests/LiveLingo.Web.Tests/Fixtures/no-cleanup.json index 295ca59..11a691f 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/no-cleanup.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/no-cleanup.json @@ -60,6 +60,7 @@ "text": "2 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/no-microphone.json b/tests/LiveLingo.Web.Tests/Fixtures/no-microphone.json index 4c7a197..ba11c76 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/no-microphone.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/no-microphone.json @@ -81,6 +81,7 @@ "text": "", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/no-speech.json b/tests/LiveLingo.Web.Tests/Fixtures/no-speech.json index e9124e6..5ffaeba 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/no-speech.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/no-speech.json @@ -77,6 +77,7 @@ "text": "", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/original-ready.json b/tests/LiveLingo.Web.Tests/Fixtures/original-ready.json index e5f7af3..836c938 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/original-ready.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/original-ready.json @@ -66,6 +66,7 @@ "text": "2 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/polishing.json b/tests/LiveLingo.Web.Tests/Fixtures/polishing.json index 631d234..635c7b4 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/polishing.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/polishing.json @@ -95,6 +95,7 @@ "text": "正在润色全文… 此时写入会等润色完成", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/reduced-motion.json b/tests/LiveLingo.Web.Tests/Fixtures/reduced-motion.json index 43b575a..d4c54df 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/reduced-motion.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/reduced-motion.json @@ -95,6 +95,7 @@ "text": "2 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/remembered.json b/tests/LiveLingo.Web.Tests/Fixtures/remembered.json index ae0e48b..3b3952d 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/remembered.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/remembered.json @@ -69,6 +69,7 @@ "text": "", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/resume-draft.json b/tests/LiveLingo.Web.Tests/Fixtures/resume-draft.json index f2c5bfc..eb41b82 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/resume-draft.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/resume-draft.json @@ -69,6 +69,7 @@ "text": "", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/row-failed.json b/tests/LiveLingo.Web.Tests/Fixtures/row-failed.json index 8694338..b1b4ee4 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/row-failed.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/row-failed.json @@ -95,6 +95,7 @@ "text": "有句子未完成 · 可重试或改用原话", "tone": "warn" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/slow.json b/tests/LiveLingo.Web.Tests/Fixtures/slow.json index 3995b1c..e062a19 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/slow.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/slow.json @@ -110,6 +110,7 @@ "text": "正在翻译…", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/speech-preparing.json b/tests/LiveLingo.Web.Tests/Fixtures/speech-preparing.json index 33e6754..f0e4b32 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/speech-preparing.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/speech-preparing.json @@ -66,6 +66,7 @@ "text": "语音模型准备中,稍后可用…", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/target-lost.json b/tests/LiveLingo.Web.Tests/Fixtures/target-lost.json index 492ec0d..162cc2b 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/target-lost.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/target-lost.json @@ -110,6 +110,7 @@ "text": "2 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/timeline.json b/tests/LiveLingo.Web.Tests/Fixtures/timeline.json index 2bc3162..0f3010a 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/timeline.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/timeline.json @@ -125,6 +125,7 @@ "text": "3 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/translate-armed.json b/tests/LiveLingo.Web.Tests/Fixtures/translate-armed.json index 9e9ec5a..6c73582 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/translate-armed.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/translate-armed.json @@ -95,6 +95,7 @@ "text": "处理完成后自动写入 · Esc 取消", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "完成后写入", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/translate-pending.json b/tests/LiveLingo.Web.Tests/Fixtures/translate-pending.json index 597fadb..d1124ba 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/translate-pending.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/translate-pending.json @@ -95,6 +95,7 @@ "text": "正在翻译…", "tone": "muted" }, + "download": null, "copyEnabled": false, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/translate-ready.json b/tests/LiveLingo.Web.Tests/Fixtures/translate-ready.json index cb1def0..2245410 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/translate-ready.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/translate-ready.json @@ -95,6 +95,7 @@ "text": "2 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/whole-wide.json b/tests/LiveLingo.Web.Tests/Fixtures/whole-wide.json index d3bbea0..21de262 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/whole-wide.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/whole-wide.json @@ -81,6 +81,7 @@ "text": "1 句 · 本地处理", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "写入 Slack", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/write-failed.json b/tests/LiveLingo.Web.Tests/Fixtures/write-failed.json index 7d46bf9..a5ab03c 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/write-failed.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/write-failed.json @@ -101,6 +101,7 @@ "text": "已写入", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "已写入", diff --git a/tests/LiveLingo.Web.Tests/Fixtures/written.json b/tests/LiveLingo.Web.Tests/Fixtures/written.json index 3dfa3a5..efd97e9 100644 --- a/tests/LiveLingo.Web.Tests/Fixtures/written.json +++ b/tests/LiveLingo.Web.Tests/Fixtures/written.json @@ -95,6 +95,7 @@ "text": "已写入", "tone": "muted" }, + "download": null, "copyEnabled": true, "commit": { "label": "已写入",