diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java index 5d6b3d27..ec212760 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java @@ -42,7 +42,7 @@ public enum GoalStatus { * the DB stores via {@link EnumValue}. Without this, Jackson defaults to * {@link #name()} (uppercase) and the frontend's * {@code status: 'active' | 'paused' | ...} TS literal types reject - * every payload — UI bug observed during PR4 manual QA. + * every payload. */ @JsonValue public String getValue() { diff --git a/mateclaw-server/src/test/java/vip/mate/goal/model/GoalCriteriaCodecTest.java b/mateclaw-server/src/test/java/vip/mate/goal/model/GoalCriteriaCodecTest.java new file mode 100644 index 00000000..48f82af2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/model/GoalCriteriaCodecTest.java @@ -0,0 +1,113 @@ +package vip.mate.goal.model; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure unit tests for the checklist (de)serialization + merge helpers. + */ +class GoalCriteriaCodecTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private static GoalCriterion c(String id, String text, boolean passed) { + return new GoalCriterion(id, text, passed, passed ? "ok" : ""); + } + + // ---------- parse ---------- + + @Test + void parse_nullOrBlankOrCorrupt_returnsEmptyMutableList() { + assertTrue(GoalCriteriaCodec.parse(null, mapper).isEmpty()); + assertTrue(GoalCriteriaCodec.parse("", mapper).isEmpty()); + assertTrue(GoalCriteriaCodec.parse(" ", mapper).isEmpty()); + assertTrue(GoalCriteriaCodec.parse("{not valid json", mapper).isEmpty()); + // mutable: callers append during bootstrap/append paths + GoalCriteriaCodec.parse(null, mapper).add(c("C1", "x", false)); + } + + @Test + void parse_roundTrip() { + String json = GoalCriteriaCodec.serialize(List.of(c("C1", "tests pass", true)), mapper); + List back = GoalCriteriaCodec.parse(json, mapper); + assertEquals(1, back.size()); + assertEquals("C1", back.get(0).id()); + assertEquals("tests pass", back.get(0).text()); + assertTrue(back.get(0).passed()); + } + + @Test + void serialize_null_returnsNull() { + assertNull(GoalCriteriaCodec.serialize(null, mapper)); + } + + // ---------- merge ---------- + + @Test + void merge_appliesVerdictById_preservesTextAndUntouched() { + List existing = List.of( + c("C1", "first", false), + c("C2", "second", false)); + List delta = List.of( + new GoalChecklistVerdict.CriterionVerdict("C1", true, "did it")); + + List merged = GoalCriteriaCodec.merge(existing, delta); + + assertEquals(2, merged.size()); + assertTrue(merged.get(0).passed()); + assertEquals("did it", merged.get(0).evidence()); + assertEquals("first", merged.get(0).text()); // text preserved + assertFalse(merged.get(1).passed()); // untouched stays + assertEquals("second", merged.get(1).text()); + } + + @Test + void merge_unknownVerdictId_isIgnored() { + List existing = List.of(c("C1", "first", false)); + List delta = List.of( + new GoalChecklistVerdict.CriterionVerdict("C9", true, "nope")); + List merged = GoalCriteriaCodec.merge(existing, delta); + assertFalse(merged.get(0).passed()); + } + + // ---------- allPassed / remaining ---------- + + @Test + void allPassed_emptyIsFalse() { + assertFalse(GoalCriteriaCodec.allPassed(List.of())); + } + + @Test + void allPassed_trueOnlyWhenEveryPassed() { + assertTrue(GoalCriteriaCodec.allPassed(List.of(c("C1", "a", true), c("C2", "b", true)))); + assertFalse(GoalCriteriaCodec.allPassed(List.of(c("C1", "a", true), c("C2", "b", false)))); + } + + @Test + void remaining_returnsOnlyUnpassed() { + List rem = GoalCriteriaCodec.remaining( + List.of(c("C1", "a", true), c("C2", "b", false), c("C3", "c", false))); + assertEquals(2, rem.size()); + assertEquals("C2", rem.get(0).id()); + assertEquals("C3", rem.get(1).id()); + } + + // ---------- reindex ---------- + + @Test + void reindex_assignsSequentialIds() { + List out = GoalCriteriaCodec.reindex(List.of( + c("", "a", false), c("zzz", "b", false), c("C99", "c", false))); + assertEquals("C1", out.get(0).id()); + assertEquals("C2", out.get(1).id()); + assertEquals("C3", out.get(2).id()); + assertEquals("a", out.get(0).text()); + } +} 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 76d2b675..ee39d3cd 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 @@ -314,6 +314,70 @@ class GoalServiceTest { verify(goalMapper, never()).selectById(any()); } + // ==================== criteria checklist ==================== + + @Test + void create_normalizesInitialCriteria_assignsIdsForcesUnpassed() { + when(goalMapper.selectOne(any())).thenReturn(null); + when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1); + + GoalCreateRequest r = validReq(); + r.setCriteria(java.util.List.of( + new vip.mate.goal.model.GoalCriterion("ignored", "tests pass", true, "x"), + new vip.mate.goal.model.GoalCriterion("", " ", false, ""), // blank dropped + new vip.mate.goal.model.GoalCriterion("", "deployed", false, ""))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GoalEntity.class); + service.create(r, "alice"); + verify(goalMapper).insert(captor.capture()); + + java.util.List parsed = + vip.mate.goal.model.GoalCriteriaCodec.parse(captor.getValue().getCriteria(), new ObjectMapper()); + assertEquals(2, parsed.size()); + assertEquals("C1", parsed.get(0).id()); + assertEquals("tests pass", parsed.get(0).text()); + assertFalse(parsed.get(0).passed()); // forced false even though caller said true + assertEquals("C2", parsed.get(1).id()); + assertEquals("deployed", parsed.get(1).text()); + } + + @Test + void create_emptyCriteria_leavesColumnNull_forBootstrap() { + when(goalMapper.selectOne(any())).thenReturn(null); + when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1); + ArgumentCaptor captor = ArgumentCaptor.forClass(GoalEntity.class); + service.create(validReq(), "alice"); + verify(goalMapper).insert(captor.capture()); + assertNull(captor.getValue().getCriteria()); + } + + @Test + void create_autoFollowup_threeState() { + when(goalMapper.selectOne(any())).thenReturn(null); + when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1); + + // null -> config default (true by default) + assertTrue(service.create(validReq(), "alice").getAutoFollowupEnabled()); + + // explicit false is honored + GoalCreateRequest off = validReq(); + off.setAutoFollowupEnabled(false); + assertFalse(service.create(off, "alice").getAutoFollowupEnabled()); + } + + @Test + void toResponse_parsesCriteriaArray_nullBecomesEmpty() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + g.setCriteria("[{\"id\":\"C1\",\"text\":\"a\",\"passed\":true,\"evidence\":\"ok\"}]"); + var resp = service.toResponse(g); + assertEquals(1, resp.getCriteria().size()); + assertTrue(resp.getCriteria().get(0).passed()); + + GoalEntity bare = persisted(2L, GoalStatus.ACTIVE); // criteria == null + assertNotNull(service.toResponse(bare).getCriteria()); + assertTrue(service.toResponse(bare).getCriteria().isEmpty()); + } + // ==================== optimistic lock retry ==================== @Test