111 lines
6.0 KiB
Swift
111 lines
6.0 KiB
Swift
import XCTest
|
|
import SwiftUI
|
|
@testable import Sybil
|
|
|
|
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
|
|
private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432,
|
|
maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg"],
|
|
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
|
|
|
|
/// In-memory upload-only fixture. Every other path fails; no socket/provider IO.
|
|
private actor AttachmentTestClient: AgentAPIClienting {
|
|
var fail = false
|
|
var delay = Duration.milliseconds(1200)
|
|
func failNextUpload(delay: Duration) { fail = true; self.delay = delay }
|
|
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
|
|
try await Task.sleep(for: delay)
|
|
if fail {
|
|
fail = false
|
|
throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507)
|
|
}
|
|
return AgentAttachment(id: "synthetic-" + uploadID, kind: .file, filename: file.filename,
|
|
mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
|
|
}
|
|
func agentDescriptor() throws -> AgentDescriptor { throw AgentAttachmentIssue.unavailable }
|
|
func agentMessages(after: String?, before: String?) throws -> AgentPage { throw AgentAttachmentIssue.unavailable }
|
|
func agentReadState() throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
|
|
func agentMarkRead(cursor: String) throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
|
|
func agentSubmit(content: String, requestID: String) throws -> AgentReceipt { throw AgentAttachmentIssue.unavailable }
|
|
func agentSocket(after: String) throws -> any AgentSocket { throw AgentAttachmentIssue.unavailable }
|
|
}
|
|
|
|
@MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool {
|
|
for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
|
|
return false
|
|
}
|
|
|
|
// Rendering the production composer against the in-memory upload fixture
|
|
// covers transient states without changing the shipping picker or contacting a provider.
|
|
@MainActor
|
|
final class AgentComposerPresentationTests: XCTestCase {
|
|
func testLoadingUploadingFailureRetryCapacityAndCapabilitySnapshots() async throws {
|
|
XCTAssertTrue(UserDefaults.standard.string(forKey: "sybil.ios.apiBaseURL") == "http://127.0.0.1:8799/api")
|
|
XCTAssertTrue(ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1")
|
|
let room = SybilAgentRoom(), client = AttachmentTestClient()
|
|
let draft = room.attachmentDraft
|
|
draft.configure(client: client, storage: nil)
|
|
var limits = attachmentLimits
|
|
limits.maxCount = 1
|
|
draft.updateCapability(limits)
|
|
await client.failNextUpload(delay: .milliseconds(1200))
|
|
await capture(room, "composer-enabled-empty")
|
|
let id = try XCTUnwrap(draft.add(filename: "synthetic-original.bin") {
|
|
try await Task.sleep(for: .milliseconds(600))
|
|
return .init(filename: "synthetic-original.bin", mimeType: "application/octet-stream", data: originalBytes)
|
|
})
|
|
XCTAssertFalse(draft.canSend)
|
|
XCTAssertEqual(draft.items.first?.state, .loading)
|
|
await capture(room, "composer-loading-at-capacity-disabled")
|
|
let uploading = await attachmentEventually { draft.items.first?.state == .uploading }
|
|
XCTAssertTrue(uploading)
|
|
await capture(room, "composer-uploading-at-capacity-disabled")
|
|
let failed = await attachmentEventually { draft.items.first?.state == .failed }
|
|
XCTAssertTrue(failed); XCTAssertFalse(draft.canSend)
|
|
await capture(room, "composer-error-retry-disabled-send")
|
|
draft.retry(id)
|
|
let ready = await attachmentEventually { draft.items.first?.state == .ready }
|
|
XCTAssertTrue(ready); XCTAssertTrue(draft.canSend)
|
|
await capture(room, "composer-ready-enabled-send")
|
|
await capture(room, "composer-ready-accessibility3", size: .accessibility3)
|
|
draft.updateCapability(nil)
|
|
XCTAssertFalse(draft.canSend)
|
|
await capture(room, "composer-unavailable-retains-attachment")
|
|
draft.remove(id)
|
|
draft.caption = "Text still works"
|
|
XCTAssertTrue(draft.canSend)
|
|
await capture(room, "composer-text-only-capability")
|
|
draft.suspend()
|
|
}
|
|
|
|
private func capture(_ room: SybilAgentRoom, _ name: String, size: DynamicTypeSize = .large) async {
|
|
// ImageRenderer omits UIKit-backed fields/scroll views; host the real hierarchy.
|
|
let host = UIHostingController(rootView: AgentComposerPreview(room: room)
|
|
.padding(12).background(SybilTheme.background)
|
|
.environment(\.colorScheme, .dark).environment(\.dynamicTypeSize, size))
|
|
let fitting = host.sizeThatFits(in: CGSize(width: 414, height: 600))
|
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 414, height: max(120, fitting.height)))
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true; window.rootViewController = nil }
|
|
host.view.frame = window.bounds
|
|
host.view.setNeedsLayout(); host.view.layoutIfNeeded()
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
|
|
XCTAssertTrue(host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true))
|
|
}
|
|
let attachment = XCTAttachment(image: image)
|
|
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
|
}
|
|
}
|
|
|
|
private struct AgentComposerPreview: View {
|
|
var room: SybilAgentRoom
|
|
@FocusState private var focused: Bool
|
|
var body: some View {
|
|
AgentAttachmentComposer(room: room) {
|
|
SybilPromptField(text: .constant(room.composer), focused: $focused, submit: {})
|
|
SybilPromptSendButton(canSend: room.attachmentDraft.canSend, submit: {})
|
|
}
|
|
}
|
|
}
|