test(scarfcore): M9 slash-command surfaces (Phase 1.10)

16 tests across name validation, frontmatter parsing, argument
substitution (plain + default fallback + multiple occurrences),
on-disk round-trip, missing-dir graceful handling, save invalidation,
delete idempotency, and ProjectContextBlock surfacing (slash command
list line + idempotency + omission when empty).

179 tests across 13 suites — green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 08:53:31 +02:00
parent b247942e1f
commit 79a350d793
@@ -0,0 +1,254 @@
import Testing
import Foundation
@testable import ScarfCore
/// v2.5 portable project slash commands. Service is transport-based so
/// these tests use a `LocalTransport`-backed `ServerContext` rooted at a
/// tmp directory (same trick `M5FeatureVMTests` uses for cron / memory).
///
/// The factory-touching tests live in M5 (the canonical `.serialized`
/// suite) these tests don't install a custom factory, they just rely
/// on `ServerContext` defaulting to LocalTransport for `.local` kinds,
/// so they're safe to run in parallel with everything else.
@Suite struct M9SlashCommandTests {
// MARK: - Name validation
@Test func nameValidationAcceptsLowercaseLettersDigitsHyphens() {
#expect(ProjectSlashCommand.validateName("review") == nil)
#expect(ProjectSlashCommand.validateName("deploy-staging") == nil)
#expect(ProjectSlashCommand.validateName("step1") == nil)
}
@Test func nameValidationRejectsBadShapes() {
#expect(ProjectSlashCommand.validateName("") != nil)
#expect(ProjectSlashCommand.validateName("Review") != nil) // uppercase
#expect(ProjectSlashCommand.validateName("1leading") != nil) // leading digit
#expect(ProjectSlashCommand.validateName("with space") != nil)
#expect(ProjectSlashCommand.validateName("under_score") != nil) // underscore not allowed
#expect(ProjectSlashCommand.validateName(String(repeating: "a", count: 65)) != nil)
}
// MARK: - Frontmatter parsing
@Test func parseExtractsRequiredFields() throws {
let raw = """
---
name: review
description: Code-review the current branch
---
Review {{argument}}.
"""
let cmd = try #require(
ProjectSlashCommandService.parse(raw, sourcePath: "/dev/null/review.md")
)
#expect(cmd.name == "review")
#expect(cmd.description == "Code-review the current branch")
#expect(cmd.body.contains("Review {{argument}}."))
}
@Test func parseExtractsOptionalFields() throws {
let raw = """
---
name: deploy
description: Deploy
argumentHint: <env>
model: claude-sonnet-4.5
tags:
- ops
- deploy
---
Deploy to {{argument}}.
"""
let cmd = try #require(
ProjectSlashCommandService.parse(raw, sourcePath: "/dev/null/deploy.md")
)
#expect(cmd.argumentHint == "<env>")
#expect(cmd.model == "claude-sonnet-4.5")
#expect(cmd.tags == ["ops", "deploy"])
}
@Test func parseRejectsMissingFrontmatter() {
let raw = "Just a body, no frontmatter.\n"
#expect(ProjectSlashCommandService.parse(raw, sourcePath: "/dev/null/x.md") == nil)
}
@Test func parseRejectsMissingRequiredFields() {
let raw = """
---
name: only
---
Body.
"""
// Missing description nil.
#expect(ProjectSlashCommandService.parse(raw, sourcePath: "/dev/null/x.md") == nil)
}
// MARK: - Argument substitution
@Test func expandSubstitutesPlainArgument() {
let cmd = ProjectSlashCommand(
name: "x",
description: "x",
body: "Hello {{argument}}, how are you?",
sourcePath: ""
)
let svc = ProjectSlashCommandService(context: .local)
let result = svc.expand(cmd, withArgument: "world")
#expect(result.contains("Hello world, how are you?"))
#expect(result.hasPrefix("<!-- scarf-slash:x -->\n"))
}
@Test func expandUsesDefaultWhenArgumentEmpty() {
let cmd = ProjectSlashCommand(
name: "x",
description: "x",
body: "Focus: {{argument | default: \"general\"}}.",
sourcePath: ""
)
let svc = ProjectSlashCommandService(context: .local)
let empty = svc.expand(cmd, withArgument: "")
#expect(empty.contains("Focus: general."))
let provided = svc.expand(cmd, withArgument: "performance")
#expect(provided.contains("Focus: performance."))
}
@Test func expandReplacesMultipleOccurrences() {
let cmd = ProjectSlashCommand(
name: "x",
description: "x",
body: "{{argument}} and {{argument}} again.",
sourcePath: ""
)
let svc = ProjectSlashCommandService(context: .local)
let result = svc.expand(cmd, withArgument: "foo")
#expect(result.contains("foo and foo again."))
}
// MARK: - Round-trip on disk
@Test func saveAndLoadRoundTripPreservesFields() async throws {
let tmp = try Self.makeTempProject()
defer { try? FileManager.default.removeItem(atPath: tmp) }
let ctx = ServerContext.local
let svc = ProjectSlashCommandService(context: ctx)
let original = ProjectSlashCommand(
name: "review",
description: "Code-review the branch",
argumentHint: "<focus>",
model: "claude-sonnet-4.5",
tags: ["code-review"],
body: "Review {{argument}}.\n",
sourcePath: ""
)
try svc.save(original, at: tmp)
let loaded = svc.loadCommands(at: tmp)
#expect(loaded.count == 1)
let r = try #require(loaded.first)
#expect(r.name == "review")
#expect(r.description == "Code-review the branch")
#expect(r.argumentHint == "<focus>")
#expect(r.model == "claude-sonnet-4.5")
#expect(r.tags == ["code-review"])
#expect(r.body.contains("Review {{argument}}."))
}
@Test func loadCommandsHandlesMissingDirGracefully() {
let tmp = NSTemporaryDirectory() + "scarf-slash-missing-\(UUID().uuidString)"
let svc = ProjectSlashCommandService(context: .local)
// Dir doesn't exist empty list, no throw.
#expect(svc.loadCommands(at: tmp) == [])
}
@Test func deleteRemovesFileAndIsIdempotent() async throws {
let tmp = try Self.makeTempProject()
defer { try? FileManager.default.removeItem(atPath: tmp) }
let svc = ProjectSlashCommandService(context: .local)
let cmd = ProjectSlashCommand(
name: "tmp", description: "x", body: "x\n", sourcePath: ""
)
try svc.save(cmd, at: tmp)
#expect(svc.loadCommands(at: tmp).count == 1)
try svc.delete(named: "tmp", at: tmp)
#expect(svc.loadCommands(at: tmp).isEmpty)
// Deleting something already gone is a no-op.
try svc.delete(named: "tmp", at: tmp)
}
@Test func saveRejectsInvalidName() async throws {
let tmp = try Self.makeTempProject()
defer { try? FileManager.default.removeItem(atPath: tmp) }
let svc = ProjectSlashCommandService(context: .local)
let bad = ProjectSlashCommand(
name: "BadName", description: "x", body: "x\n", sourcePath: ""
)
do {
try svc.save(bad, at: tmp)
Issue.record("expected save to throw on uppercase name")
} catch {
// Expected
}
}
// MARK: - ProjectContextBlock surfacing
@Test func contextBlockListsSlashCommandsWhenPresent() {
let block = ProjectContextBlock.renderMinimalBlock(
projectName: "Demo",
projectPath: "/tmp/demo",
slashCommandNames: ["review", "deploy-staging"]
)
#expect(block.contains("Project slash commands:"))
#expect(block.contains("`/review`"))
#expect(block.contains("`/deploy-staging`"))
// Marker contract held: the block still has begin/end markers.
#expect(block.hasPrefix("<!-- scarf-project:begin -->"))
#expect(block.hasSuffix("<!-- scarf-project:end -->"))
}
@Test func contextBlockOmitsSlashCommandLineWhenEmpty() {
let none = ProjectContextBlock.renderMinimalBlock(
projectName: "Demo",
projectPath: "/tmp/demo",
slashCommandNames: nil
)
#expect(!none.contains("Project slash commands:"))
let emptyArr = ProjectContextBlock.renderMinimalBlock(
projectName: "Demo",
projectPath: "/tmp/demo",
slashCommandNames: []
)
#expect(!emptyArr.contains("Project slash commands:"))
}
@Test func contextBlockIsIdempotent() {
let a = ProjectContextBlock.renderMinimalBlock(
projectName: "Demo",
projectPath: "/tmp/demo",
slashCommandNames: ["b", "a"] // unsorted on input
)
let b = ProjectContextBlock.renderMinimalBlock(
projectName: "Demo",
projectPath: "/tmp/demo",
slashCommandNames: ["a", "b"] // pre-sorted
)
// Output is sorted internally both inputs render identically.
#expect(a == b)
}
// MARK: - Helpers
static func makeTempProject() throws -> String {
let dir = NSTemporaryDirectory() + "scarf-slash-\(UUID().uuidString)"
try FileManager.default.createDirectory(
atPath: dir,
withIntermediateDirectories: true
)
return dir
}
}