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
|
||||
@ViewBuilder var prompt: () -> Prompt
|
||||
@State private var modalID = UUID()
|
||||
@State private var pickerSession: UUID?
|
||||
@State private var showChoices = false
|
||||
@@ -78,10 +79,17 @@ struct AgentAttachmentComposer: View {
|
||||
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
|
||||
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
if room.attachmentDraft.supportsAttachments {
|
||||
Button { pickerSession = room.attachmentSessionID; showChoices = true } label: { Label("Attach", systemImage: "paperclip") }
|
||||
.font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-attach")
|
||||
.disabled(room.attachmentDraft.items.count >= (room.attachmentDraft.capability?.countLimit ?? 0))
|
||||
SybilPromptAttachmentButton(
|
||||
isEnabled: room.attachmentDraft.items.count < (room.attachmentDraft.capability?.countLimit ?? 0)
|
||||
) {
|
||||
pickerSession = room.attachmentSessionID
|
||||
showChoices = true
|
||||
}
|
||||
.accessibilityIdentifier("agent-attach")
|
||||
}
|
||||
prompt()
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Add attachment", isPresented: $showChoices) {
|
||||
|
||||
@@ -205,9 +205,7 @@ struct SybilAgentView: View {
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
AgentAttachmentComposer(room: room)
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
AgentAttachmentComposer(room: room) {
|
||||
SybilPromptField(
|
||||
text: $room.composer,
|
||||
focused: $composerFocused,
|
||||
@@ -218,7 +216,6 @@ struct SybilAgentView: View {
|
||||
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
|
||||
.accessibilityIdentifier("agent-send")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.top, 6)
|
||||
|
||||
@@ -230,15 +230,7 @@ struct MessageBubble: View {
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if isUser {
|
||||
Text(message.content)
|
||||
.font(.sybil(.body))
|
||||
} else {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
}
|
||||
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||
@@ -371,10 +363,7 @@ struct StreamingAssistantContentView: View {
|
||||
}
|
||||
|
||||
if hasAnswer {
|
||||
Markdown(content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
SybilMessageText(content: content, format: .markdown)
|
||||
.textSelection(.enabled)
|
||||
.id("streaming-assistant-answer")
|
||||
.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")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if !viewModel.isSearchMode {
|
||||
Button {
|
||||
SybilPromptAttachmentButton(isEnabled: !viewModel.isActiveSelectionSending) {
|
||||
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)
|
||||
.disabled(viewModel.isActiveSelectionSending)
|
||||
.accessibilityLabel("Attach file")
|
||||
.accessibilityIdentifier("chat-attach")
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,22 @@ include:
|
||||
|
||||
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
|
||||
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:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
@@ -27,6 +43,20 @@ targets:
|
||||
TEST_TARGET_NAME: SybilApp
|
||||
SWIFT_VERSION: 6.0
|
||||
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:
|
||||
build:
|
||||
targets:
|
||||
|
||||
Reference in New Issue
Block a user