mateclaw/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java

180 lines
7.2 KiB
Java

package vip.mate.team.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.agent.AgentService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.model.TeamTaskStatus;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.*;
/**
* Pins the dispatch loop's arbitration rules: one task per member per sweep,
* busy members skipped, the conditional assign as the only winner gate, the
* circuit breaker short-circuit, and outcome settling (auto-complete vs
* respecting a state the member already set).
*/
class TeamDispatchServiceTest {
private static final Long TEAM_ID = 10L;
private static final Long MEMBER_A = 2L;
private static final Long MEMBER_B = 3L;
private TeamService teamService;
private TeamTaskService taskService;
private AgentService agentService;
private ConversationService conversationService;
private ChatStreamTracker streamTracker;
private TeamAnnounceService announceService;
private TeamDispatchService service;
@BeforeEach
void setUp() {
teamService = mock(TeamService.class);
taskService = mock(TeamTaskService.class);
agentService = mock(AgentService.class);
conversationService = mock(ConversationService.class);
streamTracker = mock(ChatStreamTracker.class);
announceService = mock(TeamAnnounceService.class);
service = new TeamDispatchService(teamService, taskService, agentService,
conversationService, streamTracker, announceService);
}
private TeamTaskEntity task(Long id, Long assignee) {
TeamTaskEntity t = new TeamTaskEntity();
t.setId(id);
t.setTeamId(TEAM_ID);
t.setTaskNumber(id.intValue());
t.setSubject("task " + id);
t.setStatus(TeamTaskStatus.PENDING);
t.setAssigneeAgentId(assignee);
t.setLeadConversationId("lead-conv");
return t;
}
// ==================== sweep arbitration ====================
@Test
@DisplayName("one task per assignee per sweep — the second task for the same member waits")
void onePerAssigneePerSweep() {
TeamTaskEntity first = task(1L, MEMBER_A);
TeamTaskEntity second = task(2L, MEMBER_A);
TeamTaskEntity other = task(3L, MEMBER_B);
// Second stub is empty so the async post-run re-sweep chain terminates.
when(taskService.findDispatchable(TEAM_ID))
.thenReturn(List.of(first, second, other))
.thenReturn(List.of());
when(taskService.hasActiveTask(eq(TEAM_ID), any())).thenReturn(false);
when(taskService.assignTask(any(), any())).thenReturn(true);
when(taskService.tryAcquireDispatch(any())).thenReturn(true);
when(taskService.getTask(any())).thenAnswer(inv ->
task(inv.getArgument(0), MEMBER_A));
service.sweep(TEAM_ID);
verify(taskService).assignTask(1L, MEMBER_A);
verify(taskService, never()).assignTask(eq(2L), any());
verify(taskService).assignTask(3L, MEMBER_B);
}
@Test
@DisplayName("a member already executing a task is skipped entirely")
void busyMemberSkipped() {
when(taskService.findDispatchable(TEAM_ID)).thenReturn(List.of(task(1L, MEMBER_A)));
when(taskService.hasActiveTask(TEAM_ID, MEMBER_A)).thenReturn(true);
service.sweep(TEAM_ID);
verify(taskService, never()).assignTask(any(), any());
}
@Test
@DisplayName("losing the conditional assign means another sweep won — no dispatch")
void assignRaceLostSkips() {
when(taskService.findDispatchable(TEAM_ID)).thenReturn(List.of(task(1L, MEMBER_A)));
when(taskService.hasActiveTask(TEAM_ID, MEMBER_A)).thenReturn(false);
when(taskService.assignTask(1L, MEMBER_A)).thenReturn(false);
service.sweep(TEAM_ID);
verify(taskService, never()).tryAcquireDispatch(any());
}
@Test
@DisplayName("a tripped circuit breaker skips the run but announces the auto-fail to the lead")
void breakerStopsDispatch() {
when(taskService.findDispatchable(TEAM_ID)).thenReturn(List.of(task(1L, MEMBER_A)));
when(taskService.hasActiveTask(TEAM_ID, MEMBER_A)).thenReturn(false);
when(taskService.assignTask(1L, MEMBER_A)).thenReturn(true);
when(taskService.tryAcquireDispatch(1L)).thenReturn(false);
TeamTaskEntity failed = task(1L, MEMBER_A);
failed.setStatus(TeamTaskStatus.FAILED);
when(taskService.getTask(1L)).thenReturn(failed);
service.sweep(TEAM_ID);
// The work must not vanish silently: the lead hears about the auto-fail.
verify(announceService).announceTaskSettled(failed);
verifyNoInteractions(agentService);
}
// ==================== outcome settling ====================
@Test
@DisplayName("an in_progress task is auto-completed with the member's final reply")
void settleAutoCompletes() {
TeamTaskEntity running = task(1L, MEMBER_A);
running.setStatus(TeamTaskStatus.IN_PROGRESS);
TeamTaskEntity done = task(1L, MEMBER_A);
done.setStatus(TeamTaskStatus.COMPLETED);
done.setResult("analysis finished");
when(taskService.getTask(1L)).thenReturn(running, done);
when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of());
service.settleOutcome(running, "analysis finished");
verify(taskService).completeTask(1L, null, "analysis finished");
verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_completed"), any());
verify(announceService).announceTaskSettled(done);
}
@Test
@DisplayName("a task the member already failed via blocker is not completed on top")
void settleRespectsMemberFailure() {
TeamTaskEntity failed = task(1L, MEMBER_A);
failed.setStatus(TeamTaskStatus.FAILED);
failed.setReason("blocked: missing docs");
when(taskService.getTask(1L)).thenReturn(failed);
service.settleOutcome(failed, "irrelevant reply");
verify(taskService, never()).completeTask(any(), any(), anyString());
verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_failed"), any());
}
@Test
@DisplayName("an oversized member reply is truncated before persisting")
void settleTruncatesLongReply() {
TeamTaskEntity running = task(1L, MEMBER_A);
running.setStatus(TeamTaskStatus.IN_PROGRESS);
when(taskService.getTask(1L)).thenReturn(running, running);
when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of());
service.settleOutcome(running, "x".repeat(TeamDispatchService.MAX_RESULT_CHARS + 500));
verify(taskService).completeTask(eq(1L), isNull(), argThat(r ->
r.length() <= TeamDispatchService.MAX_RESULT_CHARS + 20
&& r.endsWith("...(truncated)")));
}
}