diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV3.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV3.java index 96a5fb8a..7af2b7f9 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV3.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/JobRoadmapIntegrationServiceV3.java @@ -44,11 +44,6 @@ public class JobRoadmapIntegrationServiceV3 { private static final double QUALITY_NODE_COUNT_WEIGHT = 0.3; // 품질 점수: 노드 개수 가중치 private static final double QUALITY_STANDARDIZATION_WEIGHT = 0.7; // 품질 점수: 표준화율 가중치 - - // ======================================== - // Public API - // ======================================== - /** * 직업 로드맵 통합 (DB 커넥션 점유 시간 감소를 위해 AI 호출을 트랜잭션 밖에서 수행) * @@ -67,7 +62,7 @@ public JobRoadmap integrateJobRoadmap(Long jobId) { // 3. Task prefetch (트랜잭션 외부, 읽기만 수행) Map taskMap = prefetchTasks(aggregation); - // 4. 트리 구성 및 AI 호출 (트랜잭션 외부, 6-10분 소요) + // 4. 트리 구성 및 AI 호출 (트랜잭션 외부) // 이 시간 동안 DB 커넥션은 사용하지 않음 RoadmapTreeBuilder.TreeBuildResult treeResult = roadmapTreeBuilder.build(aggregation, taskMap); diff --git a/back/src/main/java/com/back/domain/roadmap/roadmap/service/RoadmapTreeBuilder.java b/back/src/main/java/com/back/domain/roadmap/roadmap/service/RoadmapTreeBuilder.java index 67104e28..698a666c 100644 --- a/back/src/main/java/com/back/domain/roadmap/roadmap/service/RoadmapTreeBuilder.java +++ b/back/src/main/java/com/back/domain/roadmap/roadmap/service/RoadmapTreeBuilder.java @@ -149,8 +149,8 @@ private Map buildNodesFromIntegratedTexts( .learningAdvice(integratedTexts != null ? integratedTexts.learningAdvice() : null) .recommendedResources(integratedTexts != null ? integratedTexts.recommendedResources() : null) .learningGoals(integratedTexts != null ? integratedTexts.learningGoals() : null) - .difficulty(avgDifficulty != null ? avgDifficulty.intValue() : null) - .importance(avgImportance != null ? avgImportance.intValue() : null) + .difficulty(avgDifficulty != null ? (int) Math.round(avgDifficulty) : null) + .importance(avgImportance != null ? (int) Math.round(avgImportance) : null) .estimatedHours(avgEstimatedHours) .task(aggNode.task != null ? taskMap.get(aggNode.task.getId()) : null) .roadmapId(0L) // 임시 값 diff --git a/back/src/test/java/com/back/domain/roadmap/roadmap/service/RoadmapAggregatorTest.java b/back/src/test/java/com/back/domain/roadmap/roadmap/service/RoadmapAggregatorTest.java new file mode 100644 index 00000000..67c84ab8 --- /dev/null +++ b/back/src/test/java/com/back/domain/roadmap/roadmap/service/RoadmapAggregatorTest.java @@ -0,0 +1,312 @@ + +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.member.member.entity.Member; +import com.back.domain.member.mentor.entity.Mentor; +import com.back.domain.roadmap.roadmap.entity.MentorRoadmap; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.task.entity.Task; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class RoadmapAggregatorTest { + + private RoadmapAggregator roadmapAggregator; + + // --- Mock Data --- + private Mentor mentor1, mentor2, mentor3; + private Task taskJava, taskSpring, taskJpa, taskDocker; + + @BeforeEach + void setUp() { + roadmapAggregator = new RoadmapAggregator(); + + mentor1 = createMentor(101L); + mentor2 = createMentor(102L); + mentor3 = createMentor(103L); + + taskJava = createTask(1L, "Java"); + taskSpring = createTask(2L, "Spring"); + taskJpa = createTask(3L, "JPA"); + taskDocker = createTask(4L, "Docker"); + } + + @Test + @DisplayName("다양한 멘토 로드맵을 집계하여 모든 필드의 통계를 정확하게 생성한다.") + void aggregate_complexScenario_aggregatesAllFieldsCorrectly() { + // given + List mentorRoadmaps = createComplexMentorRoadmaps(); + + // when + RoadmapAggregator.AggregationResult result = roadmapAggregator.aggregate(mentorRoadmaps); + + // then + assertThat(result.getTotalMentorCount()).isEqualTo(3); + + verifyAggregatedNodes(result.getAgg()); + verifyRootCandidates(result.getRootCount()); + verifyTransitions(result.getTransitions()); + verifyMentorAppearance(result.getMentorAppearSet()); + verifyNodePositions(result.getPositions()); + verifyDescriptionCollections(result.getDescriptions()); + } + + @Test + @DisplayName("멘토 로드맵이 비어 있을 경우 빈 결과를 반환한다.") + void aggregate_emptyList_returnsEmptyResult() { + // when + RoadmapAggregator.AggregationResult result = roadmapAggregator.aggregate(List.of()); + + // then + assertThat(result.getTotalMentorCount()).isEqualTo(0); + assertThat(result.getAgg()).isEmpty(); + assertThat(result.getRootCount()).isEmpty(); + assertThat(result.getTransitions()).isEmpty(); + assertThat(result.getMentorAppearSet()).isEmpty(); + } + + @Test + @DisplayName("노드가 비어 있는 멘토 로드맵은 통계에 반영되지 않는다.") + void aggregate_emptyNodesInRoadmap_areIgnored() { + // given + MentorRoadmap emptyRoadmap = createMentorRoadmap(1L, mentor1, "빈 로드맵"); + // 노드를 추가하지 않음 + + // when + RoadmapAggregator.AggregationResult result = roadmapAggregator.aggregate(List.of(emptyRoadmap)); + + // then + assertThat(result.getTotalMentorCount()).isEqualTo(1); + assertThat(result.getAgg()).isEmpty(); + assertThat(result.getRootCount()).isEmpty(); + assertThat(result.getTransitions()).isEmpty(); + } + + @Test + @DisplayName("Task와 TaskName이 모두 없는 노드를 처리할 수 있다.") + void aggregate_nodeWithNoTaskOrName_generatesUnknownKey() { + // given + MentorRoadmap roadmap = createMentorRoadmap(1L, mentor1, "멘토1 로드맵"); + RoadmapNode unknownNode = RoadmapNode.builder() + .roadmapId(roadmap.getId()) + .roadmapType(RoadmapNode.RoadmapType.MENTOR) + .stepOrder(1) + .task(null) + .taskName(null) + .build(); + roadmap.addNodes(List.of(unknownNode)); + + // when + RoadmapAggregator.AggregationResult result = roadmapAggregator.aggregate(List.of(roadmap)); + + // then + assertThat(result.getAgg()).containsKey("N:__unknown__"); + assertThat(result.getAgg().get("N:__unknown__").count).isEqualTo(1); + assertThat(result.getRootCount()).containsEntry("N:__unknown__", 1); + } + + @Test + @DisplayName("하나의 로드맵에서 동일 Task가 여러 번 등장할 경우 누적되어야 한다.") + void aggregate_sameTaskMultipleTimes_inSingleRoadmap_countsAccumulated() { + // given + MentorRoadmap roadmap = createMentorRoadmap(1L, mentor1, "중복 Task 로드맵"); + RoadmapNode node1 = createStandardNode(roadmap.getId(), 1, taskJava, null, null, null, null, null, null); + RoadmapNode node2 = createStandardNode(roadmap.getId(), 2, taskJava, null, null, null, null, null, null); + roadmap.addNodes(List.of(node1, node2)); + + // when + RoadmapAggregator.AggregationResult result = roadmapAggregator.aggregate(List.of(roadmap)); + + // then + assertThat(result.getAgg()).containsKey("T:1"); + assertThat(result.getAgg().get("T:1").count).isEqualTo(2); // count 누적 + assertThat(result.getPositions().get("T:1")).containsExactlyInAnyOrder(1, 2); + assertThat(result.getMentorAppearSet().get("T:1")).containsExactly(mentor1.getId()); + } + + @Test + @DisplayName("여러 멘토가 동일한 전이(Transition)를 가질 경우 카운트가 누적된다.") + void aggregate_duplicateTransitions_areCounted() { + // given: 두 멘토가 모두 Java -> Spring 로드맵을 가짐 + MentorRoadmap roadmap1 = createMentorRoadmap(1L, mentor1, "로드맵1"); + roadmap1.addNodes(List.of( + createStandardNode(roadmap1.getId(), 1, taskJava, null, null, null, null, null, null), + createStandardNode(roadmap1.getId(), 2, taskSpring, null, null, null, null, null, null) + )); + + MentorRoadmap roadmap2 = createMentorRoadmap(2L, mentor2, "로드맵2"); + roadmap2.addNodes(List.of( + createStandardNode(roadmap2.getId(), 1, taskJava, null, null, null, null, null, null), + createStandardNode(roadmap2.getId(), 2, taskSpring, null, null, null, null, null, null) + )); + + // when + RoadmapAggregator.AggregationResult result = roadmapAggregator.aggregate(List.of(roadmap1, roadmap2)); + + // then + assertThat(result.getTransitions().get("T:1")).containsEntry("T:2", 2); + assertThat(result.getAgg().get("T:1").count).isEqualTo(2); + assertThat(result.getAgg().get("T:2").count).isEqualTo(2); + } + + private void verifyAggregatedNodes(Map agg) { + assertThat(agg).hasSize(5); + assertThat(agg.get("T:1").count).isEqualTo(3); + assertThat(agg.get("T:1").displayName).isEqualTo("Java"); + assertThat(agg.get("T:2").count).isEqualTo(2); + assertThat(agg.get("T:2").displayName).isEqualTo("Spring"); + assertThat(agg.get("T:3").count).isEqualTo(1); + assertThat(agg.get("T:3").displayName).isEqualTo("JPA"); + assertThat(agg.get("T:4").count).isEqualTo(1); + assertThat(agg.get("T:4").displayName).isEqualTo("Docker"); + assertThat(agg.get("N:custom db task").count).isEqualTo(1); + assertThat(agg.get("N:custom db task").displayName).isEqualTo("Custom DB Task"); + } + + private void verifyRootCandidates(Map rootCount) { + assertThat(rootCount).hasSize(2); + assertThat(rootCount.get("T:1")).isEqualTo(2); + assertThat(rootCount.get("T:4")).isEqualTo(1); + } + + private void verifyTransitions(Map> transitions) { + assertThat(transitions.get("T:1")).containsEntry("T:2", 1).containsEntry("T:3", 1); + assertThat(transitions.get("T:2")).containsEntry("N:custom db task", 1); + assertThat(transitions.get("T:3")).containsEntry("T:2", 1); + assertThat(transitions.get("T:4")).containsEntry("T:1", 1); + } + + private void verifyMentorAppearance(Map> mentorAppearSet) { + assertThat(mentorAppearSet.get("T:1")).containsExactlyInAnyOrder(101L, 102L, 103L); + assertThat(mentorAppearSet.get("T:2")).containsExactlyInAnyOrder(101L, 102L); + assertThat(mentorAppearSet.get("T:3")).containsExactlyInAnyOrder(102L); + assertThat(mentorAppearSet.get("T:4")).containsExactlyInAnyOrder(103L); + assertThat(mentorAppearSet.get("N:custom db task")).containsExactlyInAnyOrder(101L); + } + + private void verifyNodePositions(Map> positions) { + assertThat(positions.get("T:1")).containsExactlyInAnyOrder(1, 1, 2); + assertThat(positions.get("T:2")).containsExactlyInAnyOrder(2, 3); + assertThat(positions.get("T:3")).containsExactlyInAnyOrder(2); + assertThat(positions.get("T:4")).containsExactlyInAnyOrder(1); + assertThat(positions.get("N:custom db task")).containsExactlyInAnyOrder(3); + } + + private void verifyDescriptionCollections(RoadmapAggregator.DescriptionCollections descriptions) { + assertThat(descriptions.getLearningAdvices().get("T:1")) + .containsExactlyInAnyOrder("Java Advice from Mentor1", "Java Advice from Mentor2"); + assertThat(descriptions.getRecommendedResources().get("T:2")) + .containsExactly("Spring Resource from Mentor1"); + assertThat(descriptions.getLearningGoals().get("T:1")) + .containsExactly("Java Goal from Mentor2"); + assertThat(descriptions.getDifficulties().get("T:1")) + .containsExactlyInAnyOrder(2, 3); + assertThat(descriptions.getImportances().get("T:2")) + .containsExactlyInAnyOrder(5, 4); + assertThat(descriptions.getEstimatedHours().get("T:1")) + .containsExactly(40); + } + + // --- Helper Methods to build mock data (수정된 헬퍼) --- + + private List createComplexMentorRoadmaps() { + // Mentor 1: Java -> Spring -> Custom DB Task + MentorRoadmap roadmap1 = createMentorRoadmap(1L, mentor1, "멘토1 로드맵"); + RoadmapNode node1_1 = createStandardNode(roadmap1.getId(), 1, taskJava, "Java Advice from Mentor1", null, null, 2, 5, 40); + RoadmapNode node1_2 = createStandardNode(roadmap1.getId(), 2, taskSpring, null, "Spring Resource from Mentor1", null, 4, 5, 80); + RoadmapNode node1_3 = createCustomNode(roadmap1.getId(), 3, "Custom DB Task", null, null, null, 3, 3, 20); + roadmap1.addNodes(Arrays.asList(node1_1, node1_2, node1_3)); + + // Mentor 2: Java -> JPA -> Spring + MentorRoadmap roadmap2 = createMentorRoadmap(2L, mentor2, "멘토2 로드맵"); + RoadmapNode node2_1 = createStandardNode(roadmap2.getId(), 1, taskJava, "Java Advice from Mentor2", null, "Java Goal from Mentor2", 3, 4, null); + RoadmapNode node2_2 = createStandardNode(roadmap2.getId(), 2, taskJpa, null, null, "JPA Goal", 3, 5, 60); + RoadmapNode node2_3 = createStandardNode(roadmap2.getId(), 3, taskSpring, null, null, null, 5, 4, 100); + roadmap2.addNodes(Arrays.asList(node2_1, node2_2, node2_3)); + + // Mentor 3: Docker -> Java + MentorRoadmap roadmap3 = createMentorRoadmap(3L, mentor3, "멘토3 로드맵"); + RoadmapNode node3_1 = createStandardNode(roadmap3.getId(), 1, taskDocker, null, null, null, 2, 3, 20); + RoadmapNode node3_2 = createStandardNode(roadmap3.getId(), 2, taskJava, null, null, null, null, null, null); + roadmap3.addNodes(Arrays.asList(node3_1, node3_2)); + + return Arrays.asList(roadmap1, roadmap2, roadmap3); + } + + private Mentor createMentor(Long id) { + Member member = new Member(id, "mentor" + id + "@test.com", "테스트멘토" + id, "테스트멘토" + id, Member.Role.MENTOR); + Mentor mentor = Mentor.builder().member(member).job(null).build(); + try { + Field idField = mentor.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(mentor, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + return mentor; + } + + private Task createTask(Long id, String name) { + Task task = new Task(name); + try { + Field idField = task.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(task, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + return task; + } + + private MentorRoadmap createMentorRoadmap(Long id, Mentor mentor, String title) { + MentorRoadmap roadmap = new MentorRoadmap(mentor, title, "테스트 설명"); + try { + Field idField = roadmap.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(roadmap, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + return roadmap; + } + + private RoadmapNode createStandardNode(Long roadmapId, int order, Task task, String advice, String resource, String goal, Integer difficulty, Integer importance, Integer estimatedHours) { + return RoadmapNode.builder() + .roadmapId(roadmapId) // ★★★★★ FIX: 소속될 로드맵 ID 설정 + .roadmapType(RoadmapNode.RoadmapType.MENTOR) // ★★★★★ FIX: 타입 명시 + .stepOrder(order) + .task(task) + .taskName(task.getName()) + .learningAdvice(advice) + .recommendedResources(resource) + .learningGoals(goal) + .difficulty(difficulty) + .importance(importance) + .estimatedHours(estimatedHours) + .build(); + } + + private RoadmapNode createCustomNode(Long roadmapId, int order, String taskName, String advice, String resource, String goal, Integer difficulty, Integer importance, Integer estimatedHours) { + return RoadmapNode.builder() + .roadmapId(roadmapId) + .roadmapType(RoadmapNode.RoadmapType.MENTOR) + .stepOrder(order) + .task(null) + .taskName(taskName) + .learningAdvice(advice) + .recommendedResources(resource) + .learningGoals(goal) + .difficulty(difficulty) + .importance(importance) + .estimatedHours(estimatedHours) + .build(); + } +} diff --git a/back/src/test/java/com/back/domain/roadmap/roadmap/service/RoadmapTreeBuilderTest.java b/back/src/test/java/com/back/domain/roadmap/roadmap/service/RoadmapTreeBuilderTest.java new file mode 100644 index 00000000..62ef7b1a --- /dev/null +++ b/back/src/test/java/com/back/domain/roadmap/roadmap/service/RoadmapTreeBuilderTest.java @@ -0,0 +1,235 @@ +package com.back.domain.roadmap.roadmap.service; + +import com.back.domain.roadmap.roadmap.dto.response.TextFieldIntegrationResponse; +import com.back.domain.roadmap.roadmap.entity.RoadmapNode; +import com.back.domain.roadmap.task.entity.Task; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RoadmapTreeBuilderTest { + + @Mock + private TextFieldIntegrationService textFieldIntegrationService; + + @InjectMocks + private RoadmapTreeBuilder roadmapTreeBuilder; + + private Map taskMap; + private Task taskJava, taskSpring, taskJpa, taskDocker, taskMysql; + + @BeforeEach + void setUp() { + taskJava = createTask(1L, "Java"); + taskSpring = createTask(2L, "Spring"); + taskJpa = createTask(3L, "JPA"); + taskDocker = createTask(4L, "Docker"); + taskMysql = createTask(5L, "MySQL"); + + taskMap = Map.of( + 1L, taskJava, + 2L, taskSpring, + 3L, taskJpa, + 4L, taskDocker, + 5L, taskMysql + ); + } + + @Test + @DisplayName("복잡한 통계 데이터를 기반으로 올바른 로드맵 트리를 생성한다.") + void build_complexScenario_constructsCorrectTree() { + // given + RoadmapAggregator.AggregationResult aggregation = createComplexAggregationResult(); + mockTextFieldIntegration(); + + // when + RoadmapTreeBuilder.TreeBuildResult result = roadmapTreeBuilder.build(aggregation, taskMap); + + // then + // 1. 루트 노드 검증 + assertThat(result.getRootKey()).isEqualTo("T:1"); // Java가 루트여야 함 + RoadmapNode root = result.getKeyToNode().get("T:1"); + assertThat(root).isNotNull(); + assertThat(root.getTaskName()).isEqualTo("Java"); + assertThat(root.getLevel()).isEqualTo(0); + assertThat(root.getStepOrder()).isEqualTo(1); + assertThat(root.getDifficulty()).isEqualTo(3); // (2+3)/2 = 2.5 -> 3 + assertThat(root.getImportance()).isEqualTo(5); // (5+4)/2 = 4.5 -> 5 + + // 2. 생성된 노드 속성 검증 (AI 통합 필드) + assertThat(root.getLearningAdvice()).isEqualTo("Integrated Java Advice"); + assertThat(root.getRecommendedResources()).isEqualTo("Integrated Java Resources"); + + // 3. 트리 구조 검증 (BFS 결과) + // Java(T:1)의 자식은 Spring(T:2)과 JPA(T:3)여야 함 + assertThat(root.getChildren()).hasSize(2); + List childrenNames = root.getChildren().stream() + .map(RoadmapNode::getTaskName) + .collect(Collectors.toList()); + assertThat(childrenNames).containsExactlyInAnyOrder("Spring", "JPA"); + + RoadmapNode springNode = findChildByName(root, "Spring"); + RoadmapNode jpaNode = findChildByName(root, "JPA"); + + assertThat(springNode).isNotNull(); + assertThat(springNode.getLevel()).isEqualTo(1); + assertThat(springNode.getParent()).isEqualTo(root); + + assertThat(jpaNode).isNotNull(); + assertThat(jpaNode.getLevel()).isEqualTo(1); + assertThat(jpaNode.getParent()).isEqualTo(root); + + // Spring(T:2)의 자식은 Docker(T:4)여야 함 + assertThat(springNode.getChildren()).hasSize(1); + RoadmapNode dockerNode = springNode.getChildren().get(0); + assertThat(dockerNode.getTaskName()).isEqualTo("Docker"); + assertThat(dockerNode.getLevel()).isEqualTo(2); + assertThat(dockerNode.getParent()).isEqualTo(springNode); + + // 4. 순환 및 최적 부모 로직 검증 + // JPA(T:3)는 Spring(T:2)으로 가는 전이가 있지만, Spring의 최적 부모는 Java(T:1)이므로 + // JPA는 Spring을 자식으로 가지면 안됨. + assertThat(jpaNode.getChildren()).isEmpty(); + + // 5. 방문하지 않은 노드 검증 (MySQL은 어디에도 연결되지 않음) + assertThat(result.getVisited()).doesNotContain("T:5"); + } + + @Test + @DisplayName("rootCount가 비어있을 경우 전체 노드 등장 빈도를 기반으로 루트를 선택한다.") + void build_whenRootCountIsEmpty_selectsRootFromOverallCount() { + // given + RoadmapAggregator.AggregationResult aggregation = createComplexAggregationResult(); + aggregation.rootCount.clear(); // 루트 카운트 강제 클리어 + mockTextFieldIntegration(); + + // when + RoadmapTreeBuilder.TreeBuildResult result = roadmapTreeBuilder.build(aggregation, taskMap); + + // then + // agg.count가 가장 높은 Java(4)가 루트가 되어야 함 + assertThat(result.getRootKey()).isEqualTo("T:1"); + } + + @Test + @DisplayName("TextFieldIntegrationService 호출 실패 시 빈 텍스트로 트리를 생성한다.") + void build_whenTextFieldIntegrationFails_proceedsWithEmptyTexts() { + // given + RoadmapAggregator.AggregationResult aggregation = createComplexAggregationResult(); + // AI 서비스 Mock이 예외를 던지도록 설정 + when(textFieldIntegrationService.integrateBatch(any())) + .thenThrow(new RuntimeException("AI service is down")); + + // when + RoadmapTreeBuilder.TreeBuildResult result = roadmapTreeBuilder.build(aggregation, taskMap); + + // then + // 예외가 발생해도 빌드는 중단되지 않아야 함 + assertThat(result).isNotNull(); + RoadmapNode root = result.getKeyToNode().get("T:1"); + assertThat(root).isNotNull(); + + // 텍스트 필드는 모두 null이어야 함 + assertThat(root.getLearningAdvice()).isNull(); + assertThat(root.getRecommendedResources()).isNull(); + assertThat(root.getLearningGoals()).isNull(); + } + + + // --- Helper Methods --- + + private RoadmapAggregator.AggregationResult createComplexAggregationResult() { + RoadmapAggregator.AggregationResult result = new RoadmapAggregator.AggregationResult(4); + + // 1. agg (노드 등장 횟수) + result.agg.put("T:1", new RoadmapAggregator.AggregatedNode(taskJava, "Java")); + result.agg.get("T:1").count = 4; + result.agg.put("T:2", new RoadmapAggregator.AggregatedNode(taskSpring, "Spring")); + result.agg.get("T:2").count = 3; + result.agg.put("T:3", new RoadmapAggregator.AggregatedNode(taskJpa, "JPA")); + result.agg.get("T:3").count = 2; + result.agg.put("T:4", new RoadmapAggregator.AggregatedNode(taskDocker, "Docker")); + result.agg.get("T:4").count = 1; + result.agg.put("T:5", new RoadmapAggregator.AggregatedNode(taskMysql, "MySQL")); + result.agg.get("T:5").count = 1; + + + // 2. rootCount (루트 노드 빈도) + result.rootCount.put("T:1", 3); // Java가 압도적인 루트 + result.rootCount.put("T:4", 1); + + // 3. transitions (전이) + // Java -> Spring (3번) + // Java -> JPA (2번) + // Spring -> Docker (1번) + // JPA -> Spring (1번) - 순환 구조 및 부모 경쟁 유발 + result.transitions.put("T:1", new HashMap<>(Map.of("T:2", 3, "T:3", 2))); + result.transitions.put("T:2", new HashMap<>(Map.of("T:4", 1))); + result.transitions.put("T:3", new HashMap<>(Map.of("T:2", 1))); + + + // 4. positions (평균 위치) + result.positions.put("T:1", List.of(1, 1, 1, 2)); // avg ~1.25 + result.positions.put("T:2", List.of(2, 2, 3)); // avg ~2.33 + result.positions.put("T:3", List.of(2, 3)); // avg ~2.5 + result.positions.put("T:4", List.of(3)); // avg 3.0 + + // 5. mentorAppearSet (멘토 커버리지) + result.mentorAppearSet.put("T:1", Set.of(101L, 102L, 103L, 104L)); // 4/4 + result.mentorAppearSet.put("T:2", Set.of(101L, 102L, 103L)); // 3/4 + result.mentorAppearSet.put("T:3", Set.of(101L, 102L)); // 2/4 + result.mentorAppearSet.put("T:4", Set.of(101L)); // 1/4 + + // 6. descriptions (상세 정보) + result.descriptions.learningAdvices.put("T:1", List.of("Java advice 1", "Java advice 2")); + result.descriptions.recommendedResources.put("T:1", List.of("Java resource 1")); + result.descriptions.difficulties.put("T:1", List.of(2, 3)); // avg 2.5 + result.descriptions.importances.put("T:1", List.of(5, 4)); // avg 4.5 + result.descriptions.estimatedHours.put("T:2", List.of(40, 60, 80)); // avg 60 + + return result; + } + + private void mockTextFieldIntegration() { + TextFieldIntegrationResponse javaResponse = new TextFieldIntegrationResponse( + "Integrated Java Advice", + "Integrated Java Resources", + "Integrated Java Goals" + ); + // AI 서비스 Mocking + when(textFieldIntegrationService.integrateBatch(any())) + .thenReturn(Map.of("T:1", javaResponse)); + } + + private Task createTask(Long id, String name) { + Task task = new Task(name); + try { + Field idField = task.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(task, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + return task; + } + + private RoadmapNode findChildByName(RoadmapNode parent, String name) { + return parent.getChildren().stream() + .filter(node -> name.equals(node.getTaskName())) + .findFirst() + .orElse(null); + } +}