|
17 | 17 |
|
18 | 18 | import org.junit.jupiter.api.Test; |
19 | 19 |
|
| 20 | +import java.lang.reflect.Field; |
| 21 | + |
20 | 22 | import static org.junit.jupiter.api.Assertions.*; |
21 | 23 |
|
22 | 24 | /** |
@@ -214,4 +216,340 @@ void budgetRatioReturnsCorrectValue() { |
214 | 216 | gs.addTokens(2500); |
215 | 217 | assertEquals(0.75, LoopPromptBuilder.budgetRatio(gs), 0.001); |
216 | 218 | } |
| 219 | + |
| 220 | + // ===== buildBudgetLimitPrompt — tokens 重复 bug 修复专项测试 ===== |
| 221 | + |
| 222 | + @Test |
| 223 | + void budgetLimitPrompt_smallTokens_noDuplication() { |
| 224 | + // consumed < 1000, maxTokens < 1000 — 最关键的 "tokens tokens" 修复场景 |
| 225 | + LoopTask task = createGoalTask(500, 300, 0); |
| 226 | + String result = builder.buildBudgetLimitPrompt(task, task.getGoalState()); |
| 227 | + assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'"); |
| 228 | + assertTrue(result.contains("- 已消耗: 300 tokens / 500 tokens"), |
| 229 | + "should contain '300 tokens / 500 tokens'"); |
| 230 | + } |
| 231 | + |
| 232 | + @Test |
| 233 | + void budgetLimitPrompt_smallConsumedLargeMax() { |
| 234 | + // consumed < 1000, maxTokens >= 1000 |
| 235 | + LoopTask task = createGoalTask(10000, 300, 0); |
| 236 | + String result = builder.buildBudgetLimitPrompt(task, task.getGoalState()); |
| 237 | + assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'"); |
| 238 | + assertTrue(result.contains("- 已消耗: 300 tokens / 10.0k"), |
| 239 | + "consumed < 1000 shows tokens suffix, max >= 1000 shows k format"); |
| 240 | + } |
| 241 | + |
| 242 | + @Test |
| 243 | + void budgetLimitPrompt_largeConsumedSmallMax() { |
| 244 | + // consumed >= 1000, maxTokens < 1000 |
| 245 | + LoopTask task = createGoalTask(500, 2000, 0); |
| 246 | + String result = builder.buildBudgetLimitPrompt(task, task.getGoalState()); |
| 247 | + assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'"); |
| 248 | + assertTrue(result.contains("- 已消耗: 2.0k / 500 tokens"), |
| 249 | + "consumed >= 1000 shows k format, max < 1000 shows tokens suffix"); |
| 250 | + } |
| 251 | + |
| 252 | + @Test |
| 253 | + void budgetLimitPrompt_largeTokensBoth() { |
| 254 | + // consumed >= 1000, maxTokens >= 1000 |
| 255 | + LoopTask task = createGoalTask(10000, 2000, 0); |
| 256 | + String result = builder.buildBudgetLimitPrompt(task, task.getGoalState()); |
| 257 | + assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'"); |
| 258 | + assertTrue(result.contains("- 已消耗: 2.0k / 10.0k"), |
| 259 | + "both >= 1000 should use k format"); |
| 260 | + } |
| 261 | + |
| 262 | + @Test |
| 263 | + void budgetLimitPrompt_unlimitedMaxTokens_smallConsumed() { |
| 264 | + // maxTokens = 0, consumed < 1000 |
| 265 | + LoopTask task = createGoalTask(0, 300, 0); |
| 266 | + String result = builder.buildBudgetLimitPrompt(task, task.getGoalState()); |
| 267 | + assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'"); |
| 268 | + assertTrue(result.contains("- 已消耗: 300 tokens"), |
| 269 | + "unlimited budget, consumed < 1000 should show tokens suffix"); |
| 270 | + } |
| 271 | + |
| 272 | + @Test |
| 273 | + void budgetLimitPrompt_unlimitedMaxTokens_largeConsumed() { |
| 274 | + // maxTokens = 0, consumed >= 1000 |
| 275 | + LoopTask task = createGoalTask(0, 2000, 0); |
| 276 | + String result = builder.buildBudgetLimitPrompt(task, task.getGoalState()); |
| 277 | + assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'"); |
| 278 | + assertTrue(result.contains("- 已消耗: 2.0k"), |
| 279 | + "unlimited budget, consumed >= 1000 should use k format"); |
| 280 | + } |
| 281 | + |
| 282 | + @Test |
| 283 | + void budgetLimitPrompt_zeroConsumed() { |
| 284 | + // 边界:consumed = 0 |
| 285 | + LoopTask task = createGoalTask(10000, 0, 0); |
| 286 | + String result = builder.buildBudgetLimitPrompt(task, task.getGoalState()); |
| 287 | + assertFalse(result.contains("tokens tokens"), "should not duplicate 'tokens'"); |
| 288 | + assertTrue(result.contains("- 已消耗: 0 tokens"), |
| 289 | + "0 tokens should still show '0 tokens'"); |
| 290 | + } |
| 291 | + |
| 292 | + // ===== formatTokens 单测 ===== |
| 293 | + |
| 294 | + @Test |
| 295 | + void formatTokens_below1000() { |
| 296 | + assertEquals("0 tokens", LoopPromptBuilder.formatTokens(0)); |
| 297 | + assertEquals("1 tokens", LoopPromptBuilder.formatTokens(1)); |
| 298 | + assertEquals("500 tokens", LoopPromptBuilder.formatTokens(500)); |
| 299 | + assertEquals("999 tokens", LoopPromptBuilder.formatTokens(999)); |
| 300 | + } |
| 301 | + |
| 302 | + @Test |
| 303 | + void formatTokens_1000to1M() { |
| 304 | + assertEquals("1.0k", LoopPromptBuilder.formatTokens(1000)); |
| 305 | + assertEquals("1.5k", LoopPromptBuilder.formatTokens(1500)); |
| 306 | + assertEquals("10.0k", LoopPromptBuilder.formatTokens(10000)); |
| 307 | + assertEquals("999.9k", LoopPromptBuilder.formatTokens(999900)); |
| 308 | + } |
| 309 | + |
| 310 | + @Test |
| 311 | + void formatTokens_1Mplus() { |
| 312 | + assertEquals("1.0M", LoopPromptBuilder.formatTokens(1_000_000)); |
| 313 | + assertEquals("1.5M", LoopPromptBuilder.formatTokens(1_500_000)); |
| 314 | + assertEquals("10.0M", LoopPromptBuilder.formatTokens(10_000_000)); |
| 315 | + } |
| 316 | + |
| 317 | + // ===== buildBudgetInfo — 确保无 tokens 重复 ===== |
| 318 | + |
| 319 | + @Test |
| 320 | + void buildBudgetInfo_noTokensDuplication() { |
| 321 | + GoalState gs = new GoalState("test", 10000); |
| 322 | + gs.addTokens(300); |
| 323 | + String info = LoopPromptBuilder.buildBudgetInfo(gs); |
| 324 | + assertFalse(info.contains("tokens tokens"), "buildBudgetInfo should not duplicate tokens"); |
| 325 | + |
| 326 | + // maxTokens=0 分支 |
| 327 | + GoalState gs2 = new GoalState("test", 0); |
| 328 | + gs2.addTokens(300); |
| 329 | + String info2 = LoopPromptBuilder.buildBudgetInfo(gs2); |
| 330 | + assertFalse(info2.contains("tokens tokens"), "buildBudgetInfo unlimited should not duplicate tokens"); |
| 331 | + } |
| 332 | + |
| 333 | + // ===== buildBudgetLimitPrompt — 完整内容完整性 ===== |
| 334 | + |
| 335 | + @Test |
| 336 | + void budgetLimitPrompt_containsAllSections() { |
| 337 | + LoopTask task = createGoalTask(10000, 2000, 0); |
| 338 | + String result = builder.buildBudgetLimitPrompt(task, task.getGoalState()); |
| 339 | + assertTrue(result.contains("预算耗尽 (Budget Limit)"), "should have header"); |
| 340 | + assertTrue(result.contains("目标:"), "should contain goal label"); |
| 341 | + assertTrue(result.contains("test goal objective here"), "should contain condition"); |
| 342 | + assertTrue(result.contains("budget_limited"), "should mention budget_limited"); |
| 343 | + assertTrue(result.contains("总结已完成的工作"), "should instruct summary"); |
| 344 | + assertTrue(result.contains("剩余未完成的工作"), "should instruct remaining"); |
| 345 | + assertTrue(result.contains("下一步建议"), "should instruct next steps"); |
| 346 | + } |
| 347 | + |
| 348 | + // ===== 全面覆盖:完整模式剩余分支 ===== |
| 349 | + |
| 350 | + @Test |
| 351 | + void fullModeWithPreviousSummary() { |
| 352 | + // 完整模式 + 非首轮 + 有上一轮结果(覆盖 "上一轮执行摘要" + truncateForPrompt 截断) |
| 353 | + LoopTask task = createGoalTask(10000, 2000, 3); |
| 354 | + String longResult = "第一轮:分析了项目结构,发现主要模块。\n" + |
| 355 | + "第二轮:实现了核心功能,包括A、B、C三个组件。\n" + |
| 356 | + "第三轮:编写了单元测试,编写了大量测试用例确保覆盖率。"; |
| 357 | + // 追加大量字符触发 truncate (maxLen=300, 当前字符约120) |
| 358 | + StringBuilder sb = new StringBuilder(longResult); |
| 359 | + while (sb.length() < 350) { |
| 360 | + sb.append(" 更多内容以确保文本长度超过300字符触发截断机制。"); |
| 361 | + } |
| 362 | + task.updateLastExecution(sb.toString()); |
| 363 | + String result = builder.buildEffectivePrompt(task); |
| 364 | + assertTrue(result.contains("上一轮执行摘要(第 3 轮)"), "full mode should show previous round summary"); |
| 365 | + assertTrue(result.contains("请基于以上进展继续推进"), "should have progress instruction"); |
| 366 | + assertTrue(result.contains("...(省略)..."), "should contain ellipsis when truncated"); |
| 367 | + } |
| 368 | + |
| 369 | + @Test |
| 370 | + void fullModeWithoutStagnationCheck() { |
| 371 | + // 完整模式 + stagnationCount < threshold → 不应出现质疑章节 |
| 372 | + LoopTask task = createGoalTask(10000, 2000, 0); |
| 373 | + task.recordStagnation(); // 1 < 3 |
| 374 | + String result = builder.buildEffectivePrompt(task); |
| 375 | + assertFalse(result.contains("进展质疑 (Stagnation Check)"), "should NOT show stagnation when count < threshold"); |
| 376 | + } |
| 377 | + |
| 378 | + @Test |
| 379 | + void fullModeWithBudgetCritical() throws Exception { |
| 380 | + // isBudgetCritical() 在完整模式下通常不可达(ratio >= 0.30 → consumed <= 70%, |
| 381 | + // 而 critical 需要 >= 85%)。通过 GoalState.configure() 临时降低阈值覆盖此分支。 |
| 382 | + Field warningField = GoalState.class.getDeclaredField("budgetWarningPercent"); |
| 383 | + Field criticalField = GoalState.class.getDeclaredField("budgetCriticalPercent"); |
| 384 | + warningField.setAccessible(true); |
| 385 | + criticalField.setAccessible(true); |
| 386 | + int savedWarning = warningField.getInt(null); |
| 387 | + int savedCritical = criticalField.getInt(null); |
| 388 | + |
| 389 | + try { |
| 390 | + // 设置 critical 阈值为 1%,使 consumed=1000 (10%) 超过阈值 |
| 391 | + GoalState.configure(savedWarning, 1); |
| 392 | + LoopTask task = createGoalTask(10000, 1000, 0); |
| 393 | + String result = builder.buildEffectivePrompt(task); |
| 394 | + assertTrue(result.contains("[紧急]"), "full mode should show '[紧急]' when budget critical"); |
| 395 | + } finally { |
| 396 | + // 恢复原配置,避免影响其他测试 |
| 397 | + warningField.setInt(null, savedWarning); |
| 398 | + criticalField.setInt(null, savedCritical); |
| 399 | + } |
| 400 | + } |
| 401 | + |
| 402 | + @Test |
| 403 | + void fullModeContainsGoalAchievedInstruction() { |
| 404 | + LoopTask task = createGoalTask(10000, 2000, 0); |
| 405 | + String result = builder.buildEffectivePrompt(task); |
| 406 | + assertTrue(result.contains("[GOAL_ACHIEVED]"), "full mode should contain GOAL_ACHIEVED instruction"); |
| 407 | + } |
| 408 | + |
| 409 | + // ===== 全面覆盖:精简模式剩余分支 ===== |
| 410 | + |
| 411 | + @Test |
| 412 | + void compactModeFirstIter_noPreviousSummary() { |
| 413 | + // 精简模式 + isFirstIter → 不应有上一轮摘要 |
| 414 | + LoopTask task = createGoalTask(10000, 8000, 0); // iter=0 |
| 415 | + String result = builder.buildEffectivePrompt(task); |
| 416 | + assertFalse(result.contains("上一轮"), "compact mode first iter should NOT show previous round"); |
| 417 | + } |
| 418 | + |
| 419 | + @Test |
| 420 | + void compactModeNullLastResult_noPreviousSummary() { |
| 421 | + // 精简模式 + 非首轮但 lastResult=null → 不应有上一轮摘要 |
| 422 | + LoopTask task = createGoalTask(10000, 8000, 2); |
| 423 | + // lastResult 显式置 null |
| 424 | + task.setLastResult(null); |
| 425 | + String result = builder.buildEffectivePrompt(task); |
| 426 | + assertFalse(result.contains("上一轮"), "compact mode with null lastResult should NOT show previous round"); |
| 427 | + } |
| 428 | + |
| 429 | + // ===== 全面覆盖:buildBudgetInfo ===== |
| 430 | + |
| 431 | + @Test |
| 432 | + void buildBudgetInfo_withBudgetWarning() { |
| 433 | + // 消耗 75% (70% ≤ 75% < 85%) → isBudgetWarning() = true |
| 434 | + GoalState gs = new GoalState("test", 10000); |
| 435 | + gs.addTokens(7500); |
| 436 | + String info = LoopPromptBuilder.buildBudgetInfo(gs); |
| 437 | + assertTrue(info.contains("[预算提示]"), "should show budget warning at 75%"); |
| 438 | + assertTrue(info.contains("75%"), "should show correct percentage 75%"); |
| 439 | + } |
| 440 | + |
| 441 | + @Test |
| 442 | + void buildBudgetInfo_withBudgetCriticalAndRemaining() { |
| 443 | + // 消耗 90% + remainToken > 0 → 显示 (剩余: …) |
| 444 | + GoalState gs = new GoalState("test", 10000); |
| 445 | + gs.addTokens(9000); |
| 446 | + String info = LoopPromptBuilder.buildBudgetInfo(gs); |
| 447 | + assertTrue(info.contains("剩余: 1.0k"), "should show remaining tokens when critical"); |
| 448 | + } |
| 449 | + |
| 450 | + @Test |
| 451 | + void buildBudgetInfo_withElapsedTime() throws Exception { |
| 452 | + GoalState gs = new GoalState("test", 10000); |
| 453 | + gs.addTokens(5000); |
| 454 | + // 反射设置 startEpochMs 为 5 秒前 |
| 455 | + Field field = GoalState.class.getDeclaredField("startEpochMs"); |
| 456 | + field.setAccessible(true); |
| 457 | + field.setLong(gs, System.currentTimeMillis() - 5000); |
| 458 | + String info = LoopPromptBuilder.buildBudgetInfo(gs); |
| 459 | + assertTrue(info.contains("耗时:"), "should show elapsed time when > 1000ms"); |
| 460 | + } |
| 461 | + |
| 462 | + @Test |
| 463 | + void buildBudgetInfo_startEpochMsZero() throws Exception { |
| 464 | + // startEpochMs = 0 → 不显示耗时,其余信息正常 |
| 465 | + GoalState gs = new GoalState("test", 10000); |
| 466 | + gs.addTokens(300); |
| 467 | + Field field = GoalState.class.getDeclaredField("startEpochMs"); |
| 468 | + field.setAccessible(true); |
| 469 | + field.setLong(gs, 0); |
| 470 | + String info = LoopPromptBuilder.buildBudgetInfo(gs); |
| 471 | + assertFalse(info.contains("耗时:"), "should NOT show elapsed time when startEpochMs is 0"); |
| 472 | + assertTrue(info.contains("已消耗 300 tokens / 10.0k"), "should still show budget info"); |
| 473 | + } |
| 474 | + |
| 475 | + @Test |
| 476 | + void buildBudgetInfo_maxTokensZero_consumedZero_startEpochMsZero() throws Exception { |
| 477 | + // 三个零 → 空字符串 |
| 478 | + GoalState gs = new GoalState("test", 0); |
| 479 | + Field field = GoalState.class.getDeclaredField("startEpochMs"); |
| 480 | + field.setAccessible(true); |
| 481 | + field.setLong(gs, 0); |
| 482 | + String info = LoopPromptBuilder.buildBudgetInfo(gs); |
| 483 | + assertEquals("", info, "should be empty when max=0, consumed=0, startEpochMs=0"); |
| 484 | + } |
| 485 | + |
| 486 | + // ===== 全面覆盖:budgetPercent ===== |
| 487 | + |
| 488 | + @Test |
| 489 | + void budgetPercent_zeroOrNegativeTotal() { |
| 490 | + assertEquals("0", LoopPromptBuilder.budgetPercent(100, 0)); |
| 491 | + assertEquals("0", LoopPromptBuilder.budgetPercent(100, -1)); |
| 492 | + } |
| 493 | + |
| 494 | + @Test |
| 495 | + void budgetPercent_normal() { |
| 496 | + assertEquals("25", LoopPromptBuilder.budgetPercent(2500, 10000)); |
| 497 | + assertEquals("0", LoopPromptBuilder.budgetPercent(0, 10000)); |
| 498 | + assertEquals("100", LoopPromptBuilder.budgetPercent(10000, 10000)); |
| 499 | + } |
| 500 | + |
| 501 | + // ===== 全面覆盖:formatDuration ===== |
| 502 | + |
| 503 | + @Test |
| 504 | + void formatDuration_seconds() { |
| 505 | + assertEquals("0s", LoopPromptBuilder.formatDuration(0)); |
| 506 | + assertEquals("30s", LoopPromptBuilder.formatDuration(30_000)); |
| 507 | + assertEquals("59s", LoopPromptBuilder.formatDuration(59_000)); |
| 508 | + } |
| 509 | + |
| 510 | + @Test |
| 511 | + void formatDuration_minutes() { |
| 512 | + assertEquals("1m 0s", LoopPromptBuilder.formatDuration(60_000)); |
| 513 | + assertEquals("1m 30s", LoopPromptBuilder.formatDuration(90_000)); |
| 514 | + assertEquals("59m 59s", LoopPromptBuilder.formatDuration(3_599_000)); |
| 515 | + } |
| 516 | + |
| 517 | + @Test |
| 518 | + void formatDuration_hours() { |
| 519 | + assertEquals("1h 0m", LoopPromptBuilder.formatDuration(3_600_000)); |
| 520 | + assertEquals("2h 5m", LoopPromptBuilder.formatDuration(7_500_000)); |
| 521 | + assertEquals("24h 0m", LoopPromptBuilder.formatDuration(86_400_000)); |
| 522 | + } |
| 523 | + |
| 524 | + // ===== 全面覆盖:truncateForPrompt ===== |
| 525 | + |
| 526 | + @Test |
| 527 | + void truncateForPrompt_nullOrEmpty() { |
| 528 | + assertEquals("", LoopPromptBuilder.truncateForPrompt(null, 100)); |
| 529 | + assertEquals("", LoopPromptBuilder.truncateForPrompt("", 100)); |
| 530 | + } |
| 531 | + |
| 532 | + @Test |
| 533 | + void truncateForPrompt_withinMaxLen() { |
| 534 | + assertEquals("hello", LoopPromptBuilder.truncateForPrompt("hello", 100)); |
| 535 | + assertEquals("hello", LoopPromptBuilder.truncateForPrompt("hello", 5)); |
| 536 | + } |
| 537 | + |
| 538 | + @Test |
| 539 | + void truncateForPrompt_truncated() { |
| 540 | + String text = "hello world this is a long text for testing truncation in loop prompt builder"; |
| 541 | + String result = LoopPromptBuilder.truncateForPrompt(text, 10); |
| 542 | + assertTrue(result.startsWith("hello"), "should start with first half"); |
| 543 | + assertTrue(result.contains("...(省略)..."), "should contain ellipsis"); |
| 544 | + assertTrue(result.endsWith("lder"), "should end with last half"); |
| 545 | + } |
| 546 | + |
| 547 | + // ===== 全面覆盖:buildBudgetLimitPrompt 耗时行 ===== |
| 548 | + |
| 549 | + @Test |
| 550 | + void budgetLimitPrompt_containsDurationLine() { |
| 551 | + LoopTask task = createGoalTask(10000, 2000, 0); |
| 552 | + String result = builder.buildBudgetLimitPrompt(task, task.getGoalState()); |
| 553 | + assertTrue(result.contains("- 耗时:"), "budget limit prompt should contain duration line"); |
| 554 | + } |
217 | 555 | } |
0 commit comments