mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
166 lines
10 KiB
Swift
166 lines
10 KiB
Swift
import XCTest
|
|
@testable import Hapi
|
|
import HapiProtocol
|
|
|
|
final class QuestionToolPresentationTests: XCTestCase {
|
|
func testOtherAndNotesShowFromRecordedAnswersWithoutChangingRawInput() throws {
|
|
for flag in [#", "isOther":true"#, ""] {
|
|
let input = "{\"questions\":[{\"id\":\"choice\"\(flag),\"options\":[{\"label\":\"A\"}]}]}"
|
|
for note in ["", #", "user_note: 自定义\n说明""#] {
|
|
let source = "{\"answers\":{\"choice\":{\"answers\":[\"None of the above\"\(note)]}}}"
|
|
for result in [try json(source), JSONValue.string(source)] {
|
|
let call = try tool("request_user_input", input: input, result: result)
|
|
let details = questionToolDetails(call)
|
|
XCTAssertTrue(details.hasAnswers)
|
|
XCTAssertFalse(details.showResult)
|
|
if flag.isEmpty {
|
|
XCTAssertEqual(details.questions[0].otherAnswers, ["None of the above"])
|
|
} else {
|
|
XCTAssertEqual(details.questions[0].options.map(\.selected), [false, true])
|
|
XCTAssertTrue(details.questions[0].options[1].isOther)
|
|
XCTAssertEqual(details.questions[0].otherAnswers, [])
|
|
}
|
|
XCTAssertEqual(details.questions[0].note, note.isEmpty ? nil : "自定义\n说明")
|
|
XCTAssertEqual(call.input, try json(input))
|
|
}
|
|
}
|
|
}
|
|
let input = #"{"questions":[{"id":"choice","isOther":true,"options":[{"label":"A"}]}]}"#
|
|
let call = try tool("request_user_input", input: input,
|
|
answers: #"{"choice":{"answers":["None of the above","user_note: live"]}}"#,
|
|
result: json(#"{"answers":{"choice":{"answers":["A","user_note: stale"]}}}"#))
|
|
XCTAssertEqual(questionToolDetails(call).questions[0].options.map(\.selected), [false, true])
|
|
XCTAssertEqual(questionToolDetails(call).questions[0].note, "live")
|
|
let resolved = try tool("request_user_input", input: input, result: json(#"{"status":"resolved"}"#))
|
|
XCTAssertFalse(questionToolDetails(resolved).hasAnswers)
|
|
}
|
|
|
|
private let askInput = #"{"questions":[{"header":"Storage","question":"Choose **storage**","multiSelect":true,"options":[{"label":"SQLite","description":"Local file"},{"label":"Postgres"}]},{"question":"Anything else?","options":[]}]}"#
|
|
private let requestInput = #"{"questions":[{"id":"target","question":"Deploy where?","multiple":true,"options":[{"label":"staging","description":"Safe"},{"label":"production"}]},{"id":"comment","question":"Comment","prefill":"Not an answer"}]}"#
|
|
|
|
private func json(_ source: String) throws -> JSONValue {
|
|
try JSONDecoder().decode(JSONValue.self, from: Data(source.utf8))
|
|
}
|
|
|
|
private func tool(_ name: String, input: String, answers: String? = nil, result: JSONValue? = nil,
|
|
state: ToolCallState = .completed) throws -> ChatToolCall {
|
|
ChatToolCall(id: "question", name: name, state: state, input: try json(input), createdAt: 0,
|
|
result: result, permission: ToolPermission(id: "reply", status: answers == nil ? .pending : .approved,
|
|
answers: try answers.map(json)))
|
|
}
|
|
|
|
func testAskFlatNestedAndAliasesShowChoicesAndCustomAnswersWithoutChangingWireData() throws {
|
|
for name in ["AskUserQuestion", "ask_user_question", "functions.AskUserQuestion"] {
|
|
for answers in [#"{"0":[" SQLite ","Postgres","Redis"],"1":["Keep it simple"]}"#,
|
|
#"{"0":{"answers":[" SQLite ","Postgres","Redis"]},"1":{"answers":["Keep it simple"]}}"#] {
|
|
let call = try tool(name, input: askInput, answers: answers)
|
|
let details = questionToolDetails(call)
|
|
XCTAssertTrue(isQuestionDetailsTool(name))
|
|
XCTAssertTrue(details.hasAnswers)
|
|
XCTAssertFalse(details.showResult)
|
|
XCTAssertEqual(details.questions[0].header, "Storage")
|
|
XCTAssertTrue(details.questions[0].multiple)
|
|
XCTAssertEqual(details.questions[0].options.map(\.selected), [true, true])
|
|
XCTAssertEqual(details.questions[0].options[0].description, "Local file")
|
|
XCTAssertEqual(details.questions[0].otherAnswers, ["Redis"])
|
|
XCTAssertEqual(details.questions[1].otherAnswers, ["Keep it simple"])
|
|
XCTAssertEqual(call.permission?.answers, try json(answers))
|
|
}
|
|
}
|
|
}
|
|
|
|
func testCursorUsesStableQuestionAndOptionIds() throws {
|
|
let input = #"{"title":"Preferences","questions":[{"id":"storage","prompt":"Choose storage","allowMultiple":false,"options":[{"id":"local","label":"SQLite"},{"id":"server","label":"Postgres"}]}]}"#
|
|
let details = questionToolDetails(try tool("CursorAskQuestion", input: input, answers: #"{"storage":["server"]}"#))
|
|
XCTAssertEqual(details.questions[0].header, "Preferences")
|
|
XCTAssertEqual(details.questions[0].question, "Choose storage")
|
|
XCTAssertFalse(details.questions[0].multiple)
|
|
XCTAssertEqual(details.questions[0].options.map(\.selected), [false, true])
|
|
XCTAssertEqual(details.questions[0].otherAnswers, [])
|
|
}
|
|
|
|
func testRequestHistoryObjectsStringsFlatAndNestedMaps() throws {
|
|
for source in [#"{"target":["staging"],"comment":["plain free text"]}"#,
|
|
#"{"target":{"answers":["staging"]},"comment":{"answers":["plain free text"]}}"#,
|
|
#"{"answers":{"target":{"answers":["staging"]},"comment":{"answers":["plain free text"]}}}"#] {
|
|
for result in [try json(source), JSONValue.string(source)] {
|
|
let details = questionToolDetails(try tool("functions.request_user_input", input: requestInput, result: result))
|
|
XCTAssertTrue(details.hasAnswers)
|
|
XCTAssertFalse(details.showResult)
|
|
XCTAssertEqual(details.questions[0].options.map(\.selected), [true, false])
|
|
XCTAssertEqual(details.questions[1].otherAnswers, ["plain free text"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func testLiveAnswersWinAsAWholeWithoutMergingHistory() throws {
|
|
let result = try json(#"{"answers":{"target":{"answers":["production"]},"comment":{"answers":["stale"]}}}"#)
|
|
let call = try tool("request_user_input", input: requestInput,
|
|
answers: #"{"target":{"answers":["staging","user_note: after tests "]}}"#, result: result)
|
|
let details = questionToolDetails(call)
|
|
XCTAssertEqual(details.questions[0].options.map(\.selected), [true, false])
|
|
XCTAssertEqual(details.questions[0].note, "after tests")
|
|
XCTAssertFalse(details.questions[1].hasAnswers)
|
|
for missing in ["{}", #"{"unknown":["unmapped"]}"#, #"{"target":{"answers":[null,3]}}"#] {
|
|
let fallback = questionToolDetails(try tool("request_user_input", input: requestInput, answers: missing, result: result))
|
|
XCTAssertEqual(fallback.questions[0].options.map(\.selected), [false, true])
|
|
}
|
|
}
|
|
|
|
func testNotePrefixCanBeAnOptionAndEditorWhitespaceAndEmptyDocumentsSurvive() throws {
|
|
let input = #"{"questions":[{"id":"choice","question":"Choose","options":[{"label":"user_note: later"}]},{"id":"document","question":"Edit","inputType":"editor"}]}"#
|
|
for note in [" code\n", ""] {
|
|
let answers: JSONValue = .object([
|
|
"choice": .array([.string("user_note: later")]),
|
|
"document": .object(["answers": .array([.string("user_note: " + note)])]),
|
|
])
|
|
var call = try tool("request_user_input", input: input)
|
|
call.permission?.answers = answers
|
|
let details = questionToolDetails(call)
|
|
XCTAssertTrue(details.questions[0].options[0].selected)
|
|
XCTAssertNil(details.questions[0].note)
|
|
XCTAssertEqual(details.questions[1].note, note)
|
|
XCTAssertTrue(details.questions[1].hasAnswers)
|
|
}
|
|
}
|
|
|
|
func testQuestionNamedAnswersDoesNotGetConfusedWithOuterWrapper() throws {
|
|
let input = #"{"questions":[{"id":"answers","question":"Any comment?"}]}"#
|
|
for source in [#"{"answers":{"answers":["comment"]}}"#,
|
|
#"{"answers":{"answers":{"answers":["comment"]}}}"#] {
|
|
let details = questionToolDetails(try tool("request_user_input", input: input, result: json(source)))
|
|
XCTAssertEqual(details.questions[0].otherAnswers, ["comment"])
|
|
}
|
|
}
|
|
|
|
func testPendingDoesNotInventAnswersFromPrefillAndErrorsRemainVisible() throws {
|
|
let pending = questionToolDetails(try tool("request_user_input", input: requestInput, state: .running))
|
|
XCTAssertFalse(pending.hasAnswers)
|
|
XCTAssertTrue(pending.showResult)
|
|
XCTAssertTrue(pending.questions.allSatisfy { !$0.hasAnswers })
|
|
let failed = questionToolDetails(try tool("AskUserQuestion", input: askInput,
|
|
answers: #"{"0":["SQLite"]}"#, result: .string("Failure"), state: .error))
|
|
XCTAssertTrue(failed.hasAnswers)
|
|
XCTAssertTrue(failed.showResult)
|
|
}
|
|
|
|
func testMalformedInputAndAnswersRetainFallbackAndAsyncStaysOnLegacyPath() throws {
|
|
for input in ["null", "[]", #"{"questions":[null,{},3]}"#] {
|
|
let details = questionToolDetails(try tool("AskUserQuestion", input: input))
|
|
XCTAssertTrue(details.questions.isEmpty)
|
|
XCTAssertTrue(details.showResult)
|
|
}
|
|
let freeform = questionToolDetails(try tool("AskUserQuestion", input: "{}", answers: #"{"0":["A fallback answer"]}"#))
|
|
XCTAssertEqual(freeform.questions[0].otherAnswers, ["A fallback answer"])
|
|
for source in ["not JSON", #"{"answers":{"target":{"answers":"not an array"}}}"#] {
|
|
let details = questionToolDetails(try tool("request_user_input", input: requestInput, result: .string(source)))
|
|
XCTAssertFalse(details.hasAnswers)
|
|
XCTAssertTrue(details.showResult)
|
|
}
|
|
XCTAssertFalse(isQuestionDetailsTool("request_user_input_async"))
|
|
XCTAssertFalse(isQuestionDetailsTool("mcp__server__request_user_input"))
|
|
let duplicate = #"{"questions":[{"id":"same","question":"First"},{"id":"same","question":"Second"}]}"#
|
|
XCTAssertEqual(questionToolDetails(try tool("request_user_input", input: duplicate)).questions.count, 2)
|
|
}
|
|
}
|