import { SupportedEntry, type EventPostPayload, type OntimeEntry, type PatchWithId, type Rundown } from 'ontime-types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const addEntryMock = vi.hoisted(() => vi.fn()); const editEntryMock = vi.hoisted(() => vi.fn()); const groupEntriesMock = vi.hoisted(() => vi.fn()); const ungroupEntriesMock = vi.hoisted(() => vi.fn()); const getCurrentRundownMock = vi.hoisted(() => vi.fn()); vi.mock('../../api-data/rundown/rundown.dao.js', () => ({ getCurrentRundown: getCurrentRundownMock, getCurrentRundownId: vi.fn(() => 'loaded-rundown'), getProjectCustomFields: vi.fn(() => ({})), })); vi.mock('../../classes/data-provider/DataProvider.js', () => ({ getDataProvider: vi.fn(), })); vi.mock('../../api-data/rundown/rundown.service.js', () => ({ addEntry: addEntryMock, batchEditEntries: vi.fn(), deleteEntries: vi.fn(), editEntry: editEntryMock, groupEntries: groupEntriesMock, reorderEntry: vi.fn(), ungroupEntries: ungroupEntriesMock, })); const { batchCreateEntriesForMcp, groupEntriesForMcp, ungroupEntryForMcp } = await import('../mcp.service.js'); function makeRundown(entries: Rundown['entries'], order: string[] = Object.keys(entries)): Rundown { return { id: 'loaded-rundown', title: 'Loaded', order, flatOrder: order, entries, revision: 0, }; } describe('mcp.service', () => { beforeEach(() => { vi.clearAllMocks(); getCurrentRundownMock.mockReturnValue(makeRundown({})); let id = 0; addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => { id += 1; const entryId = `entry-${id}`; if (payload.type === SupportedEntry.Group) { return { id: entryId, type: SupportedEntry.Group, title: payload.title ?? '', note: '', colour: '', custom: {}, targetDuration: null, entries: [], revision: 0, timeStart: null, timeEnd: null, duration: 0, isFirstLinked: false, }; } return { id: entryId, type: payload.type ?? SupportedEntry.Event, title: payload.title ?? '', parent: 'parent' in payload ? payload.parent : null, } as OntimeEntry; }); editEntryMock.mockImplementation(async (_rundownId: string, patch: PatchWithId) => ({ id: patch.id, type: SupportedEntry.Group, title: 'Session block', note: patch.note ?? '', colour: patch.colour ?? '', custom: patch.custom ?? {}, targetDuration: patch.targetDuration ?? null, entries: [], revision: 1, timeStart: null, timeEnd: null, duration: 0, isFirstLinked: false, })); }); it('creates grouped batch entries with group metadata and child parent references', async () => { const result = await batchCreateEntriesForMcp({ entries: [ { type: SupportedEntry.Group, title: 'Session block', note: 'Main room', colour: '#123456', targetDuration: 3_600_000, children: [ { type: SupportedEntry.Event, title: 'Talk', timeStart: 36_000_000, duration: 1_800_000 }, { type: SupportedEntry.Milestone, title: 'Reset stage' }, ], }, { type: SupportedEntry.Event, title: 'After block', timeStart: 39_600_000, duration: 900_000 }, ], }); expect(addEntryMock).toHaveBeenNthCalledWith( 1, 'loaded-rundown', expect.objectContaining({ type: SupportedEntry.Group, title: 'Session block' }), ); expect(editEntryMock).toHaveBeenCalledWith( 'loaded-rundown', expect.objectContaining({ id: 'entry-1', note: 'Main room', colour: '#123456', targetDuration: 3_600_000, }), ); expect(addEntryMock).toHaveBeenNthCalledWith( 2, 'loaded-rundown', expect.objectContaining({ type: SupportedEntry.Event, title: 'Talk', parent: 'entry-1' }), ); expect(addEntryMock).toHaveBeenNthCalledWith( 3, 'loaded-rundown', expect.objectContaining({ type: SupportedEntry.Milestone, title: 'Reset stage', parent: 'entry-1', after: 'entry-2', }), ); expect(addEntryMock).toHaveBeenNthCalledWith( 4, 'loaded-rundown', expect.objectContaining({ type: SupportedEntry.Event, title: 'After block', after: 'entry-1' }), ); expect(result.created.map((entry) => entry.id)).toEqual(['entry-1', 'entry-2', 'entry-3', 'entry-4']); }); it('rejects nested groups before creating entries', async () => { await expect( batchCreateEntriesForMcp({ entries: [ { type: SupportedEntry.Group, title: 'Outer', children: [{ type: SupportedEntry.Group, title: 'Inner' }], }, ], }), ).rejects.toThrow('Cannot create a group inside another group.'); expect(addEntryMock).not.toHaveBeenCalled(); expect(editEntryMock).not.toHaveBeenCalled(); }); it('groups existing top-level entries and applies group metadata', async () => { const sourceRundown = makeRundown( { 'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: null } as OntimeEntry, 'entry-2': { id: 'entry-2', type: SupportedEntry.Event, title: 'Two', parent: null } as OntimeEntry, }, ['entry-1', 'entry-2'], ); const groupedRundown = makeRundown( { group: { id: 'group', type: SupportedEntry.Group, title: '', note: '', colour: '', custom: {}, targetDuration: null, entries: ['entry-1', 'entry-2'], revision: 0, timeStart: null, timeEnd: null, duration: 0, isFirstLinked: false, }, 'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: 'group' } as OntimeEntry, 'entry-2': { id: 'entry-2', type: SupportedEntry.Event, title: 'Two', parent: 'group' } as OntimeEntry, }, ['group'], ); getCurrentRundownMock.mockReturnValue(sourceRundown); groupEntriesMock.mockResolvedValue(groupedRundown); const result = await groupEntriesForMcp({ ids: ['entry-1', 'entry-2'], title: 'Block', colour: '#abcdef', targetDuration: 1_200_000, }); expect(groupEntriesMock).toHaveBeenCalledWith('loaded-rundown', ['entry-1', 'entry-2']); expect(editEntryMock).toHaveBeenCalledWith( 'loaded-rundown', expect.objectContaining({ id: 'group', colour: '#abcdef', targetDuration: 1_200_000 }), ); expect(result.entry.id).toBe('group'); expect(result.order).toEqual(['group']); }); it('rejects grouping nested entries before mutating', async () => { getCurrentRundownMock.mockReturnValue( makeRundown({ group: { id: 'group', type: SupportedEntry.Group, entries: ['entry-1'] } as OntimeEntry, 'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: 'group' } as OntimeEntry, }), ); await expect(groupEntriesForMcp({ ids: ['entry-1'] })).rejects.toThrow( 'Cannot group nested entry entry-1. Move it out of its group first.', ); expect(groupEntriesMock).not.toHaveBeenCalled(); expect(editEntryMock).not.toHaveBeenCalled(); }); it('ungroups an existing group entry', async () => { getCurrentRundownMock.mockReturnValue( makeRundown({ group: { id: 'group', type: SupportedEntry.Group, entries: ['entry-1'] } as OntimeEntry, 'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: 'group' } as OntimeEntry, }), ); ungroupEntriesMock.mockResolvedValue( makeRundown( { 'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: null } as OntimeEntry, }, ['entry-1'], ), ); const result = await ungroupEntryForMcp({ id: 'group' }); expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group'); expect(result).toMatchObject({ ungrouped: 'group', order: ['entry-1'] }); }); });