mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 23:49:15 +00:00
Refactor core services for multi-rundown support
This commit introduces foundational changes to enable handling multiple rundowns within a single project.
Key changes:
- `rundown.dao.ts`:
- Replaced single global rundown cache with Map-based caches for rundowns, metadata, and custom field usage, keyed by RundownId.
- Updated `init`, getters, and transaction logic to operate on specific RundownIds.
- Added `removeRundownFromCache`.
- Basic unit tests for DAO operations implemented.
- `ProjectService.ts`:
- `loadProject` now initializes all rundowns from a project file into the DAO.
- `patchCurrentProject` updated to sync multiple rundowns with the DAO (add/update/remove from cache).
- Added new CRUD methods: `getAvailableRundownsInCurrentProject`, `createNewRundownInCurrentProject`, `deleteRundownFromCurrentProject`, `updateRundownMetadataInCurrentProject`.
- `RuntimeService.ts`:
- Introduced `activeRundownId` state.
- Implemented `setActiveRundown`, `clearActiveRundown`, `getActiveRundownId`.
- DAO calls within service now use `activeRundownId`.
- `notifyOfChangedEvents` now filters by active rundown.
- `RestorePoint` type and save/resume logic updated to include `rundownId`.
- Enhanced state reset in `runtimeState.ts` and `RuntimeService` for rundown context switching.
- `AppStateService.ts`:
- Extended `AppState` to persist `lastActiveRundowns` (map of projectFileName to last active RundownId).
- Added methods to manage this persisted preference.
- API Layer (Conceptual Outline):
- Project loading to make all rundowns available and activate last active.
- New CRUD APIs for rundowns within a project.
- New API for runtime activation (`/runtime/activate_rundown`).
- Existing runtime APIs to use activated context.
- Editing APIs to explicitly target `rundownId`.
Further work will involve implementing the API layer changes and comprehensively updating/writing tests across the application to align with this new architecture.
This commit is contained in:
@@ -0,0 +1,203 @@
|
|||||||
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import type { Rundown, CustomFields, RundownId, OntimeEvent, EntryId, RundownMetadata, CustomFieldsMetadata } from 'ontime-types';
|
||||||
|
import * as rundownDao from './rundown.dao'; // Import all exports
|
||||||
|
|
||||||
|
// Mock DataProvider
|
||||||
|
const mockSetRundown = vi.fn();
|
||||||
|
const mockSetCustomFields = vi.fn();
|
||||||
|
const mockGetData = vi.fn(); // May not be needed for DAO tests directly
|
||||||
|
|
||||||
|
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
|
||||||
|
getDataProvider: () => ({
|
||||||
|
setRundown: mockSetRundown,
|
||||||
|
setCustomFields: mockSetCustomFields,
|
||||||
|
getData: mockGetData,
|
||||||
|
// Add other methods if they become necessary and need mocking
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to create a basic rundown
|
||||||
|
const createSampleRundown = (id: RundownId, title: string, revision: number = 0): Readonly<Rundown> => ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
order: [],
|
||||||
|
flatOrder: [],
|
||||||
|
entries: {},
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to create basic custom fields
|
||||||
|
const createSampleCustomFields = (): Readonly<CustomFields> => ({
|
||||||
|
field1: { label: 'Field 1', type: 'text', colour: '#FF0000' },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rundown DAO', () => {
|
||||||
|
let sampleRundown1: Readonly<Rundown>;
|
||||||
|
let sampleRundown2: Readonly<Rundown>;
|
||||||
|
let sampleCustomFields: Readonly<CustomFields>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset mocks and DAO internal state before each test
|
||||||
|
mockSetRundown.mockClear();
|
||||||
|
mockSetCustomFields.mockClear();
|
||||||
|
|
||||||
|
// Clear internal caches of rundownDao by removing all known rundowns
|
||||||
|
// This assumes we can get all keys or have a reset mechanism.
|
||||||
|
// For now, we'll rely on removeRundownFromCache if available, or test around existing state.
|
||||||
|
// A proper reset function in the DAO for testing would be ideal.
|
||||||
|
// For now, let's assume tests manage their own rundown IDs to avoid collision.
|
||||||
|
|
||||||
|
sampleRundown1 = createSampleRundown('rd1', 'Rundown One');
|
||||||
|
sampleRundown2 = createSampleRundown('rd2', 'Rundown Two');
|
||||||
|
sampleCustomFields = createSampleCustomFields();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Cleanup: remove any rundowns created during tests to ensure test isolation
|
||||||
|
rundownDao.removeRundownFromCache('rd1');
|
||||||
|
rundownDao.removeRundownFromCache('rd2');
|
||||||
|
rundownDao.removeRundownFromCache('rd_commit_test');
|
||||||
|
// Clear projectCustomFields by re-initializing with empty object - a bit hacky
|
||||||
|
rundownDao.init(createSampleRundown('temp_cleanup_rd', 'Cleanup'), {});
|
||||||
|
rundownDao.removeRundownFromCache('temp_cleanup_rd');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
it('should initialize a new rundown, store it in cache, and update metadata', async () => {
|
||||||
|
const { rundown, rundownMetadata, customFields, revision } = rundownDao.init(sampleRundown1, sampleCustomFields);
|
||||||
|
|
||||||
|
expect(rundown.id).toBe('rd1');
|
||||||
|
expect(rundown.title).toBe('Rundown One');
|
||||||
|
expect(revision).toBe(0); // Initial revision from sampleRundown1
|
||||||
|
expect(customFields).toEqual(sampleCustomFields);
|
||||||
|
|
||||||
|
expect(rundownDao.getCurrentRundown('rd1')).toEqual(rundown);
|
||||||
|
expect(rundownDao.getRundownMetadata('rd1')).toEqual(rundownMetadata);
|
||||||
|
expect(rundownDao.getCustomFieldsMetadata('rd1')).toBeDefined();
|
||||||
|
expect(rundownDao.getProjectCustomFields()).toEqual(sampleCustomFields);
|
||||||
|
|
||||||
|
// Check if persistence was called (uses setImmediate, so might need to handle async)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockSetRundown).toHaveBeenCalledWith('rd1', expect.objectContaining({ id: 'rd1' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update projectCustomFields if new custom fields are provided', () => {
|
||||||
|
rundownDao.init(sampleRundown1, {}); // Initial empty
|
||||||
|
expect(rundownDao.getProjectCustomFields()).toEqual({});
|
||||||
|
rundownDao.init(sampleRundown2, sampleCustomFields); // Now with custom fields
|
||||||
|
expect(rundownDao.getProjectCustomFields()).toEqual(sampleCustomFields);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Getter Functions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rundownDao.init(sampleRundown1, sampleCustomFields);
|
||||||
|
rundownDao.init(sampleRundown2, {}); // No specific custom fields for this one, uses project global
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCurrentRundown should retrieve the correct rundown by ID', () => {
|
||||||
|
expect(rundownDao.getCurrentRundown('rd1')?.title).toBe('Rundown One');
|
||||||
|
expect(rundownDao.getCurrentRundown('rd2')?.title).toBe('Rundown Two');
|
||||||
|
expect(rundownDao.getCurrentRundown('nonexistent')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRundownMetadata should retrieve metadata for the correct rundown by ID', () => {
|
||||||
|
expect(rundownDao.getRundownMetadata('rd1')).toBeDefined();
|
||||||
|
expect(rundownDao.getRundownMetadata('rd2')).toBeDefined();
|
||||||
|
expect(rundownDao.getRundownMetadata('nonexistent')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCustomFieldsMetadata should retrieve custom fields metadata for the correct rundown by ID', () => {
|
||||||
|
expect(rundownDao.getCustomFieldsMetadata('rd1')).toBeDefined();
|
||||||
|
expect(rundownDao.getCustomFieldsMetadata('rd1')?.assigned).toEqual({}); // Initially empty
|
||||||
|
expect(rundownDao.getCustomFieldsMetadata('nonexistent')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getEntryWithId should retrieve an entry from the specified rundown', () => {
|
||||||
|
// Add an entry to sampleRundown1 for testing
|
||||||
|
const event: OntimeEvent = { id: 'ev1', type: 'event', title: 'Event 1', timeStart: 0, duration: 1000, revision: 0, custom: {}, linkStart: false, parent: null, dayOffset: 0, delay:0, gap:0 };
|
||||||
|
const transaction = rundownDao.createTransaction('rd1', { mutableRundown: true });
|
||||||
|
rundownDao.rundownMutation.add(transaction.rundown, event, null, null);
|
||||||
|
transaction.commit(true); // process to update entries in cache
|
||||||
|
|
||||||
|
expect(rundownDao.getEntryWithId('rd1', 'ev1')?.title).toBe('Event 1');
|
||||||
|
expect(rundownDao.getEntryWithId('rd1', 'nonexistent')).toBeUndefined();
|
||||||
|
expect(rundownDao.getEntryWithId('rd2', 'ev1')).toBeUndefined(); // Should not find in another rundown
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rundownCache.get should retrieve rundown, metadata, and project custom fields', () => {
|
||||||
|
const cacheDataRd1 = rundownDao.rundownCache.get('rd1');
|
||||||
|
expect(cacheDataRd1.rundown?.id).toBe('rd1');
|
||||||
|
expect(cacheDataRd1.metadata).toBeDefined();
|
||||||
|
expect(cacheDataRd1.customFields).toEqual(sampleCustomFields);
|
||||||
|
|
||||||
|
const cacheDataNonExistent = rundownDao.rundownCache.get('nonexistent');
|
||||||
|
expect(cacheDataNonExistent.rundown).toBeUndefined();
|
||||||
|
expect(cacheDataNonExistent.metadata).toBeUndefined();
|
||||||
|
expect(cacheDataNonExistent.customFields).toEqual(sampleCustomFields); // Still returns global CFs
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeRundownFromCache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rundownDao.init(sampleRundown1, sampleCustomFields);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove the rundown and its metadata from caches', () => {
|
||||||
|
expect(rundownDao.getCurrentRundown('rd1')).toBeDefined();
|
||||||
|
const result = rundownDao.removeRundownFromCache('rd1');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(rundownDao.getCurrentRundown('rd1')).toBeUndefined();
|
||||||
|
expect(rundownDao.getRundownMetadata('rd1')).toBeUndefined();
|
||||||
|
expect(rundownDao.getCustomFieldsMetadata('rd1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if rundownId does not exist', () => {
|
||||||
|
const result = rundownDao.removeRundownFromCache('nonexistent');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createTransaction and commit', () => {
|
||||||
|
it('should update a rundown title without processing and increment revision', async () => {
|
||||||
|
rundownDao.init(createSampleRundown('rd_commit_test', 'Initial Title'), {});
|
||||||
|
const tx = rundownDao.createTransaction('rd_commit_test', { mutableRundown: true });
|
||||||
|
tx.rundown.title = 'Updated Title';
|
||||||
|
const { rundown: committedRundown, revision } = tx.commit(false); // No processing
|
||||||
|
|
||||||
|
expect(committedRundown.title).toBe('Updated Title');
|
||||||
|
expect(revision).toBe(1); // 0 (init) + 1 (commit)
|
||||||
|
expect(rundownDao.getCurrentRundown('rd_commit_test')?.title).toBe('Updated Title');
|
||||||
|
expect(rundownDao.getCurrentRundown('rd_commit_test')?.revision).toBe(1);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockSetRundown).toHaveBeenCalledWith('rd_commit_test', expect.objectContaining({ title: 'Updated Title', revision: 1 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// More complex transaction tests (e.g., with processing, custom field changes) would go here.
|
||||||
|
// For example, testing that adding an event and committing with shouldProcess=true updates metadata.
|
||||||
|
// This requires more setup for event data and validation of metadata changes.
|
||||||
|
|
||||||
|
it('should throw an error if creating a transaction for a non-existent rundownId', () => {
|
||||||
|
expect(() => rundownDao.createTransaction('non_existent_rd', { mutableRundown: true })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update projectCustomFields if mutableCustomFields is true', async () => {
|
||||||
|
rundownDao.init(sampleRundown1, {}); // Start with empty project custom fields
|
||||||
|
const tx = rundownDao.createTransaction('rd1', { mutableRundown: false, mutableCustomFields: true });
|
||||||
|
tx.customFields['newField'] = { label: 'New Field', type: 'text', colour: '#00FF00' };
|
||||||
|
tx.commit(false);
|
||||||
|
|
||||||
|
expect(rundownDao.getProjectCustomFields()['newField']).toBeDefined();
|
||||||
|
expect(rundownDao.getProjectCustomFields()['newField']?.label).toBe('New Field');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mockSetCustomFields).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
newField: { label: 'New Field', type: 'text', colour: '#00FF00' }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -673,6 +673,19 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
|
|||||||
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
|
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a rundown and its associated metadata from all caches.
|
||||||
|
* Note: This does not handle removing it from the persistence layer (DataProvider).
|
||||||
|
* That responsibility lies with the caller (e.g., ProjectService).
|
||||||
|
*/
|
||||||
|
export function removeRundownFromCache(rundownId: RundownId): boolean {
|
||||||
|
const deletedRundown = cachedRundowns.delete(rundownId);
|
||||||
|
rundownMetadatas.delete(rundownId);
|
||||||
|
customFieldsMetadatas.delete(rundownId);
|
||||||
|
// projectCustomFields are global and not deleted per rundown.
|
||||||
|
return deletedRundown; // Returns true if a rundown was found and deleted, false otherwise.
|
||||||
|
}
|
||||||
|
|
||||||
export const rundownCache = {
|
export const rundownCache = {
|
||||||
init,
|
init,
|
||||||
get: () => {
|
get: () => {
|
||||||
|
|||||||
@@ -94,10 +94,20 @@ export function clearEventData() {
|
|||||||
runtimeState.clock = timeNow();
|
runtimeState.clock = timeNow();
|
||||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||||
|
|
||||||
// when clearing, we maintain the total delay from the rundown
|
// Reset _timer fields
|
||||||
runtimeState._timer.forceFinish = null;
|
runtimeState._timer.forceFinish = null;
|
||||||
runtimeState._timer.pausedAt = null;
|
runtimeState._timer.pausedAt = null;
|
||||||
runtimeState._timer.secondaryTarget = null;
|
runtimeState._timer.secondaryTarget = null;
|
||||||
|
|
||||||
|
// Reset _rundown fields (specific to a rundown context)
|
||||||
|
runtimeState._rundown.totalDelay = 0;
|
||||||
|
|
||||||
|
// Reset rundown-specific fields in runtime object
|
||||||
|
runtimeState.runtime.numEvents = 0;
|
||||||
|
runtimeState.runtime.plannedStart = null;
|
||||||
|
runtimeState.runtime.plannedEnd = null;
|
||||||
|
// Note: offsetMode is a user setting, probably should not be reset here unless intended.
|
||||||
|
// For now, offsetMode is preserved across clearState.
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear all necessary data when doing a full stop and the event is unloaded
|
// clear all necessary data when doing a full stop and the event is unloaded
|
||||||
|
|||||||
Reference in New Issue
Block a user