282 lines
9.7 KiB
Swift
282 lines
9.7 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import Testing
|
|
|
|
@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(leadingText: "", partial: ""))
|
|
#expect(ChatSlashCommandInput.parse("/comp") == .naming(leadingText: "", partial: "comp"))
|
|
}
|
|
|
|
@Test
|
|
func aSpaceSeparatesTheNameFromItsArgument() {
|
|
#expect(
|
|
ChatSlashCommandInput.parse("/compact keep the nginx findings")
|
|
== .argument(leadingText: "", 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(leadingText: "", name: "reasoning", text: "")
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func textWithoutASlashIsNotACommand() {
|
|
#expect(ChatSlashCommandInput.parse("what does df say") == nil)
|
|
#expect(ChatSlashCommandInput.parse("") == nil)
|
|
}
|
|
|
|
@Test
|
|
func theLastSlashCanOpenCommandsAnywhereInTheDraft() {
|
|
#expect(
|
|
ChatSlashCommandInput.parse("check the service /comp")
|
|
== .naming(leadingText: "check the service ", partial: "comp")
|
|
)
|
|
#expect(
|
|
ChatSlashCommandInput.parse("first /plan then /reasoning hi")
|
|
== .argument(leadingText: "first /plan then ", name: "reasoning", text: "hi")
|
|
)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
@Test
|
|
func aCommandFragmentCannotCrossALineButCanStartOnTheLastLine() {
|
|
#expect(ChatSlashCommandInput.parse("/compact\nand also check disk") == nil)
|
|
#expect(
|
|
ChatSlashCommandInput.parse("check disk\nthen /comp")
|
|
== .naming(leadingText: "check disk\nthen ", partial: "comp")
|
|
)
|
|
}
|
|
|
|
// MARK: - Completion
|
|
|
|
@Test
|
|
func displayNamesDoNotChangeTheTypedCommands() {
|
|
#expect(commands[1].name == "/plan")
|
|
#expect(
|
|
ChatSlashCommandCompletion.accepted(
|
|
.command(commands[1]), takesArgument: false, in: "/pl"
|
|
) == "/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 opaqueArgumentsDoNotIntroduceANewerSlashForPathLikeModelIDs() {
|
|
let argument = ChatSlashCommand.argumentID(
|
|
scope: UUID().uuidString.lowercased(), value: "vendor/model-preview"
|
|
)
|
|
|
|
#expect(!argument.contains("/"))
|
|
#expect(
|
|
ChatSlashCommandInput.parse("draft /model \(argument)")?.name == "model"
|
|
)
|
|
}
|
|
|
|
@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, in: "/rea") == "/reasoning ")
|
|
#expect(ChatSlashCommandCompletion.accepted(row, takesArgument: false, in: "/rea") == "/reasoning")
|
|
}
|
|
|
|
@Test
|
|
func acceptingAnOptionWritesTheWholeCommand() {
|
|
let row = ChatSlashCommandCompletion.Row.option(
|
|
commands[2], ChatSlashCommand.Option(id: "high", label: "High")
|
|
)
|
|
|
|
#expect(
|
|
ChatSlashCommandCompletion.accepted(row, takesArgument: false, in: "/reasoning h")
|
|
== "/reasoning high"
|
|
)
|
|
}
|
|
|
|
@Test
|
|
func acceptingACompletionOnlyReplacesTheTrailingSlashFragment() {
|
|
let row = ChatSlashCommandCompletion.Row.option(
|
|
commands[2], ChatSlashCommand.Option(id: "high", label: "High")
|
|
)
|
|
|
|
#expect(
|
|
ChatSlashCommandCompletion.accepted(
|
|
row, takesArgument: false, in: "keep this prompt /reasoning h"
|
|
) == "keep this prompt /reasoning high"
|
|
)
|
|
#expect(ChatSlashCommandInput.parse("keep this prompt /reasoning high")?.leadingText == "keep this prompt ")
|
|
}
|
|
|
|
// MARK: - Named arguments
|
|
|
|
/// `/skill <name>` arms it, `/skill <name> <text>` arms it and asks in the
|
|
/// same keystroke, and the two have to be told apart from one draft.
|
|
@Test
|
|
func aNamedArgumentSplitsTheNameFromWhatFollowsIt() {
|
|
let armed = ChatSlashCommandInput.parse("/skill disk-triage")
|
|
#expect(armed?.selection?.name == "disk-triage")
|
|
#expect(armed?.selection?.remainder == "")
|
|
|
|
let asked = ChatSlashCommandInput.parse("/skill disk-triage why is root full?")
|
|
#expect(asked?.selection?.name == "disk-triage")
|
|
#expect(asked?.selection?.remainder == "why is root full?")
|
|
|
|
let padded = ChatSlashCommandInput.parse("/skill disk-triage check staging ")
|
|
#expect(padded?.selection?.name == "disk-triage")
|
|
#expect(padded?.selection?.remainder == "check staging")
|
|
}
|
|
|
|
@Test
|
|
func aCommandWithNothingAfterItNamesNothing() {
|
|
#expect(ChatSlashCommandInput.parse("/skill")?.selection == nil)
|
|
#expect(ChatSlashCommandInput.parse("/skill ")?.selection == nil)
|
|
}
|
|
}
|