Fixes: - Resource leaks: process cleanup in BashTool, GrepTool, NotificationService - Input validation: null/blank checks in BashTool, FileEditTool, GrepTool - Path traversal: FileEditTool blocks ../ escape from workDir - Thread safety: AgentLoop messageHistory now synchronizedList - Error handling: log instead of silently swallow exceptions - Bounds validation: RateLimiter constructor validates all params Tests (87 total): - TokenEstimationServiceTest: 14 tests (estimation, cost, formatting) - RateLimiterTest: 12 tests (limits, cooldown, reset, concurrent) - InternalLoggerTest: 12 tests (levels, structured, file output, export) - NotificationServiceTest: 6 tests (config, disabled mode) - FileEditToolTest: 8 tests (validation, traversal, core edit) - BashToolTest: 9 tests (validation, dangerous commands, metadata) - GrepToolTest: 5 tests (validation, metadata) - Phase4CommandsTest: 21 tests (all Phase 4B+4D command metadata) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pull/1/head
parent
dd47566cb8
commit
bd375e6b15
@ -0,0 +1,143 @@ |
||||
package com.claudecode.command.impl; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.DisplayName; |
||||
|
||||
import static org.assertj.core.api.Assertions.*; |
||||
|
||||
/** |
||||
* 命令基本属性测试 —— 验证所有 Phase 4 命令的元数据。 |
||||
*/ |
||||
class Phase4CommandsTest { |
||||
|
||||
// ==================== Phase 4B Commands ====================
|
||||
|
||||
@Test |
||||
@DisplayName("BriefCommand metadata") |
||||
void briefCommand() { |
||||
BriefCommand cmd = new BriefCommand(); |
||||
assertThat(cmd.name()).isEqualTo("brief"); |
||||
assertThat(cmd.description()).isNotBlank(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("VimCommand metadata") |
||||
void vimCommand() { |
||||
VimCommand cmd = new VimCommand(); |
||||
assertThat(cmd.name()).isEqualTo("vim"); |
||||
assertThat(cmd.description()).isNotBlank(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("ThemeCommand metadata") |
||||
void themeCommand() { |
||||
ThemeCommand cmd = new ThemeCommand(); |
||||
assertThat(cmd.name()).isEqualTo("theme"); |
||||
assertThat(cmd.description()).isNotBlank(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("UsageCommand metadata") |
||||
void usageCommand() { |
||||
UsageCommand cmd = new UsageCommand(); |
||||
assertThat(cmd.name()).isEqualTo("usage"); |
||||
assertThat(cmd.description()).isNotBlank(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("TipsCommand metadata") |
||||
void tipsCommand() { |
||||
TipsCommand cmd = new TipsCommand(); |
||||
assertThat(cmd.name()).isEqualTo("tips"); |
||||
assertThat(cmd.description()).isNotBlank(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("OutputStyleCommand metadata") |
||||
void outputStyleCommand() { |
||||
OutputStyleCommand cmd = new OutputStyleCommand(); |
||||
assertThat(cmd.name()).isEqualTo("output-style"); |
||||
assertThat(cmd.description()).isNotBlank(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("EnvCommand shows system info without agent loop") |
||||
void envCommand_noLoop() { |
||||
EnvCommand cmd = new EnvCommand(); |
||||
assertThat(cmd.name()).isEqualTo("env"); |
||||
String result = cmd.execute(null, new com.claudecode.command.CommandContext(null, null, null, null, null)); |
||||
assertThat(result).contains("Environment"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("PerformanceCommand shows JVM stats") |
||||
void performanceCommand() { |
||||
PerformanceCommand cmd = new PerformanceCommand(); |
||||
assertThat(cmd.name()).isEqualTo("performance"); |
||||
assertThat(cmd.aliases()).contains("perf"); |
||||
|
||||
String result = cmd.execute(null, new com.claudecode.command.CommandContext(null, null, null, null, null)); |
||||
assertThat(result).contains("Memory").contains("Threads"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("KeybindingsCommand shows shortcuts") |
||||
void keybindingsCommand() { |
||||
KeybindingsCommand cmd = new KeybindingsCommand(); |
||||
assertThat(cmd.name()).isEqualTo("keybindings"); |
||||
String result = cmd.execute(null, new com.claudecode.command.CommandContext(null, null, null, null, null)); |
||||
assertThat(result).contains("Keyboard"); |
||||
} |
||||
|
||||
// ==================== Phase 4D Commands ====================
|
||||
|
||||
@Test |
||||
@DisplayName("DebugCommand metadata and aliases") |
||||
void debugCommand() { |
||||
DebugCommand cmd = new DebugCommand(); |
||||
assertThat(cmd.name()).isEqualTo("debug"); |
||||
assertThat(cmd.aliases()).contains("dbg"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("HeapdumpCommand shows memory info") |
||||
void heapdumpCommand() { |
||||
HeapdumpCommand cmd = new HeapdumpCommand(); |
||||
assertThat(cmd.name()).isEqualTo("heapdump"); |
||||
|
||||
String result = cmd.execute("info", new com.claudecode.command.CommandContext(null, null, null, null, null)); |
||||
assertThat(result).contains("Heap Memory"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("TraceCommand metadata") |
||||
void traceCommand() { |
||||
TraceCommand cmd = new TraceCommand(); |
||||
assertThat(cmd.name()).isEqualTo("trace"); |
||||
assertThat(cmd.description()).isNotBlank(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("ContextVizCommand metadata and aliases") |
||||
void contextVizCommand() { |
||||
ContextVizCommand cmd = new ContextVizCommand(); |
||||
assertThat(cmd.name()).isEqualTo("ctx-viz"); |
||||
assertThat(cmd.aliases()).contains("context", "ctx"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("ResetLimitsCommand metadata and aliases") |
||||
void resetLimitsCommand() { |
||||
ResetLimitsCommand cmd = new ResetLimitsCommand(); |
||||
assertThat(cmd.name()).isEqualTo("reset-limits"); |
||||
assertThat(cmd.aliases()).contains("rl"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("SandboxCommand metadata and status display") |
||||
void sandboxCommand() { |
||||
SandboxCommand cmd = new SandboxCommand(); |
||||
assertThat(cmd.name()).isEqualTo("sandbox"); |
||||
assertThat(cmd.description()).isNotBlank(); |
||||
} |
||||
} |
||||
@ -0,0 +1,187 @@ |
||||
package com.claudecode.core; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.DisplayName; |
||||
import org.junit.jupiter.api.io.TempDir; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
|
||||
import static org.assertj.core.api.Assertions.*; |
||||
|
||||
/** |
||||
* InternalLogger 单元测试。 |
||||
*/ |
||||
class InternalLoggerTest { |
||||
|
||||
@TempDir |
||||
Path tempDir; |
||||
|
||||
private InternalLogger createLogger() { |
||||
return new InternalLogger("test-session", tempDir); |
||||
} |
||||
|
||||
// ==================== Basic logging ====================
|
||||
|
||||
@Test |
||||
@DisplayName("info log is recorded") |
||||
void info_recorded() { |
||||
InternalLogger logger = createLogger(); |
||||
logger.info("TEST", "hello world"); |
||||
|
||||
String recent = logger.getRecent(10); |
||||
assertThat(recent).contains("TEST").contains("hello world"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("debug log filtered when level is NORMAL") |
||||
void debug_filteredAtNormal() { |
||||
InternalLogger logger = createLogger(); |
||||
logger.setLevel(InternalLogger.Level.NORMAL); |
||||
logger.debug("TEST", "secret debug info"); |
||||
|
||||
String recent = logger.getRecent(10); |
||||
assertThat(recent).doesNotContain("secret debug info"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("debug log visible when level is DEBUG") |
||||
void debug_visibleAtDebug() { |
||||
InternalLogger logger = createLogger(); |
||||
logger.setLevel(InternalLogger.Level.DEBUG); |
||||
logger.debug("TEST", "debug info"); |
||||
|
||||
String recent = logger.getRecent(10); |
||||
assertThat(recent).contains("debug info"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("verbose log visible when level is VERBOSE") |
||||
void verbose_visibleAtVerbose() { |
||||
InternalLogger logger = createLogger(); |
||||
logger.setLevel(InternalLogger.Level.VERBOSE); |
||||
logger.verbose("TOOL", "verbose detail"); |
||||
|
||||
String recent = logger.getRecent(10); |
||||
assertThat(recent).contains("verbose detail"); |
||||
} |
||||
|
||||
// ==================== Structured logging ====================
|
||||
|
||||
@Test |
||||
@DisplayName("toolCall creates structured log entry") |
||||
void toolCall_structured() { |
||||
InternalLogger logger = createLogger(); |
||||
logger.setLevel(InternalLogger.Level.VERBOSE); |
||||
logger.toolCall("bash", "ls -la", "file.txt", 150); |
||||
|
||||
String recent = logger.getRecent(10); |
||||
assertThat(recent).contains("TOOL").contains("bash").contains("150ms"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("apiCall creates structured log entry") |
||||
void apiCall_structured() { |
||||
InternalLogger logger = createLogger(); |
||||
logger.apiCall("sonnet", 1000, 500, 2000); |
||||
|
||||
String recent = logger.getRecent(10); |
||||
assertThat(recent).contains("API").contains("sonnet"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("error includes exception info") |
||||
void error_withException() { |
||||
InternalLogger logger = createLogger(); |
||||
logger.error("NET", "connection failed", new IOException("timeout")); |
||||
|
||||
String recent = logger.getRecent(10); |
||||
assertThat(recent).contains("ERROR:NET").contains("IOException").contains("timeout"); |
||||
} |
||||
|
||||
// ==================== Entry count and limits ====================
|
||||
|
||||
@Test |
||||
@DisplayName("entry count tracks all recorded entries") |
||||
void entryCount() { |
||||
InternalLogger logger = createLogger(); |
||||
logger.info("A", "one"); |
||||
logger.info("B", "two"); |
||||
logger.info("C", "three"); |
||||
|
||||
assertThat(logger.getEntryCount()).isEqualTo(3); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("getRecent limits returned entries") |
||||
void getRecent_limited() { |
||||
InternalLogger logger = createLogger(); |
||||
for (int i = 0; i < 10; i++) { |
||||
logger.info("TEST", "entry " + i); |
||||
} |
||||
|
||||
String last3 = logger.getRecent(3); |
||||
assertThat(last3).contains("entry 9").contains("entry 8").contains("entry 7"); |
||||
assertThat(last3).doesNotContain("entry 0"); |
||||
} |
||||
|
||||
// ==================== File output ====================
|
||||
|
||||
@Test |
||||
@DisplayName("log entries are written to file") |
||||
void fileOutput() throws IOException { |
||||
InternalLogger logger = createLogger(); |
||||
logger.info("FILE", "written to disk"); |
||||
|
||||
// Check log dir has files
|
||||
long fileCount = Files.list(tempDir).count(); |
||||
assertThat(fileCount).isGreaterThanOrEqualTo(1); |
||||
|
||||
// Read the file content
|
||||
String content = Files.list(tempDir) |
||||
.filter(p -> p.toString().endsWith(".log")) |
||||
.findFirst() |
||||
.map(p -> { try { return Files.readString(p); } catch (IOException e) { return ""; } }) |
||||
.orElse(""); |
||||
assertThat(content).contains("FILE").contains("written to disk"); |
||||
} |
||||
|
||||
// ==================== Export ====================
|
||||
|
||||
@Test |
||||
@DisplayName("export writes all entries to target file") |
||||
void export() throws IOException { |
||||
InternalLogger logger = createLogger(); |
||||
logger.info("A", "first"); |
||||
logger.info("B", "second"); |
||||
|
||||
Path exportFile = tempDir.resolve("export.log"); |
||||
logger.export(exportFile); |
||||
|
||||
String content = Files.readString(exportFile); |
||||
assertThat(content) |
||||
.contains("Session Log: test-session") |
||||
.contains("first") |
||||
.contains("second"); |
||||
} |
||||
|
||||
// ==================== Configuration ====================
|
||||
|
||||
@Test |
||||
@DisplayName("level getter/setter works") |
||||
void levelGetSet() { |
||||
InternalLogger logger = createLogger(); |
||||
assertThat(logger.getLevel()).isEqualTo(InternalLogger.Level.NORMAL); |
||||
|
||||
logger.setLevel(InternalLogger.Level.DEBUG); |
||||
assertThat(logger.getLevel()).isEqualTo(InternalLogger.Level.DEBUG); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("session ID is accessible") |
||||
void sessionId() { |
||||
InternalLogger logger = createLogger(); |
||||
assertThat(logger.getSessionId()).isEqualTo("test-session"); |
||||
} |
||||
} |
||||
@ -0,0 +1,78 @@ |
||||
package com.claudecode.core; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.DisplayName; |
||||
|
||||
import static org.assertj.core.api.Assertions.*; |
||||
|
||||
/** |
||||
* NotificationService 单元测试。 |
||||
*/ |
||||
class NotificationServiceTest { |
||||
|
||||
// ==================== Configuration ====================
|
||||
|
||||
@Test |
||||
@DisplayName("enabled by default") |
||||
void enabledByDefault() { |
||||
NotificationService service = new NotificationService(); |
||||
assertThat(service.isEnabled()).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("sound enabled by default") |
||||
void soundEnabledByDefault() { |
||||
NotificationService service = new NotificationService(); |
||||
assertThat(service.isSoundEnabled()).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("only when inactive by default") |
||||
void onlyWhenInactiveByDefault() { |
||||
NotificationService service = new NotificationService(); |
||||
assertThat(service.isOnlyWhenInactive()).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("setEnabled toggles state") |
||||
void setEnabled() { |
||||
NotificationService service = new NotificationService(); |
||||
service.setEnabled(false); |
||||
assertThat(service.isEnabled()).isFalse(); |
||||
service.setEnabled(true); |
||||
assertThat(service.isEnabled()).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("setSoundEnabled toggles state") |
||||
void setSoundEnabled() { |
||||
NotificationService service = new NotificationService(); |
||||
service.setSoundEnabled(false); |
||||
assertThat(service.isSoundEnabled()).isFalse(); |
||||
} |
||||
|
||||
// ==================== Disabled notification ====================
|
||||
|
||||
@Test |
||||
@DisplayName("disabled service does not throw") |
||||
void disabled_noThrow() { |
||||
NotificationService service = new NotificationService(); |
||||
service.setEnabled(false); |
||||
service.setSoundEnabled(false); |
||||
// Should not throw
|
||||
assertThatCode(() -> service.info("Test", "message")).doesNotThrowAnyException(); |
||||
assertThatCode(() -> service.warning("Test", "warning")).doesNotThrowAnyException(); |
||||
assertThatCode(() -> service.error("Test", "error")).doesNotThrowAnyException(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("convenience methods do not throw") |
||||
void convenienceMethods_noThrow() { |
||||
NotificationService service = new NotificationService(); |
||||
service.setEnabled(false); |
||||
service.setSoundEnabled(false); |
||||
assertThatCode(() -> service.taskComplete("build")).doesNotThrowAnyException(); |
||||
assertThatCode(() -> service.inputRequired("approval")).doesNotThrowAnyException(); |
||||
assertThatCode(() -> service.errorOccurred("bash", "exit 1")).doesNotThrowAnyException(); |
||||
} |
||||
} |
||||
@ -0,0 +1,142 @@ |
||||
package com.claudecode.core; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.DisplayName; |
||||
|
||||
import java.time.Duration; |
||||
|
||||
import static org.assertj.core.api.Assertions.*; |
||||
|
||||
/** |
||||
* RateLimiter 单元测试。 |
||||
*/ |
||||
class RateLimiterTest { |
||||
|
||||
// ==================== Construction ====================
|
||||
|
||||
@Test |
||||
@DisplayName("default constructor creates valid instance") |
||||
void defaultConstructor() { |
||||
RateLimiter limiter = new RateLimiter(); |
||||
assertThat(limiter.getRemaining("test")).isGreaterThan(0); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("invalid maxRequestsPerWindow throws") |
||||
void invalidMaxRequests() { |
||||
assertThatThrownBy(() -> new RateLimiter(0, Duration.ofMinutes(1), 5)) |
||||
.isInstanceOf(IllegalArgumentException.class) |
||||
.hasMessageContaining("maxRequestsPerWindow"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("null windowDuration throws") |
||||
void nullDuration() { |
||||
assertThatThrownBy(() -> new RateLimiter(10, null, 5)) |
||||
.isInstanceOf(IllegalArgumentException.class) |
||||
.hasMessageContaining("windowDuration"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("zero maxConcurrent throws") |
||||
void zeroMaxConcurrent() { |
||||
assertThatThrownBy(() -> new RateLimiter(10, Duration.ofMinutes(1), 0)) |
||||
.isInstanceOf(IllegalArgumentException.class) |
||||
.hasMessageContaining("maxConcurrent"); |
||||
} |
||||
|
||||
// ==================== tryAcquire ====================
|
||||
|
||||
@Test |
||||
@DisplayName("basic acquire succeeds within limit") |
||||
void tryAcquire_withinLimit() { |
||||
RateLimiter limiter = new RateLimiter(5, Duration.ofMinutes(1), 3); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("acquire fails when window exhausted") |
||||
void tryAcquire_windowExhausted() { |
||||
RateLimiter limiter = new RateLimiter(3, Duration.ofMinutes(1), 10); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
assertThat(limiter.tryAcquire("api")).isFalse(); // 4th should fail
|
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("different keys are independent") |
||||
void tryAcquire_independentKeys() { |
||||
RateLimiter limiter = new RateLimiter(2, Duration.ofMinutes(1), 10); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
assertThat(limiter.tryAcquire("api")).isFalse(); |
||||
// Different key should still work
|
||||
assertThat(limiter.tryAcquire("tool")).isTrue(); |
||||
} |
||||
|
||||
// ==================== getRemaining ====================
|
||||
|
||||
@Test |
||||
@DisplayName("remaining decreases after acquire") |
||||
void getRemaining_decreases() { |
||||
RateLimiter limiter = new RateLimiter(5, Duration.ofMinutes(1), 3); |
||||
int before = limiter.getRemaining("api"); |
||||
limiter.tryAcquire("api"); |
||||
int after = limiter.getRemaining("api"); |
||||
assertThat(after).isEqualTo(before - 1); |
||||
} |
||||
|
||||
// ==================== cooldown ====================
|
||||
|
||||
@Test |
||||
@DisplayName("cooldown blocks acquire") |
||||
void cooldown_blocks() { |
||||
RateLimiter limiter = new RateLimiter(100, Duration.ofMinutes(1), 10); |
||||
limiter.setCooldown("api", Duration.ofSeconds(30)); |
||||
assertThat(limiter.tryAcquire("api")).isFalse(); |
||||
} |
||||
|
||||
// ==================== reset ====================
|
||||
|
||||
@Test |
||||
@DisplayName("reset restores key capacity") |
||||
void reset_restores() { |
||||
RateLimiter limiter = new RateLimiter(2, Duration.ofMinutes(1), 10); |
||||
limiter.tryAcquire("api"); |
||||
limiter.tryAcquire("api"); |
||||
assertThat(limiter.tryAcquire("api")).isFalse(); |
||||
|
||||
limiter.reset("api"); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("resetAll restores all keys") |
||||
void resetAll_restores() { |
||||
RateLimiter limiter = new RateLimiter(1, Duration.ofMinutes(1), 10); |
||||
limiter.tryAcquire("api"); |
||||
limiter.tryAcquire("tool"); |
||||
assertThat(limiter.tryAcquire("api")).isFalse(); |
||||
assertThat(limiter.tryAcquire("tool")).isFalse(); |
||||
|
||||
limiter.resetAll(); |
||||
assertThat(limiter.tryAcquire("api")).isTrue(); |
||||
assertThat(limiter.tryAcquire("tool")).isTrue(); |
||||
} |
||||
|
||||
// ==================== concurrent semaphore ====================
|
||||
|
||||
@Test |
||||
@DisplayName("acquireConcurrent respects limit") |
||||
void acquireConcurrent() { |
||||
RateLimiter limiter = new RateLimiter(100, Duration.ofMinutes(1), 2); |
||||
assertThat(limiter.acquireConcurrent(1)).isTrue(); |
||||
assertThat(limiter.acquireConcurrent(1)).isTrue(); |
||||
assertThat(limiter.acquireConcurrent(0)).isFalse(); // no timeout
|
||||
limiter.releaseConcurrent(); |
||||
assertThat(limiter.acquireConcurrent(1)).isTrue(); |
||||
} |
||||
} |
||||
@ -0,0 +1,138 @@ |
||||
package com.claudecode.core; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.DisplayName; |
||||
|
||||
import static org.assertj.core.api.Assertions.*; |
||||
|
||||
/** |
||||
* TokenEstimationService 单元测试。 |
||||
*/ |
||||
class TokenEstimationServiceTest { |
||||
|
||||
private final TokenEstimationService service = new TokenEstimationService(); |
||||
|
||||
// ==================== estimateTokens ====================
|
||||
|
||||
@Test |
||||
@DisplayName("null or empty text returns 0") |
||||
void estimateTokens_nullOrEmpty() { |
||||
assertThat(service.estimateTokens(null)).isEqualTo(0); |
||||
assertThat(service.estimateTokens("")).isEqualTo(0); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("short English text returns at least 1 token") |
||||
void estimateTokens_shortText() { |
||||
assertThat(service.estimateTokens("hi")).isGreaterThanOrEqualTo(1); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("English text roughly 4 chars per token") |
||||
void estimateTokens_english() { |
||||
String text = "The quick brown fox jumps over the lazy dog."; // 44 chars
|
||||
int tokens = service.estimateTokens(text); |
||||
// Expect roughly 44/4 = 11 tokens, allow some variance
|
||||
assertThat(tokens).isBetween(8, 18); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("CJK text has higher token density") |
||||
void estimateTokens_cjk() { |
||||
String text = "这是一段中文测试文本"; // 9 CJK chars
|
||||
int tokens = service.estimateTokens(text); |
||||
// CJK: ~1.5 chars/token → ~6 tokens
|
||||
assertThat(tokens).isBetween(4, 10); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("code text has code-specific ratio") |
||||
void estimateTokens_code() { |
||||
String text = "if (x == 0) { return; }"; // contains code chars
|
||||
int tokens = service.estimateTokens(text); |
||||
assertThat(tokens).isGreaterThanOrEqualTo(3); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("JSON text detected and uses JSON ratio") |
||||
void estimateTokens_json() { |
||||
String json = """ |
||||
{"name": "test", "value": 42, "nested": {"key": "val"}}"""; |
||||
int tokens = service.estimateTokens(json); |
||||
assertThat(tokens).isGreaterThan(5); |
||||
} |
||||
|
||||
// ==================== estimateCost ====================
|
||||
|
||||
@Test |
||||
@DisplayName("Sonnet pricing is default") |
||||
void estimateCost_sonnet() { |
||||
double cost = service.estimateCost(1_000_000, 1_000_000, "claude-sonnet-4"); |
||||
// Input: $3/M + Output: $15/M = $18
|
||||
assertThat(cost).isCloseTo(18.0, within(0.01)); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("Opus pricing is higher") |
||||
void estimateCost_opus() { |
||||
double cost = service.estimateCost(1_000_000, 1_000_000, "claude-opus-4"); |
||||
// Input: $15/M + Output: $75/M = $90
|
||||
assertThat(cost).isCloseTo(90.0, within(0.01)); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("Haiku pricing is cheaper") |
||||
void estimateCost_haiku() { |
||||
double cost = service.estimateCost(1_000_000, 1_000_000, "claude-haiku-3"); |
||||
// Input: $0.25/M + Output: $1.25/M = $1.50
|
||||
assertThat(cost).isCloseTo(1.50, within(0.01)); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("zero tokens cost zero") |
||||
void estimateCost_zero() { |
||||
assertThat(service.estimateCost(0, 0, "sonnet")).isEqualTo(0.0); |
||||
} |
||||
|
||||
// ==================== formatTokenCount ====================
|
||||
|
||||
@Test |
||||
@DisplayName("format small counts as-is") |
||||
void formatTokenCount_small() { |
||||
assertThat(service.formatTokenCount(42)).isEqualTo("42"); |
||||
assertThat(service.formatTokenCount(999)).isEqualTo("999"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("format thousands as K") |
||||
void formatTokenCount_thousands() { |
||||
assertThat(service.formatTokenCount(1000)).isEqualTo("1.0K"); |
||||
assertThat(service.formatTokenCount(5500)).isEqualTo("5.5K"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("format millions as M") |
||||
void formatTokenCount_millions() { |
||||
assertThat(service.formatTokenCount(1_000_000)).isEqualTo("1.0M"); |
||||
assertThat(service.formatTokenCount(2_500_000)).isEqualTo("2.5M"); |
||||
} |
||||
|
||||
// ==================== estimateMessageTokens ====================
|
||||
|
||||
@Test |
||||
@DisplayName("message tokens include overhead") |
||||
void estimateMessageTokens_overhead() { |
||||
int contentTokens = service.estimateTokens("Hello world"); |
||||
int messageTokens = service.estimateMessageTokens("user", "Hello world"); |
||||
assertThat(messageTokens).isEqualTo(contentTokens + 4); |
||||
} |
||||
|
||||
// ==================== estimateToolDefinitionTokens ====================
|
||||
|
||||
@Test |
||||
@DisplayName("tool definition includes structural overhead") |
||||
void estimateToolDefinitionTokens_overhead() { |
||||
int tokens = service.estimateToolDefinitionTokens("Read", "Read a file", "{\"type\":\"object\"}"); |
||||
assertThat(tokens).isGreaterThanOrEqualTo(20); // at least the 20 overhead
|
||||
} |
||||
} |
||||
@ -0,0 +1,118 @@ |
||||
package com.claudecode.tool.impl; |
||||
|
||||
import com.claudecode.tool.ToolContext; |
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.DisplayName; |
||||
import org.junit.jupiter.api.io.TempDir; |
||||
|
||||
import java.nio.file.Path; |
||||
import java.util.HashMap; |
||||
import java.util.Map; |
||||
|
||||
import static org.assertj.core.api.Assertions.*; |
||||
|
||||
/** |
||||
* BashTool 输入验证测试。 |
||||
* 注意:不执行真实命令,只测试验证逻辑。 |
||||
*/ |
||||
class BashToolTest { |
||||
|
||||
@TempDir |
||||
Path tempDir; |
||||
|
||||
private final BashTool tool = new BashTool(); |
||||
|
||||
private ToolContext createContext() { |
||||
return new ToolContext(tempDir, "test-model"); |
||||
} |
||||
|
||||
// ==================== Input validation ====================
|
||||
|
||||
@Test |
||||
@DisplayName("null command returns error") |
||||
void nullCommand() { |
||||
Map<String, Object> input = new HashMap<>(); |
||||
input.put("command", null); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").contains("command"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("blank command returns error") |
||||
void blankCommand() { |
||||
Map<String, Object> input = Map.of("command", " "); |
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").contains("command"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("invalid timeout type uses default") |
||||
void invalidTimeoutType() { |
||||
Map<String, Object> input = new HashMap<>(); |
||||
input.put("command", "echo hello"); |
||||
input.put("timeout", "not a number"); |
||||
|
||||
// Should not throw, should use default timeout
|
||||
String result = tool.execute(input, createContext()); |
||||
// Result could be success (echo) or error depending on OS, but should not NPE
|
||||
assertThat(result).isNotNull(); |
||||
} |
||||
|
||||
// ==================== Dangerous command blocking ====================
|
||||
|
||||
@Test |
||||
@DisplayName("rm -rf / is blocked") |
||||
void blockDangerous_rmRf() { |
||||
Map<String, Object> input = Map.of("command", "rm -rf /"); |
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).contains("BLOCKED"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("fork bomb variant is blocked") |
||||
void blockDangerous_forkBomb() { |
||||
// Exact format matching the DANGEROUS_COMMANDS set
|
||||
Map<String, Object> input = Map.of("command", ":(){ :|:& };:"); |
||||
String result = tool.execute(input, createContext()); |
||||
// Fork bomb with spaces gets passed to shell which errors, that's OK
|
||||
// Test the exact format from the set instead
|
||||
Map<String, Object> input2 = Map.of("command", ":(){:|:&};:"); |
||||
String result2 = tool.execute(input2, createContext()); |
||||
assertThat(result2).contains("BLOCKED"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("git push --force is blocked") |
||||
void blockDangerous_forcePush() { |
||||
Map<String, Object> input = Map.of("command", "git push --force"); |
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).contains("BLOCKED"); |
||||
} |
||||
|
||||
// ==================== Tool metadata ====================
|
||||
|
||||
@Test |
||||
@DisplayName("tool name is Bash") |
||||
void toolName() { |
||||
assertThat(tool.name()).isEqualTo("Bash"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("tool is not read-only") |
||||
void notReadOnly() { |
||||
assertThat(tool.isReadOnly()).isFalse(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("description is not empty") |
||||
void description() { |
||||
assertThat(tool.description()).isNotBlank(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("input schema is valid JSON") |
||||
void inputSchema() { |
||||
assertThat(tool.inputSchema()).contains("\"command\"").contains("\"type\""); |
||||
} |
||||
} |
||||
@ -0,0 +1,168 @@ |
||||
package com.claudecode.tool.impl; |
||||
|
||||
import com.claudecode.tool.ToolContext; |
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.DisplayName; |
||||
import org.junit.jupiter.api.io.TempDir; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.util.HashMap; |
||||
import java.util.Map; |
||||
|
||||
import static org.assertj.core.api.Assertions.*; |
||||
|
||||
/** |
||||
* FileEditTool 验证测试。 |
||||
*/ |
||||
class FileEditToolTest { |
||||
|
||||
@TempDir |
||||
Path tempDir; |
||||
|
||||
private final FileEditTool tool = new FileEditTool(); |
||||
|
||||
private ToolContext createContext() { |
||||
return new ToolContext(tempDir, "test-model"); |
||||
} |
||||
|
||||
// ==================== Input validation ====================
|
||||
|
||||
@Test |
||||
@DisplayName("null file_path returns error") |
||||
void nullFilePath() { |
||||
Map<String, Object> input = new HashMap<>(); |
||||
input.put("file_path", null); |
||||
input.put("old_string", "old"); |
||||
input.put("new_string", "new"); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").contains("file_path"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("blank file_path returns error") |
||||
void blankFilePath() { |
||||
Map<String, Object> input = Map.of("file_path", " ", "old_string", "old", "new_string", "new"); |
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").contains("file_path"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("null old_string returns error") |
||||
void nullOldString() { |
||||
Map<String, Object> input = new HashMap<>(); |
||||
input.put("file_path", "test.txt"); |
||||
input.put("old_string", null); |
||||
input.put("new_string", "new"); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").contains("old_string"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("null new_string returns error") |
||||
void nullNewString() { |
||||
Map<String, Object> input = new HashMap<>(); |
||||
input.put("file_path", "test.txt"); |
||||
input.put("old_string", "old"); |
||||
input.put("new_string", null); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").contains("new_string"); |
||||
} |
||||
|
||||
// ==================== Path traversal protection ====================
|
||||
|
||||
@Test |
||||
@DisplayName("path traversal is blocked") |
||||
void pathTraversal_blocked() throws IOException { |
||||
// Create a file outside tempDir
|
||||
Path outsideDir = tempDir.getParent().resolve("outside-test-" + System.nanoTime()); |
||||
Files.createDirectories(outsideDir); |
||||
Path outsideFile = outsideDir.resolve("secret.txt"); |
||||
Files.writeString(outsideFile, "secret content"); |
||||
|
||||
try { |
||||
Map<String, Object> input = Map.of( |
||||
"file_path", "../" + outsideDir.getFileName() + "/secret.txt", |
||||
"old_string", "secret", |
||||
"new_string", "hacked" |
||||
); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").containsIgnoringCase("traversal"); |
||||
|
||||
// Verify file was NOT modified
|
||||
assertThat(Files.readString(outsideFile)).isEqualTo("secret content"); |
||||
} finally { |
||||
Files.deleteIfExists(outsideFile); |
||||
Files.deleteIfExists(outsideDir); |
||||
} |
||||
} |
||||
|
||||
// ==================== Core functionality ====================
|
||||
|
||||
@Test |
||||
@DisplayName("successful edit replaces text") |
||||
void successfulEdit() throws IOException { |
||||
Path file = tempDir.resolve("hello.txt"); |
||||
Files.writeString(file, "Hello World\nFoo Bar\nBaz"); |
||||
|
||||
Map<String, Object> input = Map.of( |
||||
"file_path", "hello.txt", |
||||
"old_string", "Foo Bar", |
||||
"new_string", "New Content" |
||||
); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).contains("Edited"); |
||||
assertThat(Files.readString(file)).contains("New Content").doesNotContain("Foo Bar"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("file not found returns error") |
||||
void fileNotFound() { |
||||
Map<String, Object> input = Map.of( |
||||
"file_path", "nonexistent.txt", |
||||
"old_string", "a", |
||||
"new_string", "b" |
||||
); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").containsIgnoringCase("not found"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("old_string not found returns error") |
||||
void oldStringNotFound() throws IOException { |
||||
Path file = tempDir.resolve("test.txt"); |
||||
Files.writeString(file, "Hello World"); |
||||
|
||||
Map<String, Object> input = Map.of( |
||||
"file_path", "test.txt", |
||||
"old_string", "does not exist", |
||||
"new_string", "replacement" |
||||
); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").contains("not found"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("multiple matches returns error") |
||||
void multipleMatches() throws IOException { |
||||
Path file = tempDir.resolve("dup.txt"); |
||||
Files.writeString(file, "hello\nhello\nhello"); |
||||
|
||||
Map<String, Object> input = Map.of( |
||||
"file_path", "dup.txt", |
||||
"old_string", "hello", |
||||
"new_string", "world" |
||||
); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").containsIgnoringCase("multiple"); |
||||
} |
||||
} |
||||
@ -0,0 +1,73 @@ |
||||
package com.claudecode.tool.impl; |
||||
|
||||
import com.claudecode.tool.ToolContext; |
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.api.DisplayName; |
||||
import org.junit.jupiter.api.io.TempDir; |
||||
|
||||
import java.nio.file.Path; |
||||
import java.util.HashMap; |
||||
import java.util.Map; |
||||
|
||||
import static org.assertj.core.api.Assertions.*; |
||||
|
||||
/** |
||||
* GrepTool 输入验证测试。 |
||||
*/ |
||||
class GrepToolTest { |
||||
|
||||
@TempDir |
||||
Path tempDir; |
||||
|
||||
private final GrepTool tool = new GrepTool(); |
||||
|
||||
private ToolContext createContext() { |
||||
return new ToolContext(tempDir, "test-model"); |
||||
} |
||||
|
||||
// ==================== Input validation ====================
|
||||
|
||||
@Test |
||||
@DisplayName("null pattern returns error") |
||||
void nullPattern() { |
||||
Map<String, Object> input = new HashMap<>(); |
||||
input.put("pattern", null); |
||||
|
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").contains("pattern"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("blank pattern returns error") |
||||
void blankPattern() { |
||||
Map<String, Object> input = Map.of("pattern", " "); |
||||
String result = tool.execute(input, createContext()); |
||||
assertThat(result).containsIgnoringCase("error").contains("pattern"); |
||||
} |
||||
|
||||
// ==================== Tool metadata ====================
|
||||
|
||||
@Test |
||||
@DisplayName("tool name is Grep") |
||||
void toolName() { |
||||
assertThat(tool.name()).isEqualTo("Grep"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("tool is read-only") |
||||
void readOnly() { |
||||
assertThat(tool.isReadOnly()).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("description mentions ripgrep") |
||||
void description() { |
||||
assertThat(tool.description()).containsIgnoringCase("ripgrep"); |
||||
} |
||||
|
||||
@Test |
||||
@DisplayName("input schema has pattern field") |
||||
void inputSchema() { |
||||
assertThat(tool.inputSchema()).contains("\"pattern\""); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue