Files

170 lines
7.8 KiB
Swift

import XCTest
@testable import AIFabric
final class LocalMLXProviderTests: XCTestCase {
private let baseURL = URL(string: "http://100.76.254.116:8890")!
override func tearDown() {
MockURLProtocol.reset()
super.tearDown()
}
private func provider() -> LocalMLXProvider {
LocalMLXProvider(baseURL: baseURL, model: "mac-mini-default", session: MockURLProtocol.session())
}
// MARK: isAvailable probe (wake )
func testProbeAvailable() async throws {
MockURLProtocol.handler = { req in
MockURLProtocol.ok(req.url!, json: Data(#"{"data":[{"id":"gemma-macmini"}]}"#.utf8))
}
let available = await provider().isAvailable
XCTAssertTrue(available)
// probe GET /v1/models
XCTAssertEqual(MockURLProtocol.recorder.lastURL?.path, "/v1/models")
XCTAssertEqual(MockURLProtocol.recorder.lastMethod, "GET")
}
func testProbeUnavailableOnError() async throws {
MockURLProtocol.handler = { _ in throw URLError(.cannotConnectToHost) }
let available = await provider().isAvailable
XCTAssertFalse(available) // false(throw )
}
func testProbeUnavailableOn500() async throws {
MockURLProtocol.handler = { req in MockURLProtocol.status(req.url!, 500) }
let available = await provider().isAvailable
XCTAssertFalse(available)
}
// MARK: complete + call-shape
func testCompleteMapsResponseFixture() async throws {
let body = try Fixture.data("llm-router-chat.response.json")
MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) }
let resp = try await provider().complete(
AICompletionRequest(task: .quickSummarize, prompt: "충격시험 면제 기준을 한 문장으로 요약해줘.",
systemPrompt: "You are a concise technical assistant.", maxTokens: 512)
)
XCTAssertEqual(resp.providerUsed, .localMLX)
XCTAssertEqual(resp.finishReason, .completed)
XCTAssertTrue(resp.citations.isEmpty)
XCTAssertNotNil(resp.latencyMs)
XCTAssertTrue(resp.text.contains("면제")) //
}
func testCompleteRequestCallShape() async throws {
let body = try Fixture.data("llm-router-chat.response.json")
MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) }
_ = try await provider().complete(
AICompletionRequest(task: .quickSummarize, prompt: "PROMPT_X",
systemPrompt: "SYS_Y", maxTokens: 512)
)
// POST /v1/chat/completions
XCTAssertEqual(MockURLProtocol.recorder.lastURL?.path, "/v1/chat/completions")
XCTAssertEqual(MockURLProtocol.recorder.lastMethod, "POST")
// messages system/user call-shape (load-bearing)
let sent = try XCTUnwrap(MockURLProtocol.recorder.lastBody)
let decoded = try JSONDecoder().decode(SentRequest.self, from: sent)
XCTAssertEqual(decoded.model, "mac-mini-default")
XCTAssertEqual(decoded.maxTokens, 512)
XCTAssertEqual(decoded.stream, false)
XCTAssertEqual(decoded.messages.count, 2)
XCTAssertEqual(decoded.messages[0].role, "system")
XCTAssertEqual(decoded.messages[0].content, "SYS_Y")
XCTAssertEqual(decoded.messages[1].role, "user")
XCTAssertEqual(decoded.messages[1].content, "PROMPT_X")
}
func testNilSystemPromptSendsEmptySystemMessage() async throws {
let body = try Fixture.data("llm-router-chat.response.json")
MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) }
_ = try await provider().complete(AICompletionRequest(task: .quickSummarize, prompt: "P"))
let sent = try XCTUnwrap(MockURLProtocol.recorder.lastBody)
let decoded = try JSONDecoder().decode(SentRequest.self, from: sent)
XCTAssertEqual(decoded.messages[0].role, "system")
XCTAssertEqual(decoded.messages[0].content, "") // plan S2-2c: systemPrompt ?? ""
}
func testNon200BackendError() async throws {
MockURLProtocol.handler = { req in MockURLProtocol.status(req.url!, 503, body: "model loading") }
do {
_ = try await provider().complete(AICompletionRequest(task: .quickSummarize, prompt: "P"))
XCTFail("non-200 must throw backendError, not silent empty text")
} catch let AIProviderError.backendError(id, status, reason) {
XCTAssertEqual(id, .localMLX)
XCTAssertEqual(status, 503)
XCTAssertEqual(reason, "model loading")
}
}
func testRequestFixtureMatchesEncoder() throws {
// request fixture call-shape encodeRequest (릿 placeholder ).
let fixtureData = try Fixture.data("llm-router-chat.request.json")
let fixture = try JSONDecoder().decode(SentRequest.self, from: fixtureData)
XCTAssertEqual(fixture.messages.count, 2)
XCTAssertEqual(fixture.messages[0].role, "system")
XCTAssertEqual(fixture.messages[1].role, "user")
XCTAssertEqual(fixture.stream, false)
}
// MARK: rule-fallback (S2-2d) onDevice localMLX
func testFallbackFromOnDeviceToLocalMLX() async throws {
let body = try Fixture.data("llm-router-chat.response.json")
MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) }
let router = AIRouter(providers: [
.onDevice: MockAIProvider(id: .onDevice, available: false), //
.localMLX: provider(),
])
let resp = try await router.route(AICompletionRequest(task: .quickSummarize, prompt: "P"))
XCTAssertEqual(resp.providerUsed, .localMLX)
XCTAssertEqual(resp.routingNote, "fallback from onDevice → localMLX")
}
func testNoFallbackNoteOnFirstChoiceSuccess() async throws {
let body = try Fixture.data("llm-router-chat.response.json")
MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) }
// classify = [.localMLX, .remoteDS, .onDevice] 1 localMLX note nil
let router = AIRouter(providers: [.localMLX: provider()])
let resp = try await router.route(AICompletionRequest(task: .classify, prompt: "P"))
XCTAssertEqual(resp.providerUsed, .localMLX)
XCTAssertNil(resp.routingNote)
}
// MARK: ( llm-router :8890 offline skip)
func testLiveLocalMLXIfReachable() async throws {
let live = LocalMLXProvider(baseURL: URL(string: "http://100.76.254.116:8890")!) // URLSession, Tailscale
let reachable = await live.isAvailable
guard reachable else {
throw XCTSkip("llm-router :8890 도달 불가(맥미니 offline) — 라이브 테스트 skip")
}
let resp = try await live.complete(
AICompletionRequest(task: .quickSummarize,
prompt: "엘보 내경 가공 핵심을 한 문장으로 요약해줘.",
systemPrompt: "You are a concise technical assistant.",
maxTokens: 200)
)
XCTAssertEqual(resp.providerUsed, .localMLX)
XCTAssertEqual(resp.finishReason, .completed)
XCTAssertFalse(resp.text.isEmpty, "라이브 응답은 비어있지 않아야")
XCTAssertNotNil(resp.latencyMs)
}
/// ( ).
struct SentRequest: Decodable {
struct Message: Decodable { let role: String; let content: String }
let model: String
let messages: [Message]
let maxTokens: Int?
let stream: Bool
enum CodingKeys: String, CodingKey { case model, messages, stream; case maxTokens = "max_tokens" }
}
}