From 5074909cd7a521e407b804ac11e625952ef08a52 Mon Sep 17 00:00:00 2001 From: Jerry Date: Wed, 15 Apr 2026 15:12:38 -1000 Subject: [PATCH] Fix async conversation restore decoding and add targeted unit tests --- Package.swift | 5 ++ .../Yemma4Tests/ConversationStoreTests.swift | 80 +++++++++++++++++++ .../Yemma4Tests/StreamingRendererTests.swift | 16 ++++ Yemma4/Appearance.swift | 2 +- Yemma4/Services/ConversationStore.swift | 3 + Yemma4/Views/ChatView.swift | 2 +- docs/testing.md | 17 ++++ 7 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 Tests/Yemma4Tests/ConversationStoreTests.swift create mode 100644 Tests/Yemma4Tests/StreamingRendererTests.swift create mode 100644 docs/testing.md diff --git a/Package.swift b/Package.swift index 27aa3c0..b4c6ea8 100644 --- a/Package.swift +++ b/Package.swift @@ -40,6 +40,11 @@ let package = Package( exclude: [ "Yemma4.entitlements", ] + ), + .testTarget( + name: "Yemma4Tests", + dependencies: ["Yemma4"], + path: "Tests/Yemma4Tests" ) ] ) diff --git a/Tests/Yemma4Tests/ConversationStoreTests.swift b/Tests/Yemma4Tests/ConversationStoreTests.swift new file mode 100644 index 0000000..c65c655 --- /dev/null +++ b/Tests/Yemma4Tests/ConversationStoreTests.swift @@ -0,0 +1,80 @@ +import Foundation +import XCTest +import ExyteChat +@testable import Yemma4 + +@MainActor +final class ConversationStoreTests: XCTestCase { + func testAsyncRestoreDecodesIso8601DatesFromPersistedConversation() async throws { + let fileManager = FileManager.default + let storageRoot = fileManager.temporaryDirectory.appendingPathComponent( + "ConversationStoreTests-\(UUID().uuidString)", + isDirectory: true + ) + let defaultsName = "ConversationStoreTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: defaultsName) + XCTAssertNotNil(defaults) + + guard let defaults else { + return + } + + defer { + defaults.removePersistentDomain(forName: defaultsName) + try? fileManager.removeItem(at: storageRoot) + } + + let store = ConversationStore( + fileManager: fileManager, + defaults: defaults, + storageRootOverride: storageRoot + ) + + let createdAt = Date(timeIntervalSince1970: 1_700_000_000) + let message = ChatMessage( + id: "message-1", + user: .user, + status: .sent, + createdAt: createdAt, + text: "Hello", + attachments: [] + ) + let draftAttachment = Attachment( + id: "attachment-1", + url: storageRoot.appendingPathComponent("draft.png"), + type: .image + ) + + let conversationID = store.saveConversation( + id: nil, + messages: [message], + draftText: "Draft text", + draftAttachments: [draftAttachment] + ) + + let reloadedStore = ConversationStore( + fileManager: fileManager, + defaults: defaults, + storageRootOverride: storageRoot + ) + + await reloadedStore.loadIndexIfNeeded() + + XCTAssertEqual(reloadedStore.conversations.count, 1) + XCTAssertEqual(reloadedStore.conversations.first?.id, conversationID) + XCTAssertEqual(reloadedStore.conversations.first?.messageCount, 1) + XCTAssertEqual(reloadedStore.conversations.first?.hasDraft, true) + XCTAssertEqual(reloadedStore.currentConversationID, conversationID) + + let snapshot = await reloadedStore.loadConversationAsync(id: conversationID) + + XCTAssertNotNil(snapshot) + XCTAssertEqual(snapshot?.id, conversationID) + XCTAssertEqual(snapshot?.title, "Hello") + XCTAssertEqual(snapshot?.draftText, "Draft text") + XCTAssertEqual(snapshot?.draftAttachments.count, 1) + XCTAssertEqual(snapshot?.messages.count, 1) + XCTAssertEqual(snapshot?.messages.first?.createdAt, createdAt) + XCTAssertEqual(snapshot?.messages.first?.text, "Hello") + } +} diff --git a/Tests/Yemma4Tests/StreamingRendererTests.swift b/Tests/Yemma4Tests/StreamingRendererTests.swift new file mode 100644 index 0000000..1d3e79c --- /dev/null +++ b/Tests/Yemma4Tests/StreamingRendererTests.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import Yemma4 + +final class StreamingRendererTests: XCTestCase { + func testSanitizeRemovesControlMarkersAndThinkingBlocks() { + let raw = "model\n<|channel>thinkingHellouser" + + XCTAssertEqual(StreamingRenderer.sanitize(raw), "Hello") + } + + func testStreamingVisibleTextAndStopDetection() { + XCTAssertEqual(StreamingRenderer.streamingVisibleText("Hello wor"), "Hello") + XCTAssertTrue(StreamingRenderer.shouldStopStreaming(tailOf: "prefix <|end_of_turn|>")) + XCTAssertFalse(StreamingRenderer.shouldStopStreaming(tailOf: "prefix Hello")) + } +} diff --git a/Yemma4/Appearance.swift b/Yemma4/Appearance.swift index 5a66e15..1af3896 100644 --- a/Yemma4/Appearance.swift +++ b/Yemma4/Appearance.swift @@ -287,7 +287,7 @@ struct UtilityBackground: View { } private struct ProgressiveHeaderHeightKey: PreferenceKey { - static var defaultValue: CGFloat = 0 + static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() diff --git a/Yemma4/Services/ConversationStore.swift b/Yemma4/Services/ConversationStore.swift index 50b22ac..50dce44 100644 --- a/Yemma4/Services/ConversationStore.swift +++ b/Yemma4/Services/ConversationStore.swift @@ -490,6 +490,7 @@ final class ConversationStore { private func loadIndexAsync() async { let decoded = await Task.detached(priority: .utility) { [indexURL] in let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 guard let data = try? Data(contentsOf: indexURL) else { return [ConversationMetadata]() } @@ -638,6 +639,7 @@ final class ConversationStore { decoder.dateDecodingStrategy = .iso8601 return decoder }() + } private enum ConversationSnapshotLoader { @@ -647,6 +649,7 @@ private enum ConversationSnapshotLoader { } let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 guard let conversation = try? decoder.decode(PersistedConversation.self, from: data) else { return nil } diff --git a/Yemma4/Views/ChatView.swift b/Yemma4/Views/ChatView.swift index bf38ec8..0092075 100644 --- a/Yemma4/Views/ChatView.swift +++ b/Yemma4/Views/ChatView.swift @@ -2037,7 +2037,7 @@ private struct StartupLoadingAnimationView: View { } private struct ConversationBottomOffsetPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 + static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..3e2777f --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,17 @@ +# Testing Notes + +The highest-value pure-Swift coverage added for this task lives under `Tests/Yemma4Tests/`: + +- `ConversationStoreTests.swift` exercises the async restore path against an ISO-8601 persisted conversation. +- `StreamingRendererTests.swift` covers the sanitizer and stop-stream detection helpers. + +Run the package-backed XCTest target with: + +```bash +xcodebuild test \ + -workspace .swiftpm/xcode/package.xcworkspace \ + -scheme Yemma4 \ + -destination 'platform=iOS Simulator,name=Yemma Preview 17 Pro Max' +``` + +The app target can still be validated separately with the project build path when you want a simulator compile of the shipped shell.