mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
156 lines
8.3 KiB
Swift
156 lines
8.3 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import XCTest
|
|
@testable import Hapi
|
|
|
|
final class QuestionAnswerDraftTests: XCTestCase {
|
|
private func tool(_ input: String, name: String = "functions.request_user_input") throws -> ChatToolCall {
|
|
ChatToolCall(id: "question", name: name, state: .running,
|
|
input: try JSONDecoder().decode(JSONValue.self, from: Data(input.utf8)), createdAt: 0,
|
|
permission: ToolPermission(id: "request", status: .pending))
|
|
}
|
|
|
|
private let choices = #"{"questions":[{"id":"first","header":"First","question":"Choose **one**","options":[{"label":"A (Recommended)"},{"label":"B"}]},{"id":"last","header":"Last","question":"Choose again","options":[{"label":"C"},{"label":"D"}]}]}"#
|
|
|
|
func testSingleSelectAdvancesOnlyOnExplicitSelectionAndLastRequiresSubmission() throws {
|
|
let call = try tool(choices)
|
|
let form = QuestionAnswerForm(tool: call)
|
|
var draft = QuestionAnswerDraft()
|
|
XCTAssertEqual(form.fields.map(\.header), ["First", "Last"])
|
|
XCTAssertTrue(draft.selections.isEmpty, "Recommended options are not preselected")
|
|
draft.next(in: form)
|
|
XCTAssertEqual(draft.page, 0)
|
|
XCTAssertNil(draft.submission(in: form))
|
|
draft.select(0, at: 0, in: form)
|
|
XCTAssertEqual(draft.page, 1)
|
|
XCTAssertNil(draft.submission(in: form))
|
|
// A stale action from the previous hosted step cannot answer the new one.
|
|
draft.select(1, at: 0, in: form)
|
|
XCTAssertEqual(draft.selections[0], [0])
|
|
XCTAssertNil(draft.selections[1])
|
|
draft.select(1, at: 1, in: form)
|
|
XCTAssertEqual(draft.page, 1)
|
|
XCTAssertEqual(draft.submission(in: form), .nestedAnswers([
|
|
"first": ["A (Recommended)"], "last": ["D"],
|
|
]))
|
|
draft.previous(in: form)
|
|
XCTAssertEqual(draft.page, 0)
|
|
XCTAssertEqual(draft.selections[0], [0])
|
|
// Rebuilding from an identical SSE snapshot must never auto-navigate.
|
|
let refreshed = QuestionAnswerForm(tool: call)
|
|
XCTAssertEqual(draft.currentPage(in: refreshed), 0)
|
|
draft.setText(" keep this note ", at: 0, in: form)
|
|
XCTAssertEqual(draft.page, 0)
|
|
draft.next(in: form)
|
|
XCTAssertEqual(draft.submission(in: form), .nestedAnswers([
|
|
"first": ["A (Recommended)", "user_note: keep this note"], "last": ["D"],
|
|
]))
|
|
}
|
|
|
|
func testSingleQuestionDoesNotMoveOrSubmitWhenSelected() throws {
|
|
let form = try QuestionAnswerForm(tool: tool(#"{"questions":[{"id":"one","options":[{"label":"A"}]}]}"#))
|
|
var draft = QuestionAnswerDraft()
|
|
draft.select(0, at: 0, in: form)
|
|
XCTAssertEqual(draft.page, 0)
|
|
// This only builds an action; the view must explicitly invoke Submit.
|
|
XCTAssertEqual(draft.submission(in: form), .nestedAnswers(["one": ["A"]]))
|
|
}
|
|
|
|
func testMultipleAndTextQuestionsAdvanceManuallyAndPreserveWireOrder() throws {
|
|
let form = try QuestionAnswerForm(tool: tool(#"{"questions":[{"id":"many","multiple":true,"options":[{"label":"A"},{"label":"B"}]},{"id":"text","question":"Explain"},{"id":"optional","required":false,"options":[{"label":"C"}]}]}"#))
|
|
var draft = QuestionAnswerDraft()
|
|
draft.select(1, at: 0, in: form)
|
|
draft.select(0, at: 0, in: form)
|
|
XCTAssertEqual(draft.page, 0)
|
|
draft.next(in: form)
|
|
XCTAssertEqual(draft.page, 1)
|
|
XCTAssertTrue(draft.showsText(at: 1, in: form))
|
|
draft.setText(" \n ", at: 1, in: form)
|
|
draft.next(in: form)
|
|
XCTAssertEqual(draft.page, 1)
|
|
draft.setText(" Reason\nwith two lines ", at: 1, in: form)
|
|
XCTAssertEqual(draft.page, 1)
|
|
draft.next(in: form)
|
|
XCTAssertEqual(draft.submission(in: form), .nestedAnswers([
|
|
"many": ["A", "B"], "text": ["user_note: Reason\nwith two lines"], "optional": [],
|
|
]))
|
|
}
|
|
|
|
func testCustomSingleAnswerAndOptionsAreExclusiveInBothDirections() throws {
|
|
let form = try QuestionAnswerForm(tool: tool(#"{"questions":[{"question":"Choose","options":[{"label":"A (Recommended)"}]}]}"#, name: "AskUserQuestion"))
|
|
var draft = QuestionAnswerDraft()
|
|
draft.select(0, at: 0, in: form)
|
|
draft.setText("Custom", at: 0, in: form)
|
|
XCTAssertEqual(draft.submission(in: form), .flatAnswers(["0": ["Custom"]]))
|
|
draft.select(0, at: 0, in: form)
|
|
XCTAssertEqual(draft.text(at: 0, in: form), "")
|
|
XCTAssertEqual(draft.submission(in: form), .flatAnswers(["0": ["A (Recommended)"]]))
|
|
}
|
|
|
|
func testCursorIDsAndAskMultiselectCustomValuesRemainUnchanged() throws {
|
|
let form = try QuestionAnswerForm(tool: tool(#"{"questions":[{"id":"target","prompt":"Choose","allowMultiple":true,"options":[{"id":"stable-a","label":"A (Recommended)"},{"id":"stable-b","label":"B"}]}]}"#, name: "CursorAskQuestion"))
|
|
var draft = QuestionAnswerDraft()
|
|
draft.select(1, at: 0, in: form)
|
|
draft.select(0, at: 0, in: form)
|
|
draft.setText("Custom", at: 0, in: form)
|
|
XCTAssertEqual(draft.submission(in: form), .flatAnswers(["target": ["stable-a", "stable-b", "Custom"]]))
|
|
draft.select(0, at: 0, in: form)
|
|
XCTAssertEqual(draft.submission(in: form), .flatAnswers(["target": ["stable-b", "Custom"]]))
|
|
}
|
|
|
|
func testTextExpansionPrefillAndRestorationDoNotEraseDrafts() throws {
|
|
let form = try QuestionAnswerForm(tool: tool(choices))
|
|
var draft = QuestionAnswerDraft()
|
|
XCTAssertFalse(draft.showsText(at: 0, in: form))
|
|
draft.toggleText(at: 0, in: form)
|
|
draft.setText("Draft", at: 0, in: form)
|
|
draft.toggleText(at: 0, in: form)
|
|
XCTAssertFalse(draft.showsText(at: 0, in: form))
|
|
XCTAssertEqual(draft.text(at: 0, in: form), "Draft")
|
|
XCTAssertNil(draft.submission(in: form), "A Codex note does not replace a required selection")
|
|
let prefilled = try QuestionAnswerForm(tool: tool(#"{"questions":[{"id":"one","prefill":"Existing text","options":[{"label":"A"}]}]}"#))
|
|
XCTAssertTrue(QuestionAnswerDraft().showsText(at: 0, in: prefilled))
|
|
XCTAssertEqual(QuestionAnswerDraft().text(at: 0, in: prefilled), "Existing text")
|
|
}
|
|
|
|
func testMalformedInputNeverPostsAnEmptyRequestAndAskKeepsFreeTextFallback() throws {
|
|
let request = try QuestionAnswerForm(tool: tool("{}"))
|
|
var draft = QuestionAnswerDraft()
|
|
XCTAssertTrue(request.fields.isEmpty)
|
|
XCTAssertNil(draft.submission(in: request))
|
|
draft.next(in: request)
|
|
let ask = try QuestionAnswerForm(tool: tool("{}", name: "AskUserQuestion"))
|
|
draft.setText("Fallback", at: 0, in: ask)
|
|
XCTAssertEqual(draft.submission(in: ask), .flatAnswers(["0": ["Fallback"]]))
|
|
let duplicates = try QuestionAnswerForm(tool: tool(#"{"questions":[{"id":"same","question":"First"},{"id":"same","question":"Second"}]}"#))
|
|
XCTAssertEqual(duplicates.fields.map(\.question), ["First"])
|
|
}
|
|
|
|
func testRecommendedSuffixIsDisplayOnlyAndMustBeATrailingNonemptyLabel() {
|
|
XCTAssertEqual(QuestionOptionTitle("A (Recommended)").text, "A")
|
|
XCTAssertTrue(QuestionOptionTitle("A (Recommended)").recommended)
|
|
for text in ["(Recommended)", " (Recommended)", "A (Recommended) elsewhere", "Normal"] {
|
|
XCTAssertEqual(QuestionOptionTitle(text).text, text)
|
|
XCTAssertFalse(QuestionOptionTitle(text).recommended)
|
|
}
|
|
}
|
|
|
|
func testCardStateUsesAuthoritativeAnswersAndRetainsFailures() throws {
|
|
var call = try tool(choices)
|
|
func state(_ override: PermissionRowOverride? = nil) -> QuestionCardState {
|
|
QuestionCardState(tool: call, details: questionToolDetails(call), override: override)
|
|
}
|
|
XCTAssertEqual(state(), .answering)
|
|
XCTAssertEqual(state(.resolving), .submitting)
|
|
XCTAssertEqual(state(.alreadyHandled), .handled)
|
|
call.permission?.status = .resolved
|
|
XCTAssertEqual(state(), .handled, "Settled requests must not invent answers")
|
|
call.result = .object(["answers": .object(["first": .object(["answers": .array([.string("A (Recommended)")])])])])
|
|
XCTAssertEqual(state(), .answered)
|
|
XCTAssertEqual(questionAnswerSummary(questionToolDetails(call).questions[0]), "A")
|
|
call.state = .error
|
|
XCTAssertEqual(state(), .failed)
|
|
XCTAssertEqual(questionAnswerSummary(questionToolDetails(call).questions[0]), "A")
|
|
}
|
|
}
|