ios: unify attachment controls and message typography
This commit is contained in:
@@ -32,8 +32,9 @@ struct AgentPickedPhoto: Transferable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AgentAttachmentComposer: View {
|
struct AgentAttachmentComposer<Prompt: View>: View {
|
||||||
@Bindable var room: SybilAgentRoom
|
@Bindable var room: SybilAgentRoom
|
||||||
|
@ViewBuilder var prompt: () -> Prompt
|
||||||
@State private var modalID = UUID()
|
@State private var modalID = UUID()
|
||||||
@State private var pickerSession: UUID?
|
@State private var pickerSession: UUID?
|
||||||
@State private var showChoices = false
|
@State private var showChoices = false
|
||||||
@@ -78,10 +79,17 @@ struct AgentAttachmentComposer: View {
|
|||||||
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
|
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
|
||||||
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||||
}
|
}
|
||||||
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
if room.attachmentDraft.supportsAttachments {
|
if room.attachmentDraft.supportsAttachments {
|
||||||
Button { pickerSession = room.attachmentSessionID; showChoices = true } label: { Label("Attach", systemImage: "paperclip") }
|
SybilPromptAttachmentButton(
|
||||||
.font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-attach")
|
isEnabled: room.attachmentDraft.items.count < (room.attachmentDraft.capability?.countLimit ?? 0)
|
||||||
.disabled(room.attachmentDraft.items.count >= (room.attachmentDraft.capability?.countLimit ?? 0))
|
) {
|
||||||
|
pickerSession = room.attachmentSessionID
|
||||||
|
showChoices = true
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("agent-attach")
|
||||||
|
}
|
||||||
|
prompt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog("Add attachment", isPresented: $showChoices) {
|
.confirmationDialog("Add attachment", isPresented: $showChoices) {
|
||||||
|
|||||||
@@ -205,9 +205,7 @@ struct SybilAgentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var composer: some View {
|
private var composer: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
AgentAttachmentComposer(room: room) {
|
||||||
AgentAttachmentComposer(room: room)
|
|
||||||
HStack(alignment: .bottom, spacing: 10) {
|
|
||||||
SybilPromptField(
|
SybilPromptField(
|
||||||
text: $room.composer,
|
text: $room.composer,
|
||||||
focused: $composerFocused,
|
focused: $composerFocused,
|
||||||
@@ -218,7 +216,6 @@ struct SybilAgentView: View {
|
|||||||
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
|
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
|
||||||
.accessibilityIdentifier("agent-send")
|
.accessibilityIdentifier("agent-send")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.padding(.top, 6)
|
.padding(.top, 6)
|
||||||
|
|||||||
@@ -230,15 +230,7 @@ struct MessageBubble: View {
|
|||||||
presentation: matchingStreamingAssistantPresentation
|
presentation: matchingStreamingAssistantPresentation
|
||||||
)
|
)
|
||||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
if isUser {
|
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown)
|
||||||
Text(message.content)
|
|
||||||
.font(.sybil(.body))
|
|
||||||
} else {
|
|
||||||
Markdown(message.content)
|
|
||||||
.tint(SybilTheme.primary)
|
|
||||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
|
||||||
.markdownTheme(.sybilReadable)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||||
@@ -371,10 +363,7 @@ struct StreamingAssistantContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if hasAnswer {
|
if hasAnswer {
|
||||||
Markdown(content)
|
SybilMessageText(content: content, format: .markdown)
|
||||||
.tint(SybilTheme.primary)
|
|
||||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
|
||||||
.markdownTheme(.sybilReadable)
|
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.id("streaming-assistant-answer")
|
.id("streaming-assistant-answer")
|
||||||
.transition(answerTransition)
|
.transition(answerTransition)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import MarkdownUI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Both roles and both workspaces use normal chat's readable, Dynamic Type-aware theme.
|
||||||
|
/// Literal input is built from text nodes, never passed through the Markdown parser.
|
||||||
|
struct SybilMessageText: View {
|
||||||
|
enum Format { case literal, markdown }
|
||||||
|
var content: String
|
||||||
|
var format: Format
|
||||||
|
|
||||||
|
static func literalContent(_ text: String) -> MarkdownContent {
|
||||||
|
// Pasted CRLF/CR and blank lines containing spaces or tabs have the same
|
||||||
|
// paragraph boundaries as Markdown. Keep the newline count so deliberate
|
||||||
|
// extra blank lines survive; nonblank lines and Markdown syntax stay literal.
|
||||||
|
let normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
|
||||||
|
.replacingOccurrences(of: "\r", with: "\n")
|
||||||
|
.components(separatedBy: "\n")
|
||||||
|
.map { line in line.allSatisfy { $0 == " " || $0 == "\t" } ? "" : line }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
return MarkdownContent {
|
||||||
|
for paragraph in normalized.components(separatedBy: "\n\n") {
|
||||||
|
Paragraph {
|
||||||
|
// Keep extra blank paragraphs visible, including leading/trailing ones.
|
||||||
|
paragraph.isEmpty ? "\u{00a0}" : paragraph
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||||
|
.markdownTheme(.sybilReadable)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,3 +60,26 @@ struct SybilPromptSendButton: View {
|
|||||||
.accessibilityLabel("Send message")
|
.accessibilityLabel("Send message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The normal chat attachment control, shared with Agent's composer.
|
||||||
|
/// Picker ownership and availability stay with each composer.
|
||||||
|
struct SybilPromptAttachmentButton: View {
|
||||||
|
var isEnabled: Bool
|
||||||
|
var action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: "paperclip")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(Circle().fill(SybilTheme.surface))
|
||||||
|
.overlay(Circle().stroke(SybilTheme.border.opacity(0.82), lineWidth: 1))
|
||||||
|
.foregroundStyle(isEnabled ? SybilTheme.text : SybilTheme.textMuted)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.accessibilityLabel("Attach file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -592,25 +592,10 @@ struct SybilWorkspaceView: View {
|
|||||||
|
|
||||||
HStack(alignment: .bottom, spacing: 10) {
|
HStack(alignment: .bottom, spacing: 10) {
|
||||||
if !viewModel.isSearchMode {
|
if !viewModel.isSearchMode {
|
||||||
Button {
|
SybilPromptAttachmentButton(isEnabled: !viewModel.isActiveSelectionSending) {
|
||||||
isShowingAttachmentOptions = true
|
isShowingAttachmentOptions = true
|
||||||
} label: {
|
|
||||||
Image(systemName: "paperclip")
|
|
||||||
.font(.system(size: 17, weight: .semibold))
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.background(
|
|
||||||
Circle()
|
|
||||||
.fill(SybilTheme.surface)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
Circle()
|
|
||||||
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.foregroundStyle(viewModel.isActiveSelectionSending ? SybilTheme.textMuted : SybilTheme.text)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.accessibilityIdentifier("chat-attach")
|
||||||
.disabled(viewModel.isActiveSelectionSending)
|
|
||||||
.accessibilityLabel("Attach file")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilPromptField(
|
SybilPromptField(
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import MarkdownUI
|
||||||
|
import SwiftUI
|
||||||
|
import Testing
|
||||||
|
import UIKit
|
||||||
|
@testable import Sybil
|
||||||
|
|
||||||
|
@Suite @MainActor
|
||||||
|
struct MessagePresentationTests {
|
||||||
|
@Test func userMarkupRemainsLiteral() {
|
||||||
|
let source = "**bold** [link](https://example.invalid) `code`\n- item\n<script>literal</script>"
|
||||||
|
let content = SybilMessageText.literalContent(source)
|
||||||
|
#expect(content.renderPlainText() == source)
|
||||||
|
#expect(!content.renderHTML().contains("<strong>"))
|
||||||
|
#expect(!content.renderHTML().contains("<a "))
|
||||||
|
#expect(!content.renderHTML().contains("<code>"))
|
||||||
|
#expect(!content.renderHTML().contains("<ul>"))
|
||||||
|
#expect(content.renderHTML().contains("<script>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func literalLineBreaksAndParagraphsStayDistinct() {
|
||||||
|
let content = SybilMessageText.literalContent("First\nSecond\n\nThird")
|
||||||
|
#expect(content.renderPlainText() == "First\nSecond\n\nThird")
|
||||||
|
#expect(content.renderHTML() == "<p>First\nSecond</p>\n<p>Third</p>\n")
|
||||||
|
// Empty paragraphs retain layout space instead of disappearing.
|
||||||
|
#expect(SybilMessageText.literalContent("\n\nFirst\n\n\n\nLast\n\n").renderHTML().components(separatedBy: "<p>").count == 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pastedParagraphBoundariesMatchMarkdown() {
|
||||||
|
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
|
||||||
|
#expect(SybilMessageText.literalContent(source).renderHTML() == "<p>First</p>\n<p>Second</p>\n")
|
||||||
|
}
|
||||||
|
#expect(SybilMessageText.literalContent("First\r\nSecond").renderPlainText() == "First\nSecond")
|
||||||
|
#expect(SybilMessageText.literalContent(" **literal** \n\nLast").renderPlainText() == " **literal** \n\nLast")
|
||||||
|
#expect(SybilMessageText.literalContent("First\r\n \r\n\t\r\n\r\nLast") == SybilMessageText.literalContent("First\n\n\n\nLast"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func bothFormatsShareLineParagraphMetricsAndDynamicType() {
|
||||||
|
func height(_ content: String, _ format: SybilMessageText.Format, _ size: DynamicTypeSize) -> CGFloat {
|
||||||
|
let host = UIHostingController(rootView: SybilMessageText(content: content, format: format)
|
||||||
|
.environment(\.dynamicTypeSize, size))
|
||||||
|
return host.sizeThatFits(in: CGSize(width: 320, height: 10_000)).height
|
||||||
|
}
|
||||||
|
let literal = "First line\nSecond line\n\nAnother paragraph"
|
||||||
|
let markdown = "First line \nSecond line\n\nAnother paragraph"
|
||||||
|
for size in [DynamicTypeSize.large, .accessibility3] {
|
||||||
|
#expect(abs(height(literal, .literal, size) - height(markdown, .markdown, size)) < 1)
|
||||||
|
}
|
||||||
|
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
|
||||||
|
#expect(abs(height(source, .literal, .large) - height(source, .markdown, .large)) < 1)
|
||||||
|
}
|
||||||
|
#expect(height(literal, .literal, .accessibility3) > height(literal, .literal, .large) * 1.5)
|
||||||
|
#expect(height(markdown, .markdown, .accessibility3) > height(markdown, .markdown, .large) * 1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,12 @@ async def route(request):
|
|||||||
push_available = True; auth_required = False
|
push_available = True; auth_required = False
|
||||||
for i in range(body.get('count', 3)):
|
for i in range(body.get('count', 3)):
|
||||||
message(f'History message {i + 1}: local simulator fixture.')
|
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':
|
elif action == 'transcript-comparison':
|
||||||
transcript_comparison = True
|
transcript_comparison = True
|
||||||
elif action == 'capability':
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,22 @@ include:
|
|||||||
|
|
||||||
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
|
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
|
||||||
targets:
|
targets:
|
||||||
|
SybilPresentationTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "18.0"
|
||||||
|
sources:
|
||||||
|
- Tests/SybilPresentationTests
|
||||||
|
dependencies:
|
||||||
|
- target: SybilApp
|
||||||
|
- package: Sybil
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.presentation-tests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
SWIFT_VERSION: 6.0
|
||||||
|
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Sybil.app/Sybil"
|
||||||
|
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||||
SybilAgentUITests:
|
SybilAgentUITests:
|
||||||
type: bundle.ui-testing
|
type: bundle.ui-testing
|
||||||
platform: iOS
|
platform: iOS
|
||||||
@@ -27,6 +43,20 @@ targets:
|
|||||||
TEST_TARGET_NAME: SybilApp
|
TEST_TARGET_NAME: SybilApp
|
||||||
SWIFT_VERSION: 6.0
|
SWIFT_VERSION: 6.0
|
||||||
schemes:
|
schemes:
|
||||||
|
SybilPresentation:
|
||||||
|
run:
|
||||||
|
commandLineArguments:
|
||||||
|
"-sybil.ios.apiBaseURL http://127.0.0.1:8799/api -sybil.ios.adminToken fixture-token": true
|
||||||
|
environmentVariables:
|
||||||
|
SYBIL_PUSH_FIXTURE: "1"
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
SybilApp: all
|
||||||
|
SybilPresentationTests: [test]
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- SybilPresentationTests
|
||||||
SybilAgentUI:
|
SybilAgentUI:
|
||||||
build:
|
build:
|
||||||
targets:
|
targets:
|
||||||
|
|||||||
Reference in New Issue
Block a user