diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index 6ac307c5..2b71f1d0 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -549,12 +549,7 @@ public class GoalServiceImpl implements GoalService { if (!current.isEmpty() && !GoalEvaluationResult.DECISION_FALLBACK.equals(result.decision())) { // The model may have seen fewer criteria. Derive the public // projection from the same fresh checklist this CAS writes. - List remaining = GoalCriteriaCodec.remaining(current); - double score = (double) (current.size() - remaining.size()) / current.size(); - String gap = remaining.isEmpty() ? "" : "Still missing: " + remaining.stream() - .map(GoalCriterion::text).collect(java.util.stream.Collectors.joining("; ")); - w.set(GoalEntity::getCompletionScore, score) - .set(GoalEntity::getProgressSummary, gap); + setChecklistProgress(w, current); } else { w.set(GoalEntity::getCompletionScore, result.score()) .set(GoalEntity::getProgressSummary, result.gap()); @@ -675,6 +670,7 @@ public class GoalServiceImpl implements GoalService { LambdaUpdateWrapper w = baseLockedUpdate(fresh) .set(GoalEntity::getCriteria, criteriaJson) .set(GoalEntity::getExitCriteria, mergedText); + setChecklistProgress(w, list); bumpVersionAndTime(w); return w; }); @@ -688,6 +684,16 @@ public class GoalServiceImpl implements GoalService { return g; } + /** Both evaluation and user edits project the checklist written by this CAS. */ + private static void setChecklistProgress(LambdaUpdateWrapper update, List criteria) { + List remaining = GoalCriteriaCodec.remaining(criteria); + double score = criteria.isEmpty() ? 0.0 : (double) (criteria.size() - remaining.size()) / criteria.size(); + String gap = remaining.isEmpty() ? "" : "Still missing: " + remaining.stream() + .map(GoalCriterion::text).collect(java.util.stream.Collectors.joining("; ")); + update.set(GoalEntity::getCompletionScore, score) + .set(GoalEntity::getProgressSummary, gap); + } + // ==================== Internals ==================== /** diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java index 0a12cec7..cd847eb9 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java @@ -238,6 +238,24 @@ class GoalPersistenceIntegrationTest { return goalService.getById(goal.getId()); } + @Test + void appendedRequirementImmediatelyRefreshesProgressWithoutLosingPriorEvidence() { + GoalEntity goal = readyForCompletion("append-progress", "report"); + assertEquals(1.0, goal.getCompletionScore()); + GoalEntity appended = goalService.appendCriterion(goal.getId(), "appendix", "alice"); + assertEquals(0.5, appended.getCompletionScore()); + assertEquals("Still missing: appendix", appended.getProgressSummary()); + var criteria = vip.mate.goal.model.GoalCriteriaCodec.parse(appended.getCriteria(), new com.fasterxml.jackson.databind.ObjectMapper()); + assertEquals(true, criteria.getFirst().passed()); + assertEquals("report evidence", criteria.getFirst().evidence()); + assertEquals(false, criteria.getLast().passed()); + assertEquals(goal.getEvaluationRevision(), appended.getEvaluationRevision()); + GoalEntity again = goalService.appendCriterion(goal.getId(), "sources", "alice"); + assertEquals(1.0 / 3, again.getCompletionScore(), 0.00001); + assertEquals("Still missing: appendix; sources", again.getProgressSummary()); + assertEquals(GoalStatus.ACTIVE, goalService.getById(goal.getId()).getStatus()); + } + @Test void rolledBackCompletionDoesNotSyncMemory() { GoalEntity goal = readyForCompletion("completion-memory-rollback", "report"); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java index b23b6d01..ca61a509 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -627,6 +627,28 @@ class GoalServiceTest { assertTrue(evCaptor.getValue().getDetailJson().contains("tests pass")); } + @Test + void appendProgressUsesFreshChecklistAfterCasConflict() { + GoalEntity original = persisted(1L, GoalStatus.ACTIVE); + original.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":true,\"evidence\":\"report written\"}]"); + GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE); + fresh.setVersion(1); + fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":true,\"evidence\":\"report written\"}," + + "{\"id\":\"C2\",\"text\":\"sources\",\"passed\":false,\"evidence\":\"\"}]"); + when(goalMapper.selectById(1L)).thenReturn(original, fresh, fresh); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1); + service.appendCriterion(1L, "appendix", "alice"); + ArgumentCaptor writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper, times(2)).update(any(), writes.capture()); + var first = writes.getAllValues().getFirst(); + var retry = writes.getAllValues().getLast(); + assertTrue(setsProperty(first, "completionScore")); + assertTrue(setsProperty(retry, "completionScore")); + assertTrue(first.getParamNameValuePairs().containsValue(0.5)); + assertTrue(retry.getParamNameValuePairs().containsValue(1.0 / 3)); + assertTrue(retry.getParamNameValuePairs().containsValue("Still missing: sources; appendix")); + } + @Test void appendCriterion_rejectsBlankInput() { // Validation happens before selectById, so we do NOT stub the mapper.