ios: unify attachment controls and message typography
This commit is contained in:
@@ -110,6 +110,12 @@ async def route(request):
|
||||
push_available = True; auth_required = False
|
||||
for i in range(body.get('count', 3)):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
elif action == 'presentation-samples':
|
||||
# Same source messages in normal chat and Agent; loopback-only UI comparisons.
|
||||
messages.clear(); cursor = 0; read_cursor = 0
|
||||
transcript_comparison = True
|
||||
for sample in body['messages']:
|
||||
message(sample['content'], sample['role'])
|
||||
elif action == 'transcript-comparison':
|
||||
transcript_comparison = True
|
||||
elif action == 'capability':
|
||||
|
||||
@@ -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: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
|
||||
/// Actual normal chat and Agent views, using only the loopback contract fixture.
|
||||
@MainActor final class AgentPolishUITests: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
try await control(["action": "reset", "count": 0])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
}
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
private func control(_ body: [String: Any]) async throws {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
|
||||
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
|
||||
}
|
||||
private func sidebar() {
|
||||
if !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
|
||||
app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
}
|
||||
private func openChat() {
|
||||
sidebar()
|
||||
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
|
||||
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
|
||||
XCTAssertTrue(app.buttons["chat-attach"].waitForExistence(timeout: 8))
|
||||
}
|
||||
private func openAgent() {
|
||||
sidebar(); app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
|
||||
}
|
||||
private func capture(_ name: String) {
|
||||
let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||
shot.name = name; shot.lifetime = .keepAlways; add(shot)
|
||||
}
|
||||
func testSharedAttachmentControlAndPickerFlows() async throws {
|
||||
try await control(["action": "transcript-comparison"])
|
||||
app.launch(); openChat()
|
||||
let normal = app.buttons["chat-attach"]
|
||||
XCTAssertEqual(normal.label, "Attach file"); XCTAssertTrue(normal.isEnabled)
|
||||
let normalSize = normal.frame.size
|
||||
let normalLabel = normal.label
|
||||
XCTAssertGreaterThanOrEqual(normalSize.width, 44); XCTAssertGreaterThanOrEqual(normalSize.height, 44)
|
||||
capture("polish-normal-attachment-control")
|
||||
normal.tap(); app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
|
||||
normal.tap(); app.buttons["Photo Library"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
|
||||
openAgent()
|
||||
let agent = app.buttons["agent-attach"]
|
||||
XCTAssertEqual(agent.label, normalLabel); XCTAssertTrue(agent.isEnabled)
|
||||
XCTAssertEqual(agent.frame.size.width, normalSize.width, accuracy: 1)
|
||||
XCTAssertEqual(agent.frame.size.height, normalSize.height, accuracy: 1)
|
||||
// Same row as the prompt, including the minimum VoiceOver/touch target.
|
||||
XCTAssertEqual(agent.frame.maxY, app.buttons["agent-send"].frame.maxY, accuracy: 1)
|
||||
capture("polish-agent-attachment-control")
|
||||
agent.tap(); app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
|
||||
agent.tap(); app.buttons["Photo Library"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
|
||||
}
|
||||
|
||||
func testUserAssistantMultilineParagraphMetricsInBothWorkspaces() async throws {
|
||||
try await compareMetrics(user: "User first\nUser second\n\nUser paragraph",
|
||||
peer: "Peer first \nPeer second\n\nPeer paragraph", name: "multiline-paragraphs")
|
||||
}
|
||||
func testPastedParagraphMetricsInBothWorkspaces() async throws {
|
||||
try await compareMetrics(user: "User first\r\nUser second\r\n \r\nUser paragraph",
|
||||
peer: "Peer first \nPeer second\n\t\nPeer paragraph", name: "pasted-paragraphs")
|
||||
}
|
||||
private func compareMetrics(user: String, peer: String, name: String) async throws {
|
||||
try await control(["action": "presentation-samples", "messages": [
|
||||
["role": "user", "content": user], ["role": "assistant", "content": peer]]])
|
||||
app.launch(); openChat()
|
||||
let normal = try metrics()
|
||||
capture("polish-normal-" + name)
|
||||
openAgent()
|
||||
let agent = try metrics()
|
||||
XCTAssertEqual(normal.0, agent.0, accuracy: 1)
|
||||
XCTAssertEqual(normal.1, agent.1, accuracy: 1)
|
||||
capture("polish-agent-" + name)
|
||||
}
|
||||
private func metrics() throws -> (CGFloat, CGFloat) {
|
||||
let user = app.staticTexts["User first\nUser second"]
|
||||
let peer = app.staticTexts["Peer first\nPeer second"]
|
||||
XCTAssertTrue(user.waitForExistence(timeout: 8)); XCTAssertTrue(peer.waitForExistence(timeout: 8))
|
||||
let userParagraph = app.staticTexts["User paragraph"], peerParagraph = app.staticTexts["Peer paragraph"]
|
||||
XCTAssertTrue(userParagraph.exists); XCTAssertTrue(peerParagraph.exists)
|
||||
XCTAssertEqual(user.frame.height, peer.frame.height, accuracy: 1)
|
||||
let gap = userParagraph.frame.minY - user.frame.maxY
|
||||
XCTAssertEqual(gap, peerParagraph.frame.minY - peer.frame.maxY, accuracy: 1)
|
||||
XCTAssertGreaterThan(gap, 0)
|
||||
return (user.frame.height, gap)
|
||||
}
|
||||
func testLiteralUserAndSemanticAssistantMarkdown() async throws {
|
||||
try await control(["action": "presentation-samples", "messages": [
|
||||
["role": "user", "content": "**literal bold** `literal code`\n- literal item\n[not a link](https://example.invalid)"],
|
||||
["role": "assistant", "content": "Soft break\ncontinues here.\n\n**Real bold** and [Local link](http://127.0.0.1:8799).\n\n- First item\n- Second item\n\n```swift\nlet value = 42\n```"]]])
|
||||
app.launch(); openChat()
|
||||
assertMarkdownSemantics(); capture("polish-normal-literal-and-markdown")
|
||||
openAgent()
|
||||
assertMarkdownSemantics(); capture("polish-agent-literal-and-markdown")
|
||||
try await control(["action": "long-tool"])
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
}
|
||||
private func assertMarkdownSemantics() {
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "**literal bold**")).firstMatch.waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "[not a link](https://example.invalid)")).firstMatch.exists)
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "First item")).firstMatch.exists)
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "let value = 42")).firstMatch.exists)
|
||||
XCTAssertTrue(app.links["Local link"].exists)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user