From 3303786182b762ed7043dff00ed89598825d907b Mon Sep 17 00:00:00 2001 From: idevlab Date: Fri, 26 Jun 2026 22:28:15 +0800 Subject: [PATCH] Update model download size estimates --- Sources/Config/DownloadProgressInfo.swift | 31 +++++- Sources/Config/ModelCatalog.swift | 12 ++- Sources/Config/ModelCatalogASR.swift | 2 +- .../ModelCatalogDownloadEstimates.swift | 77 +++++++++++++-- Sources/LLM/LLMEngine.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 34 +++---- .../zh-Hans.lproj/Localizable.strings | 34 +++---- Sources/Speech/WhisperEngine.swift | 6 +- Tests/OpenTypeTests/UtilityTests.swift | 94 +++++++++++++++++++ 9 files changed, 239 insertions(+), 53 deletions(-) diff --git a/Sources/Config/DownloadProgressInfo.swift b/Sources/Config/DownloadProgressInfo.swift index 8a80f634..04a29d46 100644 --- a/Sources/Config/DownloadProgressInfo.swift +++ b/Sources/Config/DownloadProgressInfo.swift @@ -7,6 +7,21 @@ struct DownloadProgressInfo: Equatable, Sendable { let totalBytes: Int64 let speedBytesPerSecond: Double + init( + fraction: Double, + elapsedSeconds: TimeInterval, + completedBytes: Int64, + totalBytes: Int64, + speedBytesPerSecond: Double + ) { + let completedBytes = max(completedBytes, 0) + self.fraction = Self.clampFraction(fraction) + self.elapsedSeconds = elapsedSeconds.isFinite ? max(elapsedSeconds, 0) : 0 + self.completedBytes = completedBytes + self.totalBytes = totalBytes > 0 ? max(totalBytes, completedBytes) : 0 + self.speedBytesPerSecond = speedBytesPerSecond.isFinite ? max(speedBytesPerSecond, 0) : 0 + } + var percentText: String { "\(Int(clampedFraction * 100))%" } @@ -78,7 +93,7 @@ final class DownloadProgressTracker: @unchecked Sendable { init(startDate: Date = Date(), initialBytes: Int64 = 0) { self.startDate = startDate lastTime = startDate - lastBytes = initialBytes + lastBytes = max(initialBytes, 0) } func update(progress: Progress, fraction: Double? = nil) -> DownloadProgressInfo { @@ -90,17 +105,27 @@ final class DownloadProgressTracker: @unchecked Sendable { } func update(completedBytes rawCompleted: Int64, totalBytes rawTotal: Int64, fraction: Double? = nil) -> DownloadProgressInfo { + update(completedBytes: rawCompleted, totalBytes: rawTotal, fraction: fraction, at: Date()) + } + + func update( + completedBytes rawCompleted: Int64, + totalBytes rawTotal: Int64, + fraction: Double? = nil, + at now: Date + ) -> DownloadProgressInfo { lock.lock() defer { lock.unlock() } let completedBytes = max(rawCompleted, 0) - let totalBytes = max(rawTotal, 0) - let now = Date() + let totalBytes = rawTotal > 0 ? max(rawTotal, completedBytes) : 0 let sampleElapsed = now.timeIntervalSince(lastTime) if sampleElapsed > 0.5 { let deltaBytes = completedBytes - lastBytes if deltaBytes >= 0 { lastSpeedBytesPerSecond = Double(deltaBytes) / sampleElapsed + } else { + lastSpeedBytesPerSecond = 0 } lastTime = now lastBytes = completedBytes diff --git a/Sources/Config/ModelCatalog.swift b/Sources/Config/ModelCatalog.swift index b866591c..9bf7cdf0 100644 --- a/Sources/Config/ModelCatalog.swift +++ b/Sources/Config/ModelCatalog.swift @@ -210,7 +210,8 @@ final class ModelCatalog: ObservableObject { whisperModels[idx].downloadProgress = 0 do { - let tracker = DownloadProgressTracker() + let modelDir = ModelStorage.whisperVariantDir(id) + let tracker = DownloadProgressTracker(initialBytes: ModelStorage.directorySize(at: modelDir)) _ = try await WhisperKit.download( variant: id, @@ -218,7 +219,12 @@ final class ModelCatalog: ObservableObject { progressCallback: { [weak self] p in Task { @MainActor in guard let self, let i = self.whisperModels.firstIndex(where: { $0.id == id }) else { return } - let info = tracker.update(progress: p) + let completedBytes = ModelStorage.directorySize(at: modelDir) + let info = tracker.update( + completedBytes: completedBytes > 0 ? completedBytes : p.completedUnitCount, + totalBytes: p.totalUnitCount, + fraction: p.fractionCompleted + ) self.whisperModels[i].downloadProgress = info.fraction self.whisperModels[i].downloadDetail = info.detailText } @@ -279,9 +285,9 @@ final class ModelCatalog: ObservableObject { llmModels[idx].downloadProgress = 0 do { - let tracker = DownloadProgressTracker() let estimatedTotalBytes = estimatedLLMDownloadBytes(id) ?? 0 let repoDir = ModelStorage.hubModelRepoDir(id) + let tracker = DownloadProgressTracker(initialBytes: ModelStorage.directorySize(at: repoDir)) let config = ModelConfiguration(id: id) _ = try await LLMModelFactory.shared.loadContainer( from: MLXModelLoading.downloader, diff --git a/Sources/Config/ModelCatalogASR.swift b/Sources/Config/ModelCatalogASR.swift index 48fdf0e1..e68b5485 100644 --- a/Sources/Config/ModelCatalogASR.swift +++ b/Sources/Config/ModelCatalogASR.swift @@ -82,8 +82,8 @@ extension ModelCatalog { try await ensureMimoRepository() } if !asrModelFilesAreComplete(id) { + let tracker = DownloadProgressTracker(startDate: startedAt, initialBytes: asrRepoSize(id)) for (repoIndex, repoID) in repos.enumerated() { - let tracker = DownloadProgressTracker(startDate: startedAt) _ = try await api.snapshot(from: ModelStorage.hubModelRepo(repoID)) { [weak self] progress in Task { @MainActor in guard let self, let i = self.asrModels.firstIndex(where: { $0.id == id }) else { return } diff --git a/Sources/Config/ModelCatalogDownloadEstimates.swift b/Sources/Config/ModelCatalogDownloadEstimates.swift index 0007bd57..d6d7eeb9 100644 --- a/Sources/Config/ModelCatalogDownloadEstimates.swift +++ b/Sources/Config/ModelCatalogDownloadEstimates.swift @@ -3,30 +3,89 @@ import Foundation @MainActor extension ModelCatalog { func estimatedLLMDownloadBytes(_ id: String) -> Int64? { + if let bytes = Self.defaultDownloadEstimateBytes(for: id) { return bytes } guard let model = llmModels.first(where: { $0.id == id }) else { return nil } return Self.estimatedDownloadBytes(from: model.hint) } func estimatedASRDownloadBytes(_ id: String) -> Int64? { + if let bytes = Self.defaultDownloadEstimateBytes(for: id) { return bytes } guard let model = asrModels.first(where: { $0.id == id }) else { return nil } return Self.estimatedDownloadBytes(from: model.hint) } - static func estimatedDownloadBytes(from text: String) -> Int64? { - let pattern = #"([0-9]+(?:\.[0-9]+)?)\s*(GB|MB)"# - guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { - return nil - } + static func defaultDownloadEstimateBytes(for id: String) -> Int64? { + defaultDownloadEstimateBytes[id] + } + static func estimatedDownloadBytes(from text: String) -> Int64? { let range = NSRange(text.startIndex.. 0, bytes <= Double(Int64.max) else { return nil } + return Int64(bytes) + } + + private static let defaultDownloadEstimateBytes: [String: Int64] = [ + "mlx-community/Qwen3.5-0.8B-MLX-4bit": 652_027_143, + "mlx-community/Qwen3.5-2B-4bit": 1_749_079_691, + "mlx-community/Qwen3.5-9B-5bit": 7_096_163_574, + "mlx-community/Qwen3-30B-A3B-4bit": 17_190_783_781, + "mlx-community/Qwen3.5-35B-A3B-4bit": 20_418_622_319, + "mlx-community/Qwen2.5-0.5B-Instruct-4bit": 289_598_797, + "mlx-community/Qwen2.5-1.5B-Instruct-4bit": 880_169_797, + "mlx-community/Qwen2.5-3B-Instruct-4bit": 1_747_849_050, + "mlx-community/Qwen3-0.6B-4bit": 351_383_618, + "mlx-community/Qwen3-1.7B-4bit": 984_013_244, + "mlx-community/Qwen3-4B-4bit": 2_278_969_756, + "mlx-community/gemma-4-e2b-it-4bit": 3_613_528_388, + "mlx-community/gemma-4-e4b-it-4bit": 5_249_809_327, + "mlx-community/gemma-3-1b-it-4bit": 771_860_852, + "mlx-community/gemma-3-4b-it-4bit": 3_439_894_985, + "mlx-community/gemma-3-12b-it-4bit": 8_068_018_787, + "mlx-community/Llama-4-Scout-17B-16E-Instruct-4bit": 61_143_654_248, + "mlx-community/Llama-4-Maverick-17B-128E-Instruct-4bit": 225_923_469_800, + LocalASRConfiguration.qwen3DefaultModel: 4_080_707_826, + LocalASRConfiguration.mimoDefaultModel: 35_997_080_271, + ] + + private static let downloadEstimateRegex = try! NSRegularExpression( + pattern: #"([0-9]+(?:[\.,][0-9]+)?)\s*(TiB|GiB|MiB|KiB|TB|GB|MB|KB|T|G|M|K)(?![A-Za-z])"#, + options: [.caseInsensitive] + ) + + private static func parseDownloadEstimateValue(_ rawValue: String) -> Double? { + if rawValue.contains("."), rawValue.contains(",") { + return Double(rawValue.replacingOccurrences(of: ",", with: "")) + } + if let commaIndex = rawValue.firstIndex(of: ",") { + let fraction = rawValue[rawValue.index(after: commaIndex)...] + let normalized = fraction.count == 3 + ? rawValue.replacingOccurrences(of: ",", with: "") + : rawValue.replacingOccurrences(of: ",", with: ".") + return Double(normalized) + } + return Double(rawValue) + } + + private static func downloadEstimateMultiplier(for rawUnit: String) -> Double? { + switch rawUnit { + case "TIB": return pow(1024, 4) + case "GIB": return pow(1024, 3) + case "MIB": return pow(1024, 2) + case "KIB": return 1024 + case "TB", "T": return 1_000_000_000_000 + case "GB", "G": return 1_000_000_000 + case "MB", "M": return 1_000_000 + case "KB", "K": return 1_000 + default: return nil + } } } diff --git a/Sources/LLM/LLMEngine.swift b/Sources/LLM/LLMEngine.swift index 0327cd2b..d1e403c8 100644 --- a/Sources/LLM/LLMEngine.swift +++ b/Sources/LLM/LLMEngine.swift @@ -30,9 +30,9 @@ actor LLMEngine { speedBytesPerSecond: 0 )) } else { - let tracker = DownloadProgressTracker() let estimatedTotalBytes = estimatedDownloadBytes ?? 0 let repoDir = ModelStorage.hubModelRepoDir(id) + let tracker = DownloadProgressTracker(initialBytes: ModelStorage.directorySize(at: repoDir)) let config = Self.modelConfiguration(for: id) container = try await LLMModelFactory.shared.loadContainer( from: MLXModelLoading.downloader, diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index 9df6fcc2..bde32ea7 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -164,24 +164,24 @@ "model.last_formatting_value" = "%.2fs"; "model.compiling" = "Compiling…"; "model.loading" = "Loading…"; -"model.smallest" = "Smallest, fastest ~335 MB"; -"model.balanced" = "Balanced ~1 GB"; -"model.best_quality" = "Best quality ~2.3 GB"; -"model.qwen3_fast" = "Fastest, weaker cleanup ~335 MB"; -"model.qwen3_balanced" = "Qwen3, balanced ~1 GB"; +"model.smallest" = "Smallest, fastest ~290 MB"; +"model.balanced" = "Balanced ~880 MB"; +"model.best_quality" = "Best quality ~1.7 GB"; +"model.qwen3_fast" = "Fastest, weaker cleanup ~351 MB"; +"model.qwen3_balanced" = "Qwen3, balanced ~984 MB"; "model.qwen3_quality" = "Qwen3, best quality ~2.3 GB"; -"model.qwen3_moe" = "Qwen3 MoE flagship, recommended ~5 GB"; -"model.qwen35_tiny" = "Qwen3.5, fastest ~620 MB"; -"model.qwen35_fast" = "Recommended for Smart Format ~1.5 GB"; -"model.qwen35_quality" = "Best quality cleanup 5-bit ~5.5 GB"; -"model.qwen35_moe" = "Qwen3.5 MoE flagship ~6 GB"; -"model.gemma_fast" = "Gemma 3, fast ~600 MB"; -"model.gemma_balanced" = "Gemma 3, balanced ~2.5 GB"; -"model.gemma_quality" = "Gemma 3, best quality ~7 GB"; +"model.qwen3_moe" = "Qwen3 MoE flagship, recommended ~17.2 GB"; +"model.qwen35_tiny" = "Qwen3.5, fastest ~652 MB"; +"model.qwen35_fast" = "Recommended for Smart Format ~1.7 GB"; +"model.qwen35_quality" = "Best quality cleanup 5-bit ~7.1 GB"; +"model.qwen35_moe" = "Qwen3.5 MoE flagship ~20.4 GB"; +"model.gemma_fast" = "Gemma 3, fast ~772 MB"; +"model.gemma_balanced" = "Gemma 3, balanced ~3.4 GB"; +"model.gemma_quality" = "Gemma 3, best quality ~8.1 GB"; "model.gemma4_edge" = "Gemma 4 edge model ~3.6 GB"; "model.gemma4_edge_quality" = "Gemma 4 edge quality ~5.2 GB"; -"model.llama_balanced" = "Llama 4 Scout, balanced ~10 GB"; -"model.llama_quality" = "Llama 4 Maverick, high quality ~25 GB"; +"model.llama_balanced" = "Llama 4 Scout, balanced ~61.1 GB"; +"model.llama_quality" = "Llama 4 Maverick, high quality ~225.9 GB"; "model.import_local" = "Import Local Model…"; "model.import_invalid" = "Invalid model directory: config.json not found"; "model.import_failed" = "Import Failed"; @@ -341,8 +341,8 @@ "local_asr.tokenizer_path" = "Audio tokenizer path"; "qwen_asr.config_hint" = "Choose Qwen3-ASR, then click Download to fetch the local model and prepare its Python runtime."; "mimo_asr.config_hint" = "Choose MiMo-V2.5-ASR, then click Download to fetch the model, audio tokenizer, and runtime files."; -"model.qwen3_asr_quality" = "Local ASR through MLX, ~4 GB"; -"model.mimo_asr_quality" = "Local ASR model plus audio tokenizer"; +"model.qwen3_asr_quality" = "Local ASR through MLX, ~4.1 GB"; +"model.mimo_asr_quality" = "Local ASR model plus audio tokenizer ~36 GB"; "model.asr_incomplete" = "Download incomplete"; "model.asr_preparing_runtime" = "Preparing runtime files"; "model.asr_installing_runtime" = "Installing local runtime"; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index 4960a653..6967c72a 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -164,24 +164,24 @@ "model.last_formatting_value" = "%.2f 秒"; "model.compiling" = "编译中…"; "model.loading" = "加载中…"; -"model.smallest" = "最小最快 ~335 MB"; -"model.balanced" = "平衡选择 ~1 GB"; -"model.best_quality" = "质量最佳 ~2.3 GB"; -"model.qwen3_fast" = "极速,整理偏弱 ~335 MB"; -"model.qwen3_balanced" = "Qwen3 均衡 ~1 GB"; +"model.smallest" = "最小最快 ~290 MB"; +"model.balanced" = "平衡选择 ~880 MB"; +"model.best_quality" = "质量最佳 ~1.7 GB"; +"model.qwen3_fast" = "极速,整理偏弱 ~351 MB"; +"model.qwen3_balanced" = "Qwen3 均衡 ~984 MB"; "model.qwen3_quality" = "Qwen3 高质量 ~2.3 GB"; -"model.qwen3_moe" = "Qwen3 MoE 旗舰,推荐 ~5 GB"; -"model.qwen35_tiny" = "Qwen3.5 极速 ~620 MB"; -"model.qwen35_fast" = "智能整理推荐 ~1.5 GB"; -"model.qwen35_quality" = "整理质量最佳 5-bit ~5.5 GB"; -"model.qwen35_moe" = "Qwen3.5 MoE 旗舰 ~6 GB"; -"model.gemma_fast" = "Gemma 3 极速 ~600 MB"; -"model.gemma_balanced" = "Gemma 3 均衡 ~2.5 GB"; -"model.gemma_quality" = "Gemma 3 高质量 ~7 GB"; +"model.qwen3_moe" = "Qwen3 MoE 旗舰,推荐 ~17.2 GB"; +"model.qwen35_tiny" = "Qwen3.5 极速 ~652 MB"; +"model.qwen35_fast" = "智能整理推荐 ~1.7 GB"; +"model.qwen35_quality" = "整理质量最佳 5-bit ~7.1 GB"; +"model.qwen35_moe" = "Qwen3.5 MoE 旗舰 ~20.4 GB"; +"model.gemma_fast" = "Gemma 3 极速 ~772 MB"; +"model.gemma_balanced" = "Gemma 3 均衡 ~3.4 GB"; +"model.gemma_quality" = "Gemma 3 高质量 ~8.1 GB"; "model.gemma4_edge" = "Gemma 4 端侧模型 ~3.6 GB"; "model.gemma4_edge_quality" = "Gemma 4 端侧高质量 ~5.2 GB"; -"model.llama_balanced" = "Llama 4 Scout 均衡 ~10 GB"; -"model.llama_quality" = "Llama 4 Maverick 高质量 ~25 GB"; +"model.llama_balanced" = "Llama 4 Scout 均衡 ~61.1 GB"; +"model.llama_quality" = "Llama 4 Maverick 高质量 ~225.9 GB"; "model.import_local" = "导入本地模型…"; "model.import_invalid" = "无效的模型目录:未找到 config.json"; "model.import_failed" = "导入失败"; @@ -341,8 +341,8 @@ "local_asr.tokenizer_path" = "音频 tokenizer 路径"; "qwen_asr.config_hint" = "选择 Qwen3-ASR 后,请点击下载按钮获取本地模型,并准备对应的 Python 运行环境。"; "mimo_asr.config_hint" = "选择 MiMo-V2.5-ASR 后,请点击下载按钮获取模型、音频 tokenizer 和运行文件。"; -"model.qwen3_asr_quality" = "本地 MLX 语音识别,约 4 GB"; -"model.mimo_asr_quality" = "本地语音识别模型 + 音频 tokenizer"; +"model.qwen3_asr_quality" = "本地 MLX 语音识别,约 4.1 GB"; +"model.mimo_asr_quality" = "本地语音识别模型 + 音频 tokenizer,约 36 GB"; "model.asr_incomplete" = "下载不完整"; "model.asr_preparing_runtime" = "准备运行文件"; "model.asr_installing_runtime" = "安装本地运行环境"; diff --git a/Sources/Speech/WhisperEngine.swift b/Sources/Speech/WhisperEngine.swift index cf2b861d..d2df97e1 100644 --- a/Sources/Speech/WhisperEngine.swift +++ b/Sources/Speech/WhisperEngine.swift @@ -82,7 +82,8 @@ final class WhisperEngine: SpeechEngine, @unchecked Sendable { progress(dp(0.02, stage: .downloading)) - let tracker = DownloadProgressTracker() + let modelDir = ModelStorage.whisperVariantDir(selectedModel) + let tracker = DownloadProgressTracker(initialBytes: ModelStorage.directorySize(at: modelDir)) let folder: URL if let localFolder { @@ -93,7 +94,8 @@ final class WhisperEngine: SpeechEngine, @unchecked Sendable { variant: selectedModel, downloadBase: ModelCatalog.whisperDownloadBase, progressCallback: { p in - let completed = p.completedUnitCount + let downloadedBytes = ModelStorage.directorySize(at: modelDir) + let completed = downloadedBytes > 0 ? downloadedBytes : p.completedUnitCount let total = p.totalUnitCount let frac = 0.02 + p.fractionCompleted * 0.58 diff --git a/Tests/OpenTypeTests/UtilityTests.swift b/Tests/OpenTypeTests/UtilityTests.swift index 06ca94da..f143fc6a 100644 --- a/Tests/OpenTypeTests/UtilityTests.swift +++ b/Tests/OpenTypeTests/UtilityTests.swift @@ -54,9 +54,56 @@ final class UtilityTests: XCTestCase { ModelCatalog.estimatedDownloadBytes(from: "Qwen3.5 极速 ~620 MB"), 620_000_000 ) + XCTAssertEqual( + ModelCatalog.estimatedDownloadBytes(from: "MiMo tokenizer ~1,024 MB"), + 1_024_000_000 + ) + XCTAssertEqual( + ModelCatalog.estimatedDownloadBytes(from: "compact model ~750MiB"), + 786_432_000 + ) + XCTAssertEqual( + ModelCatalog.estimatedDownloadBytes(from: "about 2.5G download"), + 2_500_000_000 + ) XCTAssertNil(ModelCatalog.estimatedDownloadBytes(from: "本地语音识别模型 + audio tokenizer")) } + @MainActor + func testDefaultDownloadEstimatesUseRepositoryMetadata() { + XCTAssertEqual( + ModelCatalog.defaultDownloadEstimateBytes(for: "mlx-community/Qwen3.5-9B-5bit"), + 7_096_163_574 + ) + XCTAssertEqual( + ModelCatalog.defaultDownloadEstimateBytes( + for: "mlx-community/Llama-4-Maverick-17B-128E-Instruct-4bit" + ), + 225_923_469_800 + ) + XCTAssertEqual( + ModelCatalog.defaultDownloadEstimateBytes(for: LocalASRConfiguration.mimoDefaultModel), + 35_997_080_271 + ) + } + + @MainActor + func testDefaultModelHintsStayCloseToDownloadEstimates() { + let llmHints = ModelCatalog.defaultLLMModels.map { (id: $0.0, hint: $0.2) } + let asrHints = ModelCatalog.defaultASRModels.map { (id: $0.id, hint: $0.hint) } + + for entry in llmHints + asrHints { + guard let exactBytes = ModelCatalog.defaultDownloadEstimateBytes(for: entry.id), + let hintBytes = ModelCatalog.estimatedDownloadBytes(from: entry.hint) else { + XCTFail("Missing displayed download estimate for \(entry.id)") + continue + } + + let delta = abs(Double(hintBytes - exactBytes)) / Double(exactBytes) + XCTAssertLessThanOrEqual(delta, 0.05, "\(entry.id) hint is too far from exact estimate") + } + } + func testDownloadSpeedHidesSubByteNoise() { let info = DownloadProgressInfo( fraction: 0.81, @@ -70,6 +117,53 @@ final class UtilityTests: XCTestCase { XCTAssertEqual(info.speedText, L("download.unknown")) } + func testDownloadProgressInfoSanitizesInvalidValues() { + let info = DownloadProgressInfo( + fraction: .infinity, + elapsedSeconds: -.infinity, + completedBytes: -42, + totalBytes: -1, + speedBytesPerSecond: .nan + ) + + XCTAssertEqual(info.fraction, 0) + XCTAssertEqual(info.elapsedSeconds, 0) + XCTAssertEqual(info.completedBytes, 0) + XCTAssertEqual(info.totalBytes, 0) + XCTAssertEqual(info.speedBytesPerSecond, 0) + } + + func testDownloadProgressTrackerUsesInitialBytesForSpeed() { + let start = Date(timeIntervalSince1970: 10) + let tracker = DownloadProgressTracker(startDate: start, initialBytes: 1_000) + + let info = tracker.update( + completedBytes: 1_500, + totalBytes: 2_000, + at: start.addingTimeInterval(1) + ) + + XCTAssertEqual(info.fraction, 0.75) + XCTAssertEqual(info.speedBytesPerSecond, 500) + } + + func testDownloadProgressTrackerClearsSpeedWhenBytesReset() { + let start = Date(timeIntervalSince1970: 10) + let tracker = DownloadProgressTracker(startDate: start) + _ = tracker.update(completedBytes: 1_500, totalBytes: 2_000, at: start.addingTimeInterval(1)) + + let info = tracker.update( + completedBytes: 200, + totalBytes: 2_000, + fraction: 0.1, + at: start.addingTimeInterval(2) + ) + + XCTAssertEqual(info.fraction, 0.1) + XCTAssertEqual(info.speedBytesPerSecond, 0) + XCTAssertEqual(info.speedText, L("download.unknown")) + } + func testGzipRoundTripForTextAndBinaryData() throws { let text = Data("OpenType voice input. 你好,世界。".utf8) let compressedText = try XCTUnwrap(Gzip.compress(text))