agent-ecosystem/test/renderer/store/teamGlobalTaskNotifications.test.ts

297 lines
8.3 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
consumeFirstGlobalTasksFetchFlag,
processGlobalTaskNotifications,
resetGlobalTaskNotificationTrackerForTests,
} from '../../../src/renderer/store/team/teamGlobalTaskNotifications';
import type { AppConfig } from '../../../src/renderer/types/data';
import type {
GlobalTask,
TaskComment,
TeamMessageNotificationData,
TeamSummary,
} from '../../../src/shared/types';
const hoisted = vi.hoisted(() => ({
showMessageNotification: vi.fn(async (_data: unknown) => undefined),
}));
vi.mock('@renderer/api', () => ({
api: {
teams: {
showMessageNotification: hoisted.showMessageNotification,
},
},
}));
function createTask(overrides: Partial<GlobalTask> = {}): GlobalTask {
return {
id: 'task-1',
teamName: 'team-a',
teamDisplayName: 'Team A',
subject: 'Ship refactor',
description: 'Refactor safely',
status: 'pending',
owner: 'alice',
comments: [],
blockedBy: [],
updatedAt: '2026-05-22T10:00:00.000Z',
...overrides,
} as GlobalTask;
}
function createComment(overrides: Partial<TaskComment> = {}): TaskComment {
return {
id: 'c1',
author: 'bob',
text: 'Looks good',
type: 'comment',
createdAt: '2026-05-22T10:00:00.000Z',
...overrides,
} as TaskComment;
}
function createConfig(
notifications: Partial<NonNullable<AppConfig['notifications']>> = {}
): AppConfig {
return {
notifications: {
enabled: true,
notifyOnClarifications: true,
notifyOnStatusChange: true,
notifyOnTaskComments: true,
notifyOnTaskCreated: true,
notifyOnAllTasksCompleted: true,
statusChangeStatuses: ['in_progress', 'completed'],
statusChangeOnlySolo: true,
...notifications,
},
} as AppConfig;
}
function teamSummary(memberCount = 0): TeamSummary {
return {
teamName: 'team-a',
displayName: 'Team A',
memberCount,
} as TeamSummary;
}
function sentNotifications(): TeamMessageNotificationData[] {
return hoisted.showMessageNotification.mock.calls.map(
([payload]) => payload as TeamMessageNotificationData
);
}
describe('teamGlobalTaskNotifications', () => {
afterEach(() => {
hoisted.showMessageNotification.mockClear();
resetGlobalTaskNotificationTrackerForTests();
});
it('tracks the first global tasks fetch as a resettable module flag', () => {
expect(consumeFirstGlobalTasksFetchFlag()).toBe(true);
expect(consumeFirstGlobalTasksFetchFlag()).toBe(false);
resetGlobalTaskNotificationTrackerForTests();
expect(consumeFirstGlobalTasksFetchFlag()).toBe(true);
});
it('seeds initial tasks without sending notifications', () => {
processGlobalTaskNotifications({
oldTasks: [],
newTasks: [
createTask({
needsClarification: 'user',
blockedBy: ['task-2'],
comments: [createComment({ text: 'Needs review' })],
status: 'completed',
}),
],
appConfig: createConfig(),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: true,
});
expect(hoisted.showMessageNotification).not.toHaveBeenCalled();
});
it('emits clarification notifications and respects the per-type toast toggle', () => {
processGlobalTaskNotifications({
oldTasks: [createTask()],
newTasks: [
createTask({
needsClarification: 'user',
comments: [createComment({ text: 'Please clarify' })],
}),
],
appConfig: createConfig({ notifyOnClarifications: false }),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'task_clarification',
from: 'bob',
body: 'Please clarify',
suppressToast: true,
target: {
kind: 'task',
teamName: 'team-a',
taskId: 'task-1',
commentId: 'c1',
focus: 'comments',
},
},
{
teamEventType: 'task_comment',
from: 'bob',
body: 'Please clarify',
suppressToast: false,
target: {
kind: 'task',
teamName: 'team-a',
taskId: 'task-1',
commentId: 'c1',
focus: 'comments',
},
},
]);
});
it('emits status changes only for solo teams when solo filtering is enabled', () => {
const oldTask = createTask({ status: 'pending' });
const newTask = createTask({ status: 'in_progress' });
processGlobalTaskNotifications({
oldTasks: [oldTask],
newTasks: [newTask],
appConfig: createConfig(),
teamByName: { 'team-a': teamSummary(2) },
isInitialFetch: false,
});
expect(hoisted.showMessageNotification).not.toHaveBeenCalled();
processGlobalTaskNotifications({
oldTasks: [oldTask],
newTasks: [newTask],
appConfig: createConfig(),
teamByName: { 'team-a': teamSummary(0) },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'task_status_change',
from: 'alice',
body: 'Ship refactor',
suppressToast: false,
target: { kind: 'task', teamName: 'team-a', taskId: 'task-1', focus: 'status' },
},
]);
});
it('emits only actionable teammate comments and review requests', () => {
processGlobalTaskNotifications({
oldTasks: [createTask({ comments: [] })],
newTasks: [
createTask({
comments: [
createComment({ id: 'c1', author: 'bob', text: 'Looks blocked' }),
createComment({ id: 'c2', author: 'user', text: 'My own note' }),
createComment({
id: 'c3',
author: 'reviewer',
text: 'Review this',
type: 'review_request',
}),
],
}),
],
appConfig: createConfig(),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'task_comment',
from: 'bob',
body: 'Looks blocked',
target: { kind: 'task', teamName: 'team-a', taskId: 'task-1', commentId: 'c1' },
},
{
teamEventType: 'task_review_requested',
from: 'reviewer',
body: 'Review this',
target: { kind: 'task', teamName: 'team-a', taskId: 'task-1', commentId: 'c3' },
},
]);
});
it('emits blocked and created task notifications on non-initial updates', () => {
const existingTask = createTask();
const newTask = createTask({ id: 'task-3', subject: 'New work' });
processGlobalTaskNotifications({
oldTasks: [existingTask],
newTasks: [createTask({ blockedBy: ['task-2'] }), newTask],
appConfig: createConfig({ statusChangeStatuses: [] }),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'task_blocked',
body: 'Blocked by #task-2',
target: { kind: 'task', teamName: 'team-a', taskId: 'task-1', focus: 'detail' },
},
{
teamEventType: 'task_created',
body: 'Refactor safely',
target: { kind: 'task', teamName: 'team-a', taskId: 'task-3', focus: 'detail' },
},
]);
});
it('emits all-completed once when a team transitions into final tasks', () => {
const oldTasks = [
createTask({ id: 'task-1', status: 'completed' }),
createTask({ id: 'task-2', status: 'in_progress' }),
];
const newTasks = [
createTask({ id: 'task-1', status: 'completed' }),
createTask({ id: 'task-2', status: 'completed' }),
];
processGlobalTaskNotifications({
oldTasks,
newTasks,
appConfig: createConfig({ statusChangeStatuses: [] }),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
processGlobalTaskNotifications({
oldTasks,
newTasks,
appConfig: createConfig({ statusChangeStatuses: [] }),
teamByName: { 'team-a': teamSummary() },
isInitialFetch: false,
});
expect(sentNotifications()).toMatchObject([
{
teamEventType: 'all_tasks_completed',
from: 'system',
to: 'user',
summary: 'All 2 tasks completed',
target: { kind: 'team', teamName: 'team-a', section: 'tasks' },
},
]);
});
});