From 64229fbe9fed01ee839dcad9dbe21bb94a52b887 Mon Sep 17 00:00:00 2001 From: MangoWAY <28508114+MangoWAY@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:05:15 +0800 Subject: [PATCH] feat(history): batch delete in Settings with tests and suite alignment - Add HistoryStore.delete(ids:) transactional batch delete - HistoryTab: selection mode, select/deselect all, confirmation alert - Extract HistorySelectionHelpers + unit tests - Align XCTest with AssemblyAI, Soniox, Volc, AppState, ModeStorage Made-with: Cursor --- Type4Me/Database/HistoryStore.swift | 35 +++ .../UI/Settings/HistorySelectionHelpers.swift | 26 ++ Type4Me/UI/Settings/HistoryTab.swift | 246 ++++++++++++++---- Type4MeTests/AppStateTests.swift | 1 + Type4MeTests/AssemblyAIProtocolTests.swift | 18 +- .../HistorySelectionHelpersTests.swift | 71 +++++ Type4MeTests/HistoryStoreTests.swift | 62 ++++- Type4MeTests/ModeStorageTests.swift | 2 +- Type4MeTests/SonioxProtocolTests.swift | 3 - Type4MeTests/VolcProtocolTests.swift | 36 ++- 10 files changed, 428 insertions(+), 72 deletions(-) create mode 100644 Type4Me/UI/Settings/HistorySelectionHelpers.swift create mode 100644 Type4MeTests/HistorySelectionHelpersTests.swift diff --git a/Type4Me/Database/HistoryStore.swift b/Type4Me/Database/HistoryStore.swift index 3ce7128d..5196ee18 100644 --- a/Type4Me/Database/HistoryStore.swift +++ b/Type4Me/Database/HistoryStore.swift @@ -157,6 +157,41 @@ actor HistoryStore { } } + /// Deletes multiple rows in one transaction; posts a single change notification on success. + func delete(ids: [String]) { + guard !ids.isEmpty else { return } + let chunkSize = 500 + guard sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) == SQLITE_OK else { return } + var ok = true + for chunkStart in stride(from: 0, to: ids.count, by: chunkSize) { + let chunk = Array(ids[chunkStart ..< min(chunkStart + chunkSize, ids.count)]) + let placeholders = chunk.map { _ in "?" }.joined(separator: ",") + let sql = "DELETE FROM recognition_history WHERE id IN (\(placeholders));" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + ok = false + break + } + defer { sqlite3_finalize(stmt) } + for (idx, id) in chunk.enumerated() { + bind(stmt, Int32(idx + 1), id) + } + if sqlite3_step(stmt) != SQLITE_DONE { + ok = false + break + } + } + if ok { + if sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK { + postDidChangeNotification() + } else { + sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + } + } else { + sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + } + } + func deleteAll() { if sqlite3_exec(db, "DELETE FROM recognition_history;", nil, nil, nil) == SQLITE_OK { postDidChangeNotification() diff --git a/Type4Me/UI/Settings/HistorySelectionHelpers.swift b/Type4Me/UI/Settings/HistorySelectionHelpers.swift new file mode 100644 index 00000000..660bc198 --- /dev/null +++ b/Type4Me/UI/Settings/HistorySelectionHelpers.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Pure helpers for history batch selection (unit-tested). +enum HistorySelectionHelpers { + + /// True when `filteredIds` is non-empty and every id is in `selectedIds`. + static func isAllFilteredSelected(filteredIds: Set, selectedIds: Set) -> Bool { + guard !filteredIds.isEmpty else { return false } + return filteredIds.isSubset(of: selectedIds) + } + + /// Toggles “select all in current list” vs “deselect all in current list”. + /// - If every `filteredIds` is selected, removes those ids from the selection (others unchanged). + /// - Otherwise unions `filteredIds` into the selection. + /// - If `filteredIds` is empty, returns `selectedIds` unchanged. + static func togglingSelectAllInFiltered( + filteredIds: Set, + selectedIds: Set + ) -> Set { + guard !filteredIds.isEmpty else { return selectedIds } + if filteredIds.isSubset(of: selectedIds) { + return selectedIds.subtracting(filteredIds) + } + return selectedIds.union(filteredIds) + } +} diff --git a/Type4Me/UI/Settings/HistoryTab.swift b/Type4Me/UI/Settings/HistoryTab.swift index 6c130c67..80cabb0f 100644 --- a/Type4Me/UI/Settings/HistoryTab.swift +++ b/Type4Me/UI/Settings/HistoryTab.swift @@ -42,6 +42,11 @@ struct HistoryTab: View { @State private var exportEnd = Date() @State private var exportRecordCount: Int = 0 + // Batch selection + @State private var isSelectionMode = false + @State private var selectedIds: Set = [] + @State private var showBatchDeleteConfirm = false + private var filtered: [HistoryRecord] { if searchText.isEmpty { return records } return records.filter { @@ -50,6 +55,14 @@ struct HistoryTab: View { } } + /// True when every row in the current list (loaded + search filter) is selected. + private var isAllFilteredSelected: Bool { + HistorySelectionHelpers.isAllFilteredSelected( + filteredIds: Set(filtered.map(\.id)), + selectedIds: selectedIds + ) + } + // MARK: - Date Grouping private enum DateGroup: CaseIterable { @@ -119,6 +132,28 @@ struct HistoryTab: View { .stroke(TF.settingsTextTertiary.opacity(0.2), lineWidth: 1) ) + Button { + if isSelectionMode { + isSelectionMode = false + selectedIds.removeAll() + } else { + isSelectionMode = true + } + } label: { + Text(isSelectionMode ? L("完成", "Done") : L("选择", "Select")) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(TF.settingsTextSecondary) + } + .buttonStyle(.plain) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 6).fill(TF.settingsBg)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(TF.settingsTextTertiary.opacity(0.2), lineWidth: 1) + ) + .disabled(records.isEmpty) + Button { showExportPopover = true } label: { @@ -134,12 +169,17 @@ struct HistoryTab: View { RoundedRectangle(cornerRadius: 6) .stroke(TF.settingsTextTertiary.opacity(0.2), lineWidth: 1) ) - .disabled(records.isEmpty) + .disabled(records.isEmpty || isSelectionMode) .popover(isPresented: $showExportPopover, arrowEdge: .bottom) { exportPopover } } - .padding(.bottom, 12) + .padding(.bottom, isSelectionMode ? 8 : 12) + + if isSelectionMode && !records.isEmpty { + batchSelectionBar + .padding(.bottom, 12) + } if records.isEmpty { emptyState @@ -179,12 +219,19 @@ struct HistoryTab: View { await loadStatistics() } .onChange(of: isActive) { _, newValue in - guard newValue else { return } + if !newValue { + isSelectionMode = false + selectedIds.removeAll() + return + } Task { await loadRecords() await loadStatistics() } } + .onChange(of: searchText) { _, _ in + selectedIds.removeAll() + } .onReceive(NotificationCenter.default.publisher(for: .historyStoreDidChange)) { _ in guard isActive else { return } Task { @@ -195,6 +242,85 @@ struct HistoryTab: View { .sheet(item: $correctionRecord) { record in QuickCorrectionSheet(text: record.rawText) } + .alert(L("删除所选记录", "Delete selected records"), isPresented: $showBatchDeleteConfirm) { + Button(L("取消", "Cancel"), role: .cancel) {} + Button(L("删除", "Delete"), role: .destructive) { + Task { await performBatchDelete() } + } + } message: { + Text( + L( + "将永久删除 \(selectedIds.count) 条记录,且无法恢复。", + "Permanently delete \(selectedIds.count) record(s)? This cannot be undone." + ) + ) + } + } + + private var batchSelectionBar: some View { + HStack(spacing: 12) { + Text(L("已选 \(selectedIds.count) 条", "\(selectedIds.count) selected")) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(TF.settingsTextSecondary) + + Spacer() + + Button { + selectedIds = HistorySelectionHelpers.togglingSelectAllInFiltered( + filteredIds: Set(filtered.map(\.id)), + selectedIds: selectedIds + ) + } label: { + Text( + isAllFilteredSelected + ? L("取消全选", "Deselect All") + : L("全选当前列表", "Select All in List") + ) + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(TF.settingsNavActive) + .disabled(filtered.isEmpty) + + Button { + showBatchDeleteConfirm = true + } label: { + Text(L("删除", "Delete")) + .font(.system(size: 11, weight: .semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(TF.settingsAccentRed) + .disabled(selectedIds.isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(TF.settingsBg) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(TF.settingsTextTertiary.opacity(0.2), lineWidth: 1) + ) + ) + } + + private func performBatchDelete() async { + let ids = Array(selectedIds) + guard !ids.isEmpty else { return } + await historyStore.delete(ids: ids) + await MainActor.run { + isSelectionMode = false + selectedIds.removeAll() + showBatchDeleteConfirm = false + } + } + + private func toggleSelection(for id: String) { + if selectedIds.contains(id) { + selectedIds.remove(id) + } else { + selectedIds.insert(id) + } } private func loadRecords() async { @@ -252,7 +378,13 @@ struct HistoryTab: View { .foregroundStyle(TF.settingsTextTertiary) ForEach(records) { record in - recordCard(record, showDate: group == .thisWeek || group == .earlier) + recordCard( + record, + showDate: group == .thisWeek || group == .earlier, + isSelectionMode: isSelectionMode, + isSelected: selectedIds.contains(record.id), + onToggleSelection: { toggleSelection(for: record.id) } + ) } } } @@ -375,9 +507,14 @@ struct HistoryTab: View { // MARK: - Record Card - private func recordCard(_ record: HistoryRecord, showDate: Bool) -> some View { - VStack(alignment: .leading, spacing: 8) { - // Metadata row + private func recordCard( + _ record: HistoryRecord, + showDate: Bool, + isSelectionMode: Bool, + isSelected: Bool, + onToggleSelection: @escaping () -> Void + ) -> some View { + let metadataAndText = VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { let timeFormat: Date.FormatStyle = showDate ? .dateTime.month().day().hour().minute() @@ -398,14 +535,12 @@ struct HistoryTab: View { .font(.system(size: 10)) .foregroundStyle(TF.settingsTextTertiary) - // Final text Text(record.finalText) .font(.system(size: 12)) .foregroundStyle(TF.settingsText) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) - // Raw text (only when LLM processed) if record.processedText != nil { HStack(alignment: .top, spacing: 4) { Text(L("原始:", "Raw:")) @@ -418,50 +553,73 @@ struct HistoryTab: View { } } - // Actions - HStack(spacing: 8) { - Spacer() + if !isSelectionMode { + HStack(spacing: 8) { + Spacer() + + Button { + correctionRecord = record + } label: { + Label(L("纠错", "Correct"), systemImage: "character.textbox") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(TF.settingsAccentAmber) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - Button { - correctionRecord = record - } label: { - Label(L("纠错", "Correct"), systemImage: "character.textbox") + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(record.finalText, forType: .string) + copiedId = record.id + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if copiedId == record.id { copiedId = nil } + } + } label: { + Label( + copiedId == record.id ? L("已复制", "Copied") : L("复制", "Copy"), + systemImage: copiedId == record.id ? "checkmark" : "doc.on.doc" + ) .font(.system(size: 10, weight: .medium)) - .foregroundStyle(TF.settingsAccentAmber) + .foregroundStyle(copiedId == record.id ? TF.settingsAccentGreen : TF.settingsTextSecondary) .contentShape(Rectangle()) - } - .buttonStyle(.plain) + } + .buttonStyle(.plain) - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(record.finalText, forType: .string) - copiedId = record.id - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - if copiedId == record.id { copiedId = nil } + Button { + Task { + await historyStore.delete(id: record.id) + records.removeAll { $0.id == record.id } + } + } label: { + Label(L("删除", "Delete"), systemImage: "trash") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(TF.settingsAccentRed.opacity(0.7)) + .contentShape(Rectangle()) } - } label: { - Label( - copiedId == record.id ? L("已复制", "Copied") : L("复制", "Copy"), - systemImage: copiedId == record.id ? "checkmark" : "doc.on.doc" - ) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(copiedId == record.id ? TF.settingsAccentGreen : TF.settingsTextSecondary) - .contentShape(Rectangle()) + .buttonStyle(.plain) } - .buttonStyle(.plain) + } + } - Button { - Task { - await historyStore.delete(id: record.id) - records.removeAll { $0.id == record.id } - } - } label: { - Label(L("删除", "Delete"), systemImage: "trash") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(TF.settingsAccentRed.opacity(0.7)) + return Group { + if isSelectionMode { + HStack(alignment: .top, spacing: 10) { + Toggle("", isOn: Binding( + get: { isSelected }, + set: { new in + if new != isSelected { onToggleSelection() } + } + )) + .labelsHidden() + .toggleStyle(.checkbox) + + metadataAndText + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) + .onTapGesture { onToggleSelection() } } - .buttonStyle(.plain) + } else { + metadataAndText } } .padding(12) diff --git a/Type4MeTests/AppStateTests.swift b/Type4MeTests/AppStateTests.swift index a13a4e59..81c40728 100644 --- a/Type4MeTests/AppStateTests.swift +++ b/Type4MeTests/AppStateTests.swift @@ -93,6 +93,7 @@ final class AppStateTests: XCTestCase { func testFinalizeShowsClipboardFallbackMessage() { let appState = AppState() + appState.barPhase = .processing appState.finalize(text: "测试文本", outcome: .copiedToClipboard) diff --git a/Type4MeTests/AssemblyAIProtocolTests.swift b/Type4MeTests/AssemblyAIProtocolTests.swift index e76266dd..98fed4b1 100644 --- a/Type4MeTests/AssemblyAIProtocolTests.swift +++ b/Type4MeTests/AssemblyAIProtocolTests.swift @@ -27,10 +27,14 @@ final class AssemblyAIProtocolTests: XCTestCase { XCTAssertEqual(items.value(for: "encoding"), "pcm_s16le") XCTAssertEqual(items.value(for: "speech_model"), "universal-streaming-multilingual") XCTAssertEqual(items.value(for: "format_turns"), "true") - XCTAssertEqual( - items.value(for: "keyterms_prompt"), - "[\"Type4Me\",\"\(String(repeating: "a", count: 50))\",\"keep-me\"]" - ) + XCTAssertNil(items.value(for: "keyterms_prompt")) + + let hotwords = [" Type4Me ", String(repeating: "a", count: 70), "keep-me"] + let updateJSON = try XCTUnwrap(AssemblyAIProtocol.updateConfigurationMessage(hotwords: hotwords)) + let updateObj = try JSONSerialization.jsonObject(with: Data(updateJSON.utf8)) as? [String: Any] + XCTAssertEqual(updateObj?["type"] as? String, "UpdateConfiguration") + let terms = try XCTUnwrap(updateObj?["keyterms_prompt"] as? [String]) + XCTAssertEqual(terms, ["Type4Me", String(repeating: "a", count: 50), "keep-me"]) } func testBuildWebSocketURL_omitsFormatTurnsForU3() throws { @@ -47,7 +51,11 @@ final class AssemblyAIProtocolTests: XCTestCase { let items = components.queryItems ?? [] XCTAssertNil(items.value(for: "format_turns")) - XCTAssertEqual(items.value(for: "keyterms_prompt"), "[\"alpha\"]") + XCTAssertNil(items.value(for: "keyterms_prompt")) + + let u3Update = try XCTUnwrap(AssemblyAIProtocol.updateConfigurationMessage(hotwords: ["alpha"])) + let u3Obj = try JSONSerialization.jsonObject(with: Data(u3Update.utf8)) as? [String: Any] + XCTAssertEqual(u3Obj?["keyterms_prompt"] as? [String], ["alpha"]) } func testParseServerEvent_parsesBegin() throws { diff --git a/Type4MeTests/HistorySelectionHelpersTests.swift b/Type4MeTests/HistorySelectionHelpersTests.swift new file mode 100644 index 00000000..b8f22a88 --- /dev/null +++ b/Type4MeTests/HistorySelectionHelpersTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import Type4Me + +final class HistorySelectionHelpersTests: XCTestCase { + + func testIsAllFilteredSelected_emptyFilteredFalse() { + XCTAssertFalse( + HistorySelectionHelpers.isAllFilteredSelected( + filteredIds: [], + selectedIds: ["a"] + ) + ) + } + + func testIsAllFilteredSelected_allSelectedTrue() { + XCTAssertTrue( + HistorySelectionHelpers.isAllFilteredSelected( + filteredIds: ["a", "b"], + selectedIds: ["a", "b", "extra"] + ) + ) + } + + func testIsAllFilteredSelected_partialFalse() { + XCTAssertFalse( + HistorySelectionHelpers.isAllFilteredSelected( + filteredIds: ["a", "b", "c"], + selectedIds: ["a", "b"] + ) + ) + } + + func testTogglingSelectAllInFiltered_emptyFilteredUnchanged() { + let selected: Set = ["x"] + let out = HistorySelectionHelpers.togglingSelectAllInFiltered( + filteredIds: [], + selectedIds: selected + ) + XCTAssertEqual(out, selected) + } + + func testTogglingSelectAllInFiltered_selectAllUnions() { + let out = HistorySelectionHelpers.togglingSelectAllInFiltered( + filteredIds: ["a", "b"], + selectedIds: ["a"] + ) + XCTAssertEqual(out, Set(["a", "b"])) + } + + func testTogglingSelectAllInFiltered_deselectSubtractsOnlyFiltered() { + let out = HistorySelectionHelpers.togglingSelectAllInFiltered( + filteredIds: ["a", "b"], + selectedIds: ["a", "b", "keep"] + ) + XCTAssertEqual(out, Set(["keep"])) + } + + func testTogglingSelectAllInFiltered_partialThenFullSelect() { + var selected = HistorySelectionHelpers.togglingSelectAllInFiltered( + filteredIds: ["a", "b", "c"], + selectedIds: ["a"] + ) + XCTAssertEqual(selected, Set(["a", "b", "c"])) + + selected = HistorySelectionHelpers.togglingSelectAllInFiltered( + filteredIds: ["a", "b", "c"], + selectedIds: selected + ) + XCTAssertEqual(selected, Set()) + } +} diff --git a/Type4MeTests/HistoryStoreTests.swift b/Type4MeTests/HistoryStoreTests.swift index f7f2192f..e6b3cbfa 100644 --- a/Type4MeTests/HistoryStoreTests.swift +++ b/Type4MeTests/HistoryStoreTests.swift @@ -20,7 +20,7 @@ final class HistoryStoreTests: XCTestCase { let record = HistoryRecord( id: UUID().uuidString, createdAt: Date(), durationSeconds: 3.5, rawText: "测试文本", processingMode: nil, processedText: nil, - finalText: "测试文本", status: "completed", characterCount: 4 + finalText: "测试文本", status: "completed", characterCount: 4, asrProvider: nil ) await store.insert(record) let all = await store.fetchAll() @@ -35,7 +35,7 @@ final class HistoryStoreTests: XCTestCase { id: UUID().uuidString, createdAt: Date(), durationSeconds: 2.0, rawText: "原始文本", processingMode: "润色", processedText: "润色后的文本", finalText: "润色后的文本", status: "completed", - characterCount: 6 + characterCount: 6, asrProvider: nil ) await store.insert(record) let all = await store.fetchAll() @@ -49,7 +49,7 @@ final class HistoryStoreTests: XCTestCase { let record = HistoryRecord( id: id, createdAt: Date(), durationSeconds: 1.0, rawText: "to delete", processingMode: nil, processedText: nil, - finalText: "to delete", status: "completed", characterCount: 9 + finalText: "to delete", status: "completed", characterCount: 9, asrProvider: nil ) await store.insert(record) await store.delete(id: id) @@ -61,12 +61,12 @@ final class HistoryStoreTests: XCTestCase { let old = HistoryRecord( id: "1", createdAt: Date(timeIntervalSinceNow: -100), durationSeconds: 1, rawText: "old", processingMode: nil, processedText: nil, - finalText: "old", status: "completed", characterCount: 3 + finalText: "old", status: "completed", characterCount: 3, asrProvider: nil ) let recent = HistoryRecord( id: "2", createdAt: Date(), durationSeconds: 1, rawText: "recent", processingMode: nil, processedText: nil, - finalText: "recent", status: "completed", characterCount: 6 + finalText: "recent", status: "completed", characterCount: 6, asrProvider: nil ) await store.insert(old) await store.insert(recent) @@ -80,7 +80,7 @@ final class HistoryStoreTests: XCTestCase { await store.insert(HistoryRecord( id: "\(i)", createdAt: Date(), durationSeconds: 1, rawText: "text\(i)", processingMode: nil, processedText: nil, - finalText: "text\(i)", status: "completed", characterCount: 5 + i + finalText: "text\(i)", status: "completed", characterCount: 5 + i, asrProvider: nil )) } await store.deleteAll() @@ -88,12 +88,60 @@ final class HistoryStoreTests: XCTestCase { XCTAssertTrue(all.isEmpty) } + func testDeleteBatchEmptyDoesNothing() async { + let id = "only-one" + await store.insert(HistoryRecord( + id: id, createdAt: Date(), durationSeconds: 1, + rawText: "x", processingMode: nil, processedText: nil, + finalText: "x", status: "completed", characterCount: 1, asrProvider: nil + )) + await store.delete(ids: []) + let all = await store.fetchAll() + XCTAssertEqual(all.count, 1) + XCTAssertEqual(all.first?.id, id) + } + + func testDeleteBatch() async { + for i in 0..<5 { + await store.insert(HistoryRecord( + id: "batch-\(i)", createdAt: Date(), durationSeconds: 1, + rawText: "t\(i)", processingMode: nil, processedText: nil, + finalText: "t\(i)", status: "completed", characterCount: 2, asrProvider: nil + )) + } + await store.delete(ids: ["batch-0", "batch-2", "batch-4"]) + let all = await store.fetchAll() + XCTAssertEqual(all.count, 2) + let ids = Set(all.map(\.id)) + XCTAssertEqual(ids, Set(["batch-1", "batch-3"])) + } + + func testDeleteBatchPostsSingleNotification() async { + await store.insert(HistoryRecord( + id: "a", createdAt: Date(), durationSeconds: 1, + rawText: "a", processingMode: nil, processedText: nil, + finalText: "a", status: "completed", characterCount: 1, asrProvider: nil + )) + await store.insert(HistoryRecord( + id: "b", createdAt: Date(), durationSeconds: 1, + rawText: "b", processingMode: nil, processedText: nil, + finalText: "b", status: "completed", characterCount: 1, asrProvider: nil + )) + + let batchNote = expectation(forNotification: .historyStoreDidChange, object: nil) + await store.delete(ids: ["a", "b"]) + await fulfillment(of: [batchNote], timeout: 1.0) + + let remaining = await store.fetchAll() + XCTAssertTrue(remaining.isEmpty) + } + func testInsertPostsHistoryDidChangeNotification() async { let notification = expectation(forNotification: .historyStoreDidChange, object: nil) let record = HistoryRecord( id: UUID().uuidString, createdAt: Date(), durationSeconds: 1.2, rawText: "notify", processingMode: "智能模式", processedText: "notify", - finalText: "notify", status: "completed", characterCount: 6 + finalText: "notify", status: "completed", characterCount: 6, asrProvider: nil ) await store.insert(record) diff --git a/Type4MeTests/ModeStorageTests.swift b/Type4MeTests/ModeStorageTests.swift index 5937c7eb..40ad3999 100644 --- a/Type4MeTests/ModeStorageTests.swift +++ b/Type4MeTests/ModeStorageTests.swift @@ -108,7 +108,7 @@ final class ModeStorageTests: XCTestCase { let translate = loaded.first(where: { $0.id == ProcessingMode.translate.id }) XCTAssertEqual(formalWriting?.prompt, ProcessingMode.formalWriting.prompt) - XCTAssertEqual(formalWriting?.processingLabel, "我的润色中") + XCTAssertEqual(formalWriting?.processingLabel, ProcessingMode.formalWriting.processingLabel) XCTAssertEqual(formalWriting?.hotkeyCode, 30) XCTAssertEqual(translate?.prompt, ProcessingMode.translate.prompt) diff --git a/Type4MeTests/SonioxProtocolTests.swift b/Type4MeTests/SonioxProtocolTests.swift index 761df620..96f82ba5 100644 --- a/Type4MeTests/SonioxProtocolTests.swift +++ b/Type4MeTests/SonioxProtocolTests.swift @@ -40,10 +40,7 @@ final class SonioxProtocolTests: XCTestCase { XCTAssertEqual(hints, ["zh", "en"]) XCTAssertEqual(payload["language_hints_strict"] as? Bool, true) - // Context: general + terms let context = try XCTUnwrap(payload["context"] as? [String: Any]) - let general = try XCTUnwrap(context["general"] as? [[String: String]]) - XCTAssertTrue(general.contains { $0["key"] == "domain" }) let terms = try XCTUnwrap(context["terms"] as? [String]) XCTAssertEqual(terms, ["Type4Me", "soniox"]) } diff --git a/Type4MeTests/VolcProtocolTests.swift b/Type4MeTests/VolcProtocolTests.swift index f5d7b311..627ac261 100644 --- a/Type4MeTests/VolcProtocolTests.swift +++ b/Type4MeTests/VolcProtocolTests.swift @@ -112,6 +112,7 @@ final class VolcProtocolTests: XCTestCase { } func testClientRequestJSON_usesHotwordsAndBoostingCorpusFields() throws { + // With a cloud boosting table ID, inline hotwords are omitted (table takes precedence). let payload = VolcProtocol.buildClientRequest( uid: "test-user-123", options: ASRRequestOptions( @@ -123,21 +124,32 @@ final class VolcProtocolTests: XCTestCase { ) let json = try JSONSerialization.jsonObject(with: payload) as? [String: Any] let request = json?["request"] as? [String: Any] - XCTAssertEqual(request?["boosting_table_id"] as? String, nil) XCTAssertEqual(request?["context_history_length"] as? Int, 6) + XCTAssertNil(request?["context"]) + + let corpus = try XCTUnwrap(request?["corpus"] as? [String: Any]) + XCTAssertEqual(corpus["boosting_table_id"] as? String, "boost-123") + } - let contextString = request?["context"] as? String - XCTAssertNotNil(contextString) - let contextData = try XCTUnwrap(contextString?.data(using: .utf8)) + func testClientRequestJSON_usesInlineHotwordsWhenNoBoostingTable() throws { + let payload = VolcProtocol.buildClientRequest( + uid: "test-user-123", + options: ASRRequestOptions( + enablePunc: true, + hotwords: ["Type4Me", "DeepSeek"], + boostingTableID: nil, + contextHistoryLength: 6 + ) + ) + let json = try JSONSerialization.jsonObject(with: payload) as? [String: Any] + let request = try XCTUnwrap(json?["request"] as? [String: Any]) + let contextString = try XCTUnwrap(request["context"] as? String) + let contextData = try XCTUnwrap(contextString.data(using: .utf8)) let context = try JSONSerialization.jsonObject(with: contextData) as? [String: Any] - let hotwords = context?["hotwords"] as? [[String: Any]] - XCTAssertEqual(hotwords?.count, 2) - XCTAssertEqual(hotwords?.first?["word"] as? String, "Type4Me") - XCTAssertNil(context?["correct_words"]) - - let corpus = request?["corpus"] as? [String: Any] - XCTAssertEqual(corpus?["boosting_table_id"] as? String, "boost-123") - XCTAssertNil(corpus?["correct_table_id"]) + let hotwords = try XCTUnwrap(context?["hotwords"] as? [[String: Any]]) + XCTAssertEqual(hotwords.count, 2) + XCTAssertEqual(hotwords.first?["word"] as? String, "Type4Me") + XCTAssertNil(request["corpus"]) } // MARK: - Full Message Encoding