chore: rename project to clawdbot

This commit is contained in:
Peter Steinberger
2026-01-04 14:32:47 +00:00
parent d48dc71fa4
commit 246adaa119
841 changed files with 4590 additions and 4328 deletions

View File

@@ -0,0 +1,37 @@
@testable import ClawdbotChatUI
import Testing
@Suite struct AssistantTextParserTests {
@Test func splitsThinkAndFinalSegments() {
let segments = AssistantTextParser.segments(
from: "<think>internal</think>\n\n<final>Hello there</final>")
#expect(segments.count == 2)
#expect(segments[0].kind == .thinking)
#expect(segments[0].text == "internal")
#expect(segments[1].kind == .response)
#expect(segments[1].text == "Hello there")
}
@Test func keepsTextWithoutTags() {
let segments = AssistantTextParser.segments(from: "Just text.")
#expect(segments.count == 1)
#expect(segments[0].kind == .response)
#expect(segments[0].text == "Just text.")
}
@Test func ignoresThinkingLikeTags() {
let raw = "<thinking>example</thinking>\nKeep this."
let segments = AssistantTextParser.segments(from: raw)
#expect(segments.count == 1)
#expect(segments[0].kind == .response)
#expect(segments[0].text == raw.trimmingCharacters(in: .whitespacesAndNewlines))
}
@Test func dropsEmptyTaggedContent() {
let segments = AssistantTextParser.segments(from: "<think></think>")
#expect(segments.isEmpty)
}
}

View File

@@ -0,0 +1,27 @@
import ClawdbotKit
import Testing
@Suite struct BonjourEscapesTests {
@Test func decodePassThrough() {
#expect(BonjourEscapes.decode("hello") == "hello")
#expect(BonjourEscapes.decode("") == "")
}
@Test func decodeSpaces() {
#expect(BonjourEscapes.decode("Clawdbot\\032Gateway") == "Clawdbot Gateway")
}
@Test func decodeMultipleEscapes() {
#expect(BonjourEscapes.decode("A\\038B\\047C\\032D") == "A&B/C D")
}
@Test func decodeIgnoresInvalidEscapeSequences() {
#expect(BonjourEscapes.decode("Hello\\03World") == "Hello\\03World")
#expect(BonjourEscapes.decode("Hello\\XYZWorld") == "Hello\\XYZWorld")
}
@Test func decodeUsesDecimalUnicodeScalarValue() {
#expect(BonjourEscapes.decode("Hello\\065World") == "HelloAWorld")
}
}

View File

@@ -0,0 +1,36 @@
import ClawdbotKit
import Foundation
import Testing
@Suite struct CanvasA2UIActionTests {
@Test func sanitizeTagValueIsStable() {
#expect(ClawdbotCanvasA2UIAction.sanitizeTagValue("Hello World!") == "Hello_World_")
#expect(ClawdbotCanvasA2UIAction.sanitizeTagValue(" ") == "-")
#expect(ClawdbotCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
}
@Test func extractActionNameAcceptsNameOrAction() {
#expect(ClawdbotCanvasA2UIAction.extractActionName(["name": "Hello"]) == "Hello")
#expect(ClawdbotCanvasA2UIAction.extractActionName(["action": "Wave"]) == "Wave")
#expect(ClawdbotCanvasA2UIAction.extractActionName(["name": " ", "action": "Fallback"]) == "Fallback")
#expect(ClawdbotCanvasA2UIAction.extractActionName(["action": " "]) == nil)
}
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
actionName: "Get Weather",
session: .init(key: "main", surfaceId: "main"),
component: .init(id: "btnWeather", host: "Peters iPad", instanceId: "ipad16,6"),
contextJSON: "{\"city\":\"Vienna\"}")
let msg = ClawdbotCanvasA2UIAction.formatAgentMessage(messageContext)
#expect(msg.contains("CANVAS_A2UI "))
#expect(msg.contains("action=Get_Weather"))
#expect(msg.contains("session=main"))
#expect(msg.contains("surface=main"))
#expect(msg.contains("component=btnWeather"))
#expect(msg.contains("host=Peter_s_iPad"))
#expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}"))
#expect(msg.hasSuffix(" default=update_canvas"))
}
}

View File

@@ -0,0 +1,43 @@
import ClawdbotKit
import Testing
@Suite struct CanvasA2UITests {
@Test func commandStringsAreStable() {
#expect(ClawdbotCanvasA2UICommand.push.rawValue == "canvas.a2ui.push")
#expect(ClawdbotCanvasA2UICommand.pushJSONL.rawValue == "canvas.a2ui.pushJSONL")
#expect(ClawdbotCanvasA2UICommand.reset.rawValue == "canvas.a2ui.reset")
}
@Test func jsonlDecodesAndValidatesV0_8() throws {
let jsonl = """
{"beginRendering":{"surfaceId":"main","timestamp":1}}
{"surfaceUpdate":{"surfaceId":"main","ops":[]}}
{"dataModelUpdate":{"dataModel":{"title":"Hello"}}}
{"deleteSurface":{"surfaceId":"main"}}
"""
let messages = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
#expect(messages.count == 4)
}
@Test func jsonlRejectsV0_9CreateSurface() {
let jsonl = """
{"createSurface":{"surfaceId":"main"}}
"""
#expect(throws: Error.self) {
_ = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
}
}
@Test func jsonlRejectsUnknownShape() {
let jsonl = """
{"wat":{"nope":1}}
"""
#expect(throws: Error.self) {
_ = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
}
}
}

View File

@@ -0,0 +1,16 @@
import ClawdbotKit
import Foundation
import Testing
@Suite struct CanvasSnapshotFormatTests {
@Test func acceptsJpgAlias() throws {
struct Wrapper: Codable {
var format: ClawdbotCanvasSnapshotFormat
}
let data = try #require("{\"format\":\"jpg\"}".data(using: .utf8))
let decoded = try JSONDecoder().decode(Wrapper.self, from: data)
#expect(decoded.format == .jpeg)
}
}

View File

@@ -0,0 +1,29 @@
@testable import ClawdbotChatUI
import Foundation
import Testing
#if os(macOS)
import AppKit
#endif
#if os(macOS)
private func luminance(_ color: NSColor) throws -> CGFloat {
let rgb = try #require(color.usingColorSpace(.deviceRGB))
return 0.2126 * rgb.redComponent + 0.7152 * rgb.greenComponent + 0.0722 * rgb.blueComponent
}
#endif
@Suite struct ChatThemeTests {
@Test func assistantBubbleResolvesForLightAndDark() throws {
#if os(macOS)
let lightAppearance = try #require(NSAppearance(named: .aqua))
let darkAppearance = try #require(NSAppearance(named: .darkAqua))
let lightResolved = ClawdbotChatTheme.resolvedAssistantBubbleColor(for: lightAppearance)
let darkResolved = ClawdbotChatTheme.resolvedAssistantBubbleColor(for: darkAppearance)
#expect(try luminance(lightResolved) > luminance(darkResolved))
#else
#expect(Bool(true))
#endif
}
}

View File

@@ -0,0 +1,494 @@
@testable import ClawdbotChatUI
import ClawdbotKit
import Foundation
import Testing
private struct TimeoutError: Error, CustomStringConvertible {
let label: String
var description: String { "Timeout waiting for: \(self.label)" }
}
private func waitUntil(
_ label: String,
timeoutSeconds: Double = 2.0,
pollMs: UInt64 = 10,
_ condition: @escaping @Sendable () async -> Bool) async throws
{
let deadline = Date().addingTimeInterval(timeoutSeconds)
while Date() < deadline {
if await condition() {
return
}
try await Task.sleep(nanoseconds: pollMs * 1_000_000)
}
throw TimeoutError(label: label)
}
private actor TestChatTransportState {
var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var sentRunIds: [String] = []
var abortedRunIds: [String] = []
}
private final class TestChatTransport: @unchecked Sendable, ClawdbotChatTransport {
private let state = TestChatTransportState()
private let historyResponses: [ClawdbotChatHistoryPayload]
private let sessionsResponses: [ClawdbotChatSessionsListResponse]
private let stream: AsyncStream<ClawdbotChatTransportEvent>
private let continuation: AsyncStream<ClawdbotChatTransportEvent>.Continuation
init(
historyResponses: [ClawdbotChatHistoryPayload],
sessionsResponses: [ClawdbotChatSessionsListResponse] = [])
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
var cont: AsyncStream<ClawdbotChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in
cont = c
}
self.continuation = cont
}
func events() -> AsyncStream<ClawdbotChatTransportEvent> {
self.stream
}
func setActiveSessionKey(_: String) async throws {}
func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload {
let idx = await self.state.historyCallCount
await self.state.setHistoryCallCount(idx + 1)
if idx < self.historyResponses.count {
return self.historyResponses[idx]
}
return self.historyResponses.last ?? ClawdbotChatHistoryPayload(
sessionKey: sessionKey,
sessionId: nil,
messages: [],
thinkingLevel: "off")
}
func sendMessage(
sessionKey _: String,
message _: String,
thinking _: String,
idempotencyKey: String,
attachments _: [ClawdbotChatAttachmentPayload]) async throws -> ClawdbotChatSendResponse
{
await self.state.sentRunIdsAppend(idempotencyKey)
return ClawdbotChatSendResponse(runId: idempotencyKey, status: "ok")
}
func abortRun(sessionKey _: String, runId: String) async throws {
await self.state.abortedRunIdsAppend(runId)
}
func listSessions(limit _: Int?) async throws -> ClawdbotChatSessionsListResponse {
let idx = await self.state.sessionsCallCount
await self.state.setSessionsCallCount(idx + 1)
if idx < self.sessionsResponses.count {
return self.sessionsResponses[idx]
}
return self.sessionsResponses.last ?? ClawdbotChatSessionsListResponse(
ts: nil,
path: nil,
count: 0,
defaults: nil,
sessions: [])
}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
true
}
func emit(_ evt: ClawdbotChatTransportEvent) {
self.continuation.yield(evt)
}
func lastSentRunId() async -> String? {
let ids = await self.state.sentRunIds
return ids.last
}
func abortedRunIds() async -> [String] {
await self.state.abortedRunIds
}
}
private extension TestChatTransportState {
func setHistoryCallCount(_ v: Int) {
self.historyCallCount = v
}
func setSessionsCallCount(_ v: Int) {
self.sessionsCallCount = v
}
func sentRunIdsAppend(_ v: String) {
self.sentRunIds.append(v)
}
func abortedRunIdsAppend(_ v: String) {
self.abortedRunIds.append(v)
}
}
@Suite struct ChatViewModelTests {
@Test func streamsAssistantAndClearsOnFinal() async throws {
let sessionId = "sess-main"
let history1 = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [],
thinkingLevel: "off")
let history2 = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [
AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "final answer"]],
"timestamp": Date().timeIntervalSince1970 * 1000,
]),
],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history1, history2])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
await MainActor.run {
vm.input = "hi"
vm.send()
}
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 1,
stream: "assistant",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: ["text": AnyCodable("streaming…")])))
try await waitUntil("assistant stream visible") { await MainActor.run { vm.streamingAssistantText == "streaming…" } }
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 2,
stream: "tool",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: [
"phase": AnyCodable("start"),
"name": AnyCodable("demo"),
"toolCallId": AnyCodable("t1"),
"args": AnyCodable(["x": 1]),
])))
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
let runId = try #require(await transport.lastSentRunId())
transport.emit(
.chat(
ClawdbotChatEventPayload(
runId: runId,
sessionKey: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
try await waitUntil("history refresh") { await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } }
#expect(await MainActor.run { vm.streamingAssistantText } == nil)
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
}
@Test func clearsStreamingOnExternalFinalEvent() async throws {
let sessionId = "sess-main"
let history = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history, history])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 1,
stream: "assistant",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: ["text": AnyCodable("external stream")])))
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 2,
stream: "tool",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: [
"phase": AnyCodable("start"),
"name": AnyCodable("demo"),
"toolCallId": AnyCodable("t1"),
"args": AnyCodable(["x": 1]),
])))
try await waitUntil("streaming active") { await MainActor.run { vm.streamingAssistantText == "external stream" } }
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
transport.emit(
.chat(
ClawdbotChatEventPayload(
runId: "other-run",
sessionKey: "main",
state: "final",
message: nil,
errorMessage: nil)))
try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } }
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
}
@Test func sessionChoicesPreferMainAndRecent() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (2 * 60 * 60 * 1000)
let recentOlder = now - (5 * 60 * 60 * 1000)
let stale = now - (26 * 60 * 60 * 1000)
let history = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [],
thinkingLevel: "off")
let sessions = ClawdbotChatSessionsListResponse(
ts: now,
path: nil,
count: 4,
defaults: nil,
sessions: [
ClawdbotChatSessionEntry(
key: "recent-1",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
ClawdbotChatSessionEntry(
key: "main",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: stale,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
ClawdbotChatSessionEntry(
key: "recent-2",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recentOlder,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
ClawdbotChatSessionEntry(
key: "old-1",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: stale,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
])
let transport = TestChatTransport(
historyResponses: [history],
sessionsResponses: [sessions])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["main", "recent-1", "recent-2"])
}
@Test func sessionChoicesIncludeCurrentWhenMissing() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (30 * 60 * 1000)
let history = ClawdbotChatHistoryPayload(
sessionKey: "custom",
sessionId: "sess-custom",
messages: [],
thinkingLevel: "off")
let sessions = ClawdbotChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
ClawdbotChatSessionEntry(
key: "main",
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: recent,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil),
])
let transport = TestChatTransport(
historyResponses: [history],
sessionsResponses: [sessions])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "custom", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
#expect(keys == ["main", "custom"])
}
@Test func clearsStreamingOnExternalErrorEvent() async throws {
let sessionId = "sess-main"
let history = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history, history])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
transport.emit(
.agent(
ClawdbotAgentEventPayload(
runId: sessionId,
seq: 1,
stream: "assistant",
ts: Int(Date().timeIntervalSince1970 * 1000),
data: ["text": AnyCodable("external stream")])))
try await waitUntil("streaming active") { await MainActor.run { vm.streamingAssistantText == "external stream" } }
transport.emit(
.chat(
ClawdbotChatEventPayload(
runId: "other-run",
sessionKey: "main",
state: "error",
message: nil,
errorMessage: "boom")))
try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } }
}
@Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws {
let sessionId = "sess-main"
let history = ClawdbotChatHistoryPayload(
sessionKey: "main",
sessionId: sessionId,
messages: [],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history, history])
let vm = await MainActor.run { ClawdbotChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
await MainActor.run {
vm.input = "hi"
vm.send()
}
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try #require(await transport.lastSentRunId())
await MainActor.run { vm.abort() }
try await waitUntil("abortRun called") {
let ids = await transport.abortedRunIds()
return ids == [runId]
}
// Pending remains until the gateway broadcasts an aborted/final chat event.
#expect(await MainActor.run { vm.pendingRunCount } == 1)
transport.emit(
.chat(
ClawdbotChatEventPayload(
runId: runId,
sessionKey: "main",
state: "aborted",
message: nil,
errorMessage: nil)))
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
}
}

View File

@@ -0,0 +1,19 @@
import XCTest
@testable import ClawdbotKit
final class ElevenLabsTTSValidationTests: XCTestCase {
func testValidatedOutputFormatAllowsOnlyMp3Presets() {
XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128"), "mp3_44100_128")
XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("pcm_16000"), "pcm_16000")
}
func testValidatedLanguageAcceptsTwoLetterCodes() {
XCTAssertEqual(ElevenLabsTTSClient.validatedLanguage("EN"), "en")
XCTAssertNil(ElevenLabsTTSClient.validatedLanguage("eng"))
}
func testValidatedNormalizeAcceptsKnownValues() {
XCTAssertEqual(ElevenLabsTTSClient.validatedNormalize("AUTO"), "auto")
XCTAssertNil(ElevenLabsTTSClient.validatedNormalize("maybe"))
}
}

View File

@@ -0,0 +1,128 @@
import ClawdbotKit
import CoreGraphics
import ImageIO
import Testing
import UniformTypeIdentifiers
@Suite struct JPEGTranscoderTests {
private func makeSolidJPEG(width: Int, height: Int, orientation: Int? = nil) throws -> Data {
let cs = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
guard
let ctx = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: cs,
bitmapInfo: bitmapInfo)
else {
throw NSError(domain: "JPEGTranscoderTests", code: 1)
}
ctx.setFillColor(red: 1, green: 0, blue: 0, alpha: 1)
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
guard let img = ctx.makeImage() else {
throw NSError(domain: "JPEGTranscoderTests", code: 5)
}
let out = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
throw NSError(domain: "JPEGTranscoderTests", code: 2)
}
var props: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: 1.0,
]
if let orientation {
props[kCGImagePropertyOrientation] = orientation
}
CGImageDestinationAddImage(dest, img, props as CFDictionary)
guard CGImageDestinationFinalize(dest) else {
throw NSError(domain: "JPEGTranscoderTests", code: 3)
}
return out as Data
}
private func makeNoiseJPEG(width: Int, height: Int) throws -> Data {
let bytesPerPixel = 4
let byteCount = width * height * bytesPerPixel
var data = Data(count: byteCount)
let cs = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
let out = try data.withUnsafeMutableBytes { rawBuffer -> Data in
guard let base = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
throw NSError(domain: "JPEGTranscoderTests", code: 6)
}
for idx in 0..<byteCount {
base[idx] = UInt8.random(in: 0...255)
}
guard
let ctx = CGContext(
data: base,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * bytesPerPixel,
space: cs,
bitmapInfo: bitmapInfo)
else {
throw NSError(domain: "JPEGTranscoderTests", code: 7)
}
guard let img = ctx.makeImage() else {
throw NSError(domain: "JPEGTranscoderTests", code: 8)
}
let encoded = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(encoded, UTType.jpeg.identifier as CFString, 1, nil) else {
throw NSError(domain: "JPEGTranscoderTests", code: 9)
}
CGImageDestinationAddImage(dest, img, nil)
guard CGImageDestinationFinalize(dest) else {
throw NSError(domain: "JPEGTranscoderTests", code: 10)
}
return encoded as Data
}
return out
}
@Test func downscalesToMaxWidthPx() throws {
let input = try makeSolidJPEG(width: 2000, height: 1000)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 1600)
#expect(abs(out.heightPx - 800) <= 1)
#expect(out.data.count > 0)
}
@Test func doesNotUpscaleWhenSmallerThanMaxWidthPx() throws {
let input = try makeSolidJPEG(width: 800, height: 600)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 800)
#expect(out.heightPx == 600)
}
@Test func normalizesOrientationAndUsesOrientedWidthForMaxWidthPx() throws {
// Encode a landscape image but mark it rotated 90° (orientation 6). Oriented width becomes 1000.
let input = try makeSolidJPEG(width: 2000, height: 1000, orientation: 6)
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
#expect(out.widthPx == 1000)
#expect(out.heightPx == 2000)
}
@Test func respectsMaxBytes() throws {
let input = try makeNoiseJPEG(width: 1600, height: 1200)
let out = try JPEGTranscoder.transcodeToJPEG(
imageData: input,
maxWidthPx: 1600,
quality: 0.95,
maxBytes: 180_000)
#expect(out.data.count <= 180_000)
}
}

View File

@@ -0,0 +1,74 @@
import XCTest
@testable import ClawdbotKit
final class TalkDirectiveTests: XCTestCase {
func testParsesDirectiveAndStripsLine() {
let text = """
{"voice":"abc123","once":true}
Hello there.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertEqual(result.directive?.voiceId, "abc123")
XCTAssertEqual(result.directive?.once, true)
XCTAssertEqual(result.stripped, "Hello there.")
}
func testIgnoresNonDirective() {
let text = "Hello world."
let result = TalkDirectiveParser.parse(text)
XCTAssertNil(result.directive)
XCTAssertEqual(result.stripped, text)
}
func testKeepsDirectiveLineIfNoRecognizedFields() {
let text = """
{"unknown":"value"}
Hello.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertNil(result.directive)
XCTAssertEqual(result.stripped, text)
}
func testParsesExtendedOptions() {
let text = """
{"voice_id":"v1","model_id":"m1","rate":200,"stability":0.5,"similarity":0.8,"style":0.2,"speaker_boost":true,"seed":1234,"normalize":"auto","lang":"en","output_format":"mp3_44100_128"}
Hello.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertEqual(result.directive?.voiceId, "v1")
XCTAssertEqual(result.directive?.modelId, "m1")
XCTAssertEqual(result.directive?.rateWPM, 200)
XCTAssertEqual(result.directive?.stability, 0.5)
XCTAssertEqual(result.directive?.similarity, 0.8)
XCTAssertEqual(result.directive?.style, 0.2)
XCTAssertEqual(result.directive?.speakerBoost, true)
XCTAssertEqual(result.directive?.seed, 1234)
XCTAssertEqual(result.directive?.normalize, "auto")
XCTAssertEqual(result.directive?.language, "en")
XCTAssertEqual(result.directive?.outputFormat, "mp3_44100_128")
XCTAssertEqual(result.stripped, "Hello.")
}
func testSkipsLeadingEmptyLinesWhenParsingDirective() {
let text = """
{"voice":"abc123"}
Hello there.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertEqual(result.directive?.voiceId, "abc123")
XCTAssertEqual(result.stripped, "Hello there.")
}
func testTracksUnknownKeys() {
let text = """
{"voice":"abc","mystery":"value","extra":1}
Hi.
"""
let result = TalkDirectiveParser.parse(text)
XCTAssertEqual(result.directive?.voiceId, "abc")
XCTAssertEqual(result.unknownKeys, ["extra", "mystery"])
}
}

View File

@@ -0,0 +1,16 @@
import XCTest
@testable import ClawdbotKit
final class TalkHistoryTimestampTests: XCTestCase {
func testSecondsTimestampsAreAcceptedWithSmallTolerance() {
XCTAssertTrue(TalkHistoryTimestamp.isAfter(999.6, sinceSeconds: 1000))
XCTAssertFalse(TalkHistoryTimestamp.isAfter(999.4, sinceSeconds: 1000))
}
func testMillisecondsTimestampsAreAcceptedWithSmallTolerance() {
let sinceSeconds = 1_700_000_000.0
let sinceMs = sinceSeconds * 1000
XCTAssertTrue(TalkHistoryTimestamp.isAfter(sinceMs - 500, sinceSeconds: sinceSeconds))
XCTAssertFalse(TalkHistoryTimestamp.isAfter(sinceMs - 501, sinceSeconds: sinceSeconds))
}
}

View File

@@ -0,0 +1,16 @@
import XCTest
@testable import ClawdbotKit
final class TalkPromptBuilderTests: XCTestCase {
func testBuildIncludesTranscript() {
let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil)
XCTAssertTrue(prompt.contains("Talk Mode active."))
XCTAssertTrue(prompt.hasSuffix("\n\nHello"))
}
func testBuildIncludesInterruptionLineWhenProvided() {
let prompt = TalkPromptBuilder.build(transcript: "Hi", interruptedAtSeconds: 1.234)
XCTAssertTrue(prompt.contains("Assistant speech interrupted at 1.2s."))
}
}