143 lines
8.0 KiB
Swift
143 lines
8.0 KiB
Swift
import XCTest
|
|||
|
|
import SwiftUI
|
||
|
|
@testable import Sybil
|
||
|
|
|
||
|
|
/// Run on an iPhone simulator, with the real Agent view and deterministic local
|
||
|
|
/// messages. Display-link delivery gaps measure main-run-loop responsiveness,
|
||
|
|
/// not GPU presentation, touch latency, or physical-device frame rate.
|
||
|
|
@MainActor
|
||
|
|
final class AgentPerformanceTests: XCTestCase {
|
||
|
|
func testThinkingScrollResponsiveness() async throws {
|
||
|
|
let room = SybilAgentRoom()
|
||
|
|
var rows: [Message] = []
|
||
|
|
for index in 1...160 { rows.append(Self.message(index)) }
|
||
|
|
try room.receive(.messages(AgentPage(conversationId: "performance", messages: rows, cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
|
||
|
|
room.flush()
|
||
|
|
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
|
||
|
|
.background(SybilTheme.background).environment(\.colorScheme, .dark))
|
||
|
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||
|
|
let window = UIWindow(windowScene: scene)
|
||
|
|
window.rootViewController = host
|
||
|
|
window.makeKeyAndVisible()
|
||
|
|
defer {
|
||
|
|
AgentRenderDiagnostics.enabled = false
|
||
|
|
window.isHidden = true
|
||
|
|
window.rootViewController = nil
|
||
|
|
room.stop()
|
||
|
|
}
|
||
|
|
host.view.frame = window.bounds
|
||
|
|
host.view.layoutIfNeeded()
|
||
|
|
try await Task.sleep(for: .seconds(2))
|
||
|
|
let scroll = try XCTUnwrap(findScroll(host.view))
|
||
|
|
XCTAssertGreaterThan(scroll.contentSize.height, 10_000)
|
||
|
|
// Move away from the pinned bottom before the controlled sweeps.
|
||
|
|
scroll.setContentOffset(CGPoint(x: 0, y: max(0, scroll.contentSize.height - scroll.bounds.height - 1200)), animated: false)
|
||
|
|
try await Task.sleep(for: .seconds(1))
|
||
|
|
AgentRenderDiagnostics.enabled = true
|
||
|
|
var reports: [[String: Any]] = []
|
||
|
|
for phase in ["idle-scroll", "thinking-stationary", "thinking-scroll"] {
|
||
|
|
if phase == "thinking-stationary" {
|
||
|
|
try room.receive(.activity("performance", busyActivity(step: 0)))
|
||
|
|
room.flush()
|
||
|
|
scroll.setContentOffset(CGPoint(x: 0, y: scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom), animated: false)
|
||
|
|
try await Task.sleep(for: .seconds(1))
|
||
|
|
XCTAssertTrue(room.snapshot.isTyping)
|
||
|
|
}
|
||
|
|
AgentRenderDiagnostics.counts = [:]
|
||
|
|
let sampler = AgentFrameSampler(scroll: phase == "thinking-stationary" ? nil : scroll)
|
||
|
|
sampler.start()
|
||
|
|
// Changing queue/worker/tool progress every 500 ms exercises real
|
||
|
|
// activity observation while the typing spinner remains active. No IO.
|
||
|
|
for step in 1...24 {
|
||
|
|
try await Task.sleep(for: .milliseconds(500))
|
||
|
|
if phase != "idle-scroll" {
|
||
|
|
try room.receive(.activity("performance", busyActivity(step: step)))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var report = sampler.stop()
|
||
|
|
report["phase"] = phase
|
||
|
|
report["procedure"] = "mixed160-changing-activity-v1"
|
||
|
|
report["operations"] = AgentRenderDiagnostics.counts
|
||
|
|
report["contentHeight"] = scroll.contentSize.height
|
||
|
|
// iPhone 16e / iOS 26.5 / default text size reference geometry from
|
||
|
|
// the unoptimized view. A faster result must not shrink the content.
|
||
|
|
XCTAssertEqual(scroll.contentSize.height,
|
||
|
|
phase == "idle-scroll" ? 39_181.333333 : 39_223.333333,
|
||
|
|
accuracy: 1)
|
||
|
|
reports.append(report)
|
||
|
|
}
|
||
|
|
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
|
||
|
|
print("AGENT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
|
||
|
|
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
|
||
|
|
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func message(_ index: Int) -> Message {
|
||
|
|
let own = index % 3 == 0
|
||
|
|
let content = own
|
||
|
|
? "Can you review step \(index)? Keep the scroll position and the **literal input** intact.\n\nInclude the next checks."
|
||
|
|
: "### Investigation \(index)\n\nThe worker checked the local transcript and found a repeatable result. This paragraph contains **emphasis**, `inline code`, and a [reference](https://example.com).\n\n- Preserve the reading position while new messages arrive.\n- Keep attachments, queued messages, and status updates visible.\n- Verify the result on the simulator before publishing.\n\n```swift\nlet item = messages[\(index)]\nrender(item)\n```\n\nThe next step is to compare the measurements and report any remaining limitations."
|
||
|
|
return Message(id: "perf-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000 + Double(index * 60)),
|
||
|
|
role: own ? .user : .assistant, content: content,
|
||
|
|
metadata: .object(["kind": .string("message"), "entryId": .string(String(index))]))
|
||
|
|
}
|
||
|
|
|
||
|
|
private func busyActivity(step: Int) -> AgentActivity {
|
||
|
|
AgentActivity(state: "thinking", isThinking: true, queued: 1 + step % 3,
|
||
|
|
backgroundWorkers: 1 + step % 2,
|
||
|
|
tools: [.init(id: "local-review", name: "review_transcript", status: "running",
|
||
|
|
output: "Checked \(step * 7) of 200 local entries; preparing batch \(step + 1).")])
|
||
|
|
}
|
||
|
|
|
||
|
|
private func findScroll(_ view: UIView) -> UIScrollView? {
|
||
|
|
if let scroll = view as? UIScrollView, scroll.contentSize.height > 10_000 { return scroll }
|
||
|
|
return view.subviews.lazy.compactMap { self.findScroll($0) }.first
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@MainActor
|
||
|
|
private final class AgentFrameSampler: NSObject {
|
||
|
|
private weak var scroll: UIScrollView?
|
||
|
|
private var link: CADisplayLink?
|
||
|
|
private var previous: CFTimeInterval?
|
||
|
|
private var started: CFTimeInterval = 0
|
||
|
|
private var gaps: [Double] = []
|
||
|
|
private var budgets: [Double] = []
|
||
|
|
private var origin: CGFloat = 0
|
||
|
|
private var scrollDistance: CGFloat = 0
|
||
|
|
init(scroll: UIScrollView?) { self.scroll = scroll }
|
||
|
|
func start() {
|
||
|
|
origin = scroll?.contentOffset.y ?? 0
|
||
|
|
started = CACurrentMediaTime()
|
||
|
|
let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
|
||
|
|
link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 60, preferred: 60)
|
||
|
|
link.add(to: .main, forMode: .common)
|
||
|
|
self.link = link
|
||
|
|
}
|
||
|
|
@objc private func frame(_ link: CADisplayLink) {
|
||
|
|
let now = CACurrentMediaTime()
|
||
|
|
if let previous { gaps.append((now - previous) * 1000); budgets.append((link.targetTimestamp - link.timestamp) * 1000) }
|
||
|
|
previous = now
|
||
|
|
if let scroll {
|
||
|
|
// Constant-speed triangular sweep over 2400 points, one cycle / 8 s.
|
||
|
|
let phase = (now - started).truncatingRemainder(dividingBy: 8) / 4
|
||
|
|
let delta = CGFloat(phase <= 1 ? phase : 2 - phase) * 2400
|
||
|
|
let target = max(-scroll.adjustedContentInset.top, origin - delta)
|
||
|
|
scrollDistance += abs(target - scroll.contentOffset.y)
|
||
|
|
scroll.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func stop() -> [String: Any] {
|
||
|
|
link?.invalidate(); link = nil
|
||
|
|
let sorted = gaps.sorted()
|
||
|
|
let budget = budgets.sorted()[budgets.count / 2]
|
||
|
|
func percentile(_ value: Double) -> Double { sorted[min(sorted.count - 1, Int(Double(sorted.count - 1) * value))] }
|
||
|
|
return ["seconds": CACurrentMediaTime() - started, "callbacks": gaps.count,
|
||
|
|
"medianBudgetMS": budget, "p50MS": percentile(0.5), "p95MS": percentile(0.95),
|
||
|
|
"p99MS": percentile(0.99), "maxMS": sorted.last ?? 0,
|
||
|
|
"over25MS": gaps.filter { $0 > 25 }.count, "over50MS": gaps.filter { $0 > 50 }.count,
|
||
|
|
"missedBudgetIntervals": gaps.reduce(0) { $0 + max(0, Int(($1 / budget).rounded()) - 1) },
|
||
|
|
"scrollDistance": scrollDistance]
|
||
|
|
}
|
||
|
|
}
|