ios: unify attachment controls and message typography

This commit is contained in:
2026-10-14 22:03:21 -07:00
parent a1c0264d36
commit 60a35509d4
11 changed files with 403 additions and 47 deletions
@@ -0,0 +1,110 @@
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: {})
}
}
}