221 lines
7.4 KiB
Swift
221 lines
7.4 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import Termind
|
|
|
|
@Suite
|
|
struct ChatSlashCommandTests {
|
|
private let commands = [
|
|
ChatSlashCommand(
|
|
id: "compact", displayName: "Compact",
|
|
summary: "Summarize older turns", icon: .contextRing,
|
|
argumentHint: "instructions"
|
|
),
|
|
ChatSlashCommand(
|
|
id: "plan", displayName: "Plan mode",
|
|
summary: "Research first", icon: .symbol("sparkles")
|
|
),
|
|
ChatSlashCommand(
|
|
id: "reasoning", displayName: "Reasoning",
|
|
summary: "Thinking effort", icon: .symbol("brain")
|
|
),
|
|
]
|
|
|
|
private func options(_ command: ChatSlashCommand) -> [ChatSlashCommand.Option] {
|
|
guard command.id == "reasoning" else { return [] }
|
|
return [
|
|
ChatSlashCommand.Option(id: "low", label: "Low"),
|
|
ChatSlashCommand.Option(id: "medium", label: "Medium"),
|
|
ChatSlashCommand.Option(id: "high", label: "High"),
|
|
]
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
@Test
|
|
func aBareSlashIsACommandBeingNamed() {
|
|
#expect(ChatSlashCommandInput.parse("/") == .naming(""))
|
|
#expect(ChatSlashCommandInput.parse("/comp") == .naming("comp"))
|
|
}
|
|
|
|
@Test
|
|
func aSpaceSeparatesTheNameFromItsArgument() {
|
|
#expect(
|
|
ChatSlashCommandInput.parse("/compact keep the nginx findings")
|
|
== .argument(name: "compact", text: "keep the nginx findings")
|
|
)
|
|
// The trailing space is what advances the palette to the options, so an
|
|
// empty argument has to be distinguishable from no argument at all.
|
|
#expect(ChatSlashCommandInput.parse("/reasoning ") == .argument(name: "reasoning", text: ""))
|
|
}
|
|
|
|
@Test
|
|
func proseIsNotACommand() {
|
|
#expect(ChatSlashCommandInput.parse("check /etc/nginx for the include") == nil)
|
|
#expect(ChatSlashCommandInput.parse("what does df say") == nil)
|
|
#expect(ChatSlashCommandInput.parse("") == nil)
|
|
}
|
|
|
|
/// A path pasted as the whole draft still parses as a name — it is one
|
|
/// token beginning with a slash — but it matches no command, so the palette
|
|
/// stays shut and the draft sends as a message.
|
|
@Test
|
|
func aPastedPathMatchesNoCommandAndSendsAsAMessage() {
|
|
let completion = ChatSlashCommandCompletion.rows(
|
|
draft: "/etc/nginx/nginx.conf", commands: commands, options: options
|
|
)
|
|
|
|
#expect(completion.isEmpty)
|
|
}
|
|
|
|
/// Once a draft has a second line it is prose that happened to start with a
|
|
/// slash, and losing it to a command would be the worst outcome available.
|
|
@Test
|
|
func aMultiLineDraftIsNeverACommand() {
|
|
#expect(ChatSlashCommandInput.parse("/compact\nand also check disk") == nil)
|
|
}
|
|
|
|
// MARK: - Completion
|
|
|
|
@Test
|
|
func displayNamesDoNotChangeTheTypedCommands() {
|
|
#expect(commands[1].displayName == "Plan mode")
|
|
#expect(commands[1].name == "/plan")
|
|
#expect(
|
|
ChatSlashCommandCompletion.accepted(
|
|
.command(commands[1]), takesArgument: false
|
|
) == "/plan"
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func anEmptyNameOffersEveryCommand() {
|
|
let completion = ChatSlashCommandCompletion.rows(
|
|
draft: "/", commands: commands, options: options
|
|
)
|
|
|
|
#expect(completion.rows.count == 3)
|
|
}
|
|
|
|
@Test
|
|
func typingNarrowsTheListAndThenClosesIt() {
|
|
#expect(
|
|
ChatSlashCommandCompletion.rows(
|
|
draft: "/co", commands: commands, options: options
|
|
).rows.count == 1
|
|
)
|
|
#expect(
|
|
ChatSlashCommandCompletion.rows(
|
|
draft: "/zzz", commands: commands, options: options
|
|
).isEmpty
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func aCompletedNameOffersItsOptions() {
|
|
let completion = ChatSlashCommandCompletion.rows(
|
|
draft: "/reasoning ", commands: commands, options: options
|
|
)
|
|
|
|
#expect(completion.rows.count == 3)
|
|
#expect(completion.rows.first?.id == "reasoning/low")
|
|
guard let first = completion.rows.first,
|
|
case .option(let command, _) = first
|
|
else {
|
|
Issue.record("Expected an option row")
|
|
return
|
|
}
|
|
#expect(command.icon == .symbol("brain"))
|
|
}
|
|
|
|
@Test
|
|
func anArgumentFiltersTheOptions() {
|
|
let completion = ChatSlashCommandCompletion.rows(
|
|
draft: "/reasoning hi", commands: commands, options: options
|
|
)
|
|
|
|
#expect(completion.rows.count == 1)
|
|
#expect(completion.rows.first?.id == "reasoning/high")
|
|
}
|
|
|
|
@Test
|
|
func anOptionPageCanSelectItsCurrentValue() {
|
|
let completion = ChatSlashCommandCompletion.rows(
|
|
draft: "/reasoning ", commands: commands, options: options
|
|
)
|
|
|
|
#expect(completion.selectionIndex(commandID: "reasoning", optionID: "medium") == 1)
|
|
}
|
|
|
|
@Test
|
|
func preferredOptionMatchingUsesTheStableIDNotTheVisibleLabel() {
|
|
let model = ChatSlashCommand(
|
|
id: "model", displayName: "Model", summary: "Switch models",
|
|
icon: .symbol("sparkles")
|
|
)
|
|
let providerA = "11111111-1111-1111-1111-111111111111:same-model"
|
|
let providerB = "22222222-2222-2222-2222-222222222222:same-model"
|
|
let completion = ChatSlashCommandCompletion.rows(
|
|
draft: "/model ", commands: [model]
|
|
) { _ in
|
|
[
|
|
ChatSlashCommand.Option(id: providerA, label: "Same model"),
|
|
ChatSlashCommand.Option(id: providerB, label: "Same model"),
|
|
]
|
|
}
|
|
|
|
#expect(completion.selectionIndex(commandID: "model", optionID: providerB) == 1)
|
|
}
|
|
|
|
@Test
|
|
func anUnavailableOrFilteredPreferredOptionFallsBackToTheFirstRow() {
|
|
let completion = ChatSlashCommandCompletion.rows(
|
|
draft: "/reasoning hi", commands: commands, options: options
|
|
)
|
|
|
|
#expect(completion.selectionIndex(commandID: "reasoning", optionID: "medium") == 0)
|
|
#expect(completion.selectionIndex(commandID: "model", optionID: "high") == 0)
|
|
}
|
|
|
|
/// `/compact` takes free text, not a menu. Once the name is complete the
|
|
/// palette gets out of the way so the instructions can be typed.
|
|
@Test
|
|
func aFreeTextCommandClosesThePaletteAfterItsName() {
|
|
let completion = ChatSlashCommandCompletion.rows(
|
|
draft: "/compact drop the log dumps", commands: commands, options: options
|
|
)
|
|
|
|
#expect(completion.isEmpty)
|
|
}
|
|
|
|
// MARK: - Acceptance
|
|
|
|
@Test
|
|
func acceptingANameThatTakesAnArgumentLeavesATrailingSpace() {
|
|
let row = ChatSlashCommandCompletion.Row.command(commands[2])
|
|
|
|
#expect(ChatSlashCommandCompletion.accepted(row, takesArgument: true) == "/reasoning ")
|
|
#expect(ChatSlashCommandCompletion.accepted(row, takesArgument: false) == "/reasoning")
|
|
}
|
|
|
|
@Test
|
|
func acceptingAnOptionWritesTheWholeCommand() {
|
|
let row = ChatSlashCommandCompletion.Row.option(
|
|
commands[2], ChatSlashCommand.Option(id: "high", label: "High")
|
|
)
|
|
|
|
#expect(
|
|
ChatSlashCommandCompletion.accepted(row, takesArgument: false) == "/reasoning high"
|
|
)
|
|
}
|
|
|
|
/// Every effort the picker offers has to round-trip through the draft, or a
|
|
/// command would silently do nothing for one of them.
|
|
@Test
|
|
func everyReasoningEffortRoundTripsThroughItsRawValue() {
|
|
for effort in ReasoningEffort.allCases {
|
|
#expect(ReasoningEffort(rawValue: effort.rawValue) == effort)
|
|
}
|
|
}
|
|
}
|