mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
165 lines
7.7 KiB
Swift
165 lines
7.7 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import Observation
|
|
import XCTest
|
|
@testable import Hapi
|
|
|
|
@MainActor @Observable
|
|
final class ScratchlistTestStore: SessionScratchlistStoring {
|
|
var session = ScratchlistSessionState()
|
|
var updates: [(String?, [ScratchlistAttachment]?)] = []
|
|
var deletedAttachments: [String] = []
|
|
var updateSucceeds = true
|
|
var holdUpload = false
|
|
var uploadStarted = false
|
|
var uploadSucceeds = true
|
|
var uploadCount = 0
|
|
@ObservationIgnored var uploadGate: CheckedContinuation<Void, Never>?
|
|
let uploaded = ScratchlistAttachment(id: "new-photo", filename: "new.txt", mimeType: "text/plain", size: 4, path: "hapi-hub:scratchlist/test/new.txt")
|
|
|
|
init(entries: [ScratchlistEntry] = []) {
|
|
session.loaded = true
|
|
session.entries = entries
|
|
}
|
|
func state(_ sessionId: String) -> ScratchlistSessionState { session }
|
|
func open(_ sessionId: String) {}
|
|
func release(_ sessionId: String) {}
|
|
func refresh(_ sessionId: String) async throws {}
|
|
func createEntry(sessionId: String, text: String, attachments: [ScratchlistAttachment], entryId: String?) async -> ScratchlistCreateResult {
|
|
let entry = ScratchlistEntry(entryId: entryId ?? "new", text: text, createdAt: 1, updatedAt: 1, attachments: attachments)
|
|
session.entries.insert(entry, at: 0)
|
|
return .created(entry)
|
|
}
|
|
func updateEntry(sessionId: String, entryId: String, text: String?, attachments: [ScratchlistAttachment]?) async -> Bool {
|
|
updates.append((text, attachments))
|
|
return updateSucceeds
|
|
}
|
|
func deleteEntry(sessionId: String, entryId: String) async -> Bool { true }
|
|
func uploadAttachment(sessionId: String, filename: String, data: Data, mimeType: String) async -> ScratchlistUploadResult {
|
|
uploadStarted = true
|
|
uploadCount += 1
|
|
if holdUpload { await withCheckedContinuation { uploadGate = $0 } }
|
|
return uploadSucceeds ? .uploaded(uploaded) : .failed(message: "offline", code: nil)
|
|
}
|
|
func deleteAttachment(sessionId: String, attachmentId: String) async -> ScratchlistAttachmentDeleteResult {
|
|
deletedAttachments.append(attachmentId)
|
|
return .removed
|
|
}
|
|
func limits(sessionId: String) async -> ScratchlistAttachmentLimits { .defaultLimits }
|
|
}
|
|
|
|
@MainActor
|
|
final class ScratchlistScreenModelTests: XCTestCase {
|
|
private var original: ScratchlistEntry {
|
|
ScratchlistEntry(entryId: "original", text: "Original text", createdAt: 1, updatedAt: 2,
|
|
attachments: [ScratchlistAttachment(id: "old", filename: "reference.txt", mimeType: "text/plain", size: 5, path: "hub-path")])
|
|
}
|
|
private func wait(_ condition: () -> Bool) async throws {
|
|
for _ in 0..<200 {
|
|
if condition() { return }
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
XCTFail("Timed out waiting for scratchlist state")
|
|
}
|
|
|
|
func testCancelDoesNotPersistTextOrAttachmentChanges() async throws {
|
|
let store = ScratchlistTestStore(entries: [original])
|
|
let model = ScratchlistScreenModel(sessionId: "session", store: store)
|
|
model.openEditor(original)
|
|
model.setEditorText("Changed")
|
|
model.removeAttachment(original.attachments[0])
|
|
XCTAssertTrue(model.editor?.isDirty == true)
|
|
XCTAssertTrue(store.updates.isEmpty)
|
|
model.dismissEditor()
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
XCTAssertNil(model.editor)
|
|
XCTAssertTrue(store.updates.isEmpty)
|
|
XCTAssertTrue(store.deletedAttachments.isEmpty)
|
|
}
|
|
|
|
func testSaveCommitsTextAndAttachmentsTogetherAndKeepsFailedDraft() async throws {
|
|
let store = ScratchlistTestStore(entries: [original])
|
|
store.updateSucceeds = false
|
|
let model = ScratchlistScreenModel(sessionId: "session", store: store)
|
|
model.openEditor(original)
|
|
model.setEditorText("Changed")
|
|
model.removeAttachment(original.attachments[0])
|
|
model.saveEditor()
|
|
try await wait { model.editor?.isSaving == false }
|
|
XCTAssertEqual(store.updates.count, 1)
|
|
XCTAssertEqual(store.updates[0].0, "Changed")
|
|
XCTAssertEqual(store.updates[0].1, [])
|
|
XCTAssertEqual(model.editor?.text, "Changed")
|
|
XCTAssertNotNil(model.editor?.error)
|
|
XCTAssertTrue(store.deletedAttachments.isEmpty)
|
|
store.updateSucceeds = true
|
|
model.saveEditor()
|
|
try await wait { model.editor == nil && store.deletedAttachments == ["old"] }
|
|
}
|
|
|
|
func testLateUploadCannotWriteIntoAnotherEditor() async throws {
|
|
let store = ScratchlistTestStore(entries: [original])
|
|
store.holdUpload = true
|
|
let model = ScratchlistScreenModel(sessionId: "session", store: store)
|
|
model.openEditor(original)
|
|
model.addPreparedAttachment(PreparedAttachment(filename: "new.txt", mimeType: "text/plain", bytes: Data("test".utf8)))
|
|
try await wait { store.uploadStarted }
|
|
model.dismissEditor()
|
|
model.openEditor(ScratchlistEntry(entryId: "another", text: "Another", createdAt: 2, updatedAt: 2))
|
|
store.uploadGate?.resume()
|
|
store.uploadGate = nil
|
|
try await wait { store.deletedAttachments.contains("new-photo") }
|
|
XCTAssertEqual(model.editor?.entryId, "another")
|
|
XCTAssertEqual(model.editor?.attachments, [])
|
|
XCTAssertFalse(model.editor?.isUploading ?? true)
|
|
XCTAssertTrue(store.updates.isEmpty)
|
|
}
|
|
|
|
func testCancellingUploadedAttachmentCleansOnlyTheNewFile() async throws {
|
|
let store = ScratchlistTestStore(entries: [original])
|
|
let model = ScratchlistScreenModel(sessionId: "session", store: store)
|
|
model.openEditor(original)
|
|
model.addPreparedAttachment(PreparedAttachment(filename: "new.txt", mimeType: "text/plain", bytes: Data("test".utf8)))
|
|
try await wait { model.editor?.isUploading == false }
|
|
XCTAssertEqual(model.editor?.attachments.count, 2)
|
|
XCTAssertTrue(store.updates.isEmpty)
|
|
model.dismissEditor()
|
|
try await wait { store.deletedAttachments == ["new-photo"] }
|
|
}
|
|
|
|
func testUTF16LimitAndAttachmentOnlyValidation() {
|
|
let store = ScratchlistTestStore(entries: [original])
|
|
let model = ScratchlistScreenModel(sessionId: "session", store: store)
|
|
model.openEditor(original)
|
|
model.setEditorText("")
|
|
XCTAssertTrue(model.editor?.canSave == true)
|
|
model.removeAttachment(original.attachments[0])
|
|
XCTAssertFalse(model.editor?.canSave ?? true)
|
|
model.setEditorText(String(repeating: "😀", count: 5001))
|
|
XCTAssertFalse(model.editor?.canSave ?? true)
|
|
model.saveEditor()
|
|
XCTAssertTrue(store.updates.isEmpty)
|
|
XCTAssertEqual(model.editor?.text.utf16.count, 10_002)
|
|
}
|
|
|
|
func testFailedUploadBlocksSaveAndRetryRetriesTheFileNotTheSave() async throws {
|
|
let store = ScratchlistTestStore(entries: [original])
|
|
store.uploadSucceeds = false
|
|
let model = ScratchlistScreenModel(sessionId: "session", store: store)
|
|
model.openEditor(original)
|
|
model.addPreparedAttachment(PreparedAttachment(filename: "new.txt", mimeType: "text/plain", bytes: Data("test".utf8)))
|
|
try await wait { model.editor?.isUploading == false }
|
|
XCTAssertEqual(model.editor?.failedUploadName, "new.txt")
|
|
XCTAssertFalse(model.editor?.canSave ?? true)
|
|
model.saveEditor()
|
|
XCTAssertTrue(store.updates.isEmpty)
|
|
store.uploadSucceeds = true
|
|
model.retryEditorOperation()
|
|
try await wait { model.editor?.failedUploadName == nil }
|
|
XCTAssertEqual(store.uploadCount, 2)
|
|
XCTAssertEqual(model.editor?.attachments.count, 2)
|
|
XCTAssertTrue(store.updates.isEmpty, "Retry upload must not save or dismiss the editor")
|
|
XCTAssertTrue(model.editor?.canSave == true)
|
|
}
|
|
}
|