From fc8819cea6fd30977084d4cc38ee8f3883b47ee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 11:10:16 +0000 Subject: [PATCH] feat(demo): ship the demo project with working automations The demo had automations empty and switched off, so the feature stayed invisible to exactly the users most likely to be exploring. It was also inconsistent with the blank project default, where automations are on. Three automations, none of which touch the network. Two fire: an aux timer that runs with the event, on a global trigger, and a wrap-up message on an event level trigger, which is the only way per-event triggers get found by browsing rather than by reading the docs. The third is a fully formed OSC example with no trigger attached at all, so there is something realistic to read and edit without anything being sent anywhere. OSC input stays off: never open a listening socket the user did not ask for. The ids are hand written literals and nothing in the type system checks that a trigger resolves to an automation, so a test covers that, along with the promise that nothing bound to a trigger can reach off this machine. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H --- .../src/models/__tests__/demoProject.test.ts | 73 +++++++++++++++++++ apps/server/src/models/demoProject.ts | 53 +++++++++++++- apps/server/src/models/demoRundowns.ts | 13 +++- 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/models/__tests__/demoProject.test.ts diff --git a/apps/server/src/models/__tests__/demoProject.test.ts b/apps/server/src/models/__tests__/demoProject.test.ts new file mode 100644 index 000000000..0bf219b58 --- /dev/null +++ b/apps/server/src/models/__tests__/demoProject.test.ts @@ -0,0 +1,73 @@ +import { isHTTPOutput, isOSCOutput, isOntimeEvent, timerLifecycleValues } from 'ontime-types'; + +import { parseAutomation } from '../../api-data/automation/automation.validation.js'; +import { demoDb } from '../demoProject.js'; + +/** + * The demo ships with automations so the feature is visible on first run. + * They are hand written literals: nothing in the type system checks that a trigger + * resolves to an automation, or that the demo cannot reach out to the network. + */ +describe('demo project automations', () => { + const { automations, triggers } = demoDb.automation; + + it('is enabled, otherwise the automations are invisible', () => { + expect(demoDb.automation.enabledAutomations).toBe(true); + }); + + it('does not open a listening socket', () => { + expect(demoDb.automation.enabledOscIn).toBe(false); + }); + + it('keys every automation by its own id', () => { + for (const [key, automation] of Object.entries(automations)) { + expect(automation.id).toBe(key); + } + }); + + it('passes the same validation as a user created automation', () => { + for (const automation of Object.values(automations)) { + expect(() => parseAutomation(automation)).not.toThrow(); + } + }); + + it('resolves every global trigger to an automation', () => { + for (const trigger of triggers) { + expect(timerLifecycleValues).toContain(trigger.trigger); + expect(Object.hasOwn(automations, trigger.automationId)).toBe(true); + } + }); + + it('resolves every event level trigger to an automation', () => { + for (const rundown of Object.values(demoDb.rundowns)) { + for (const entry of Object.values(rundown.entries)) { + if (!isOntimeEvent(entry)) { + continue; + } + for (const trigger of entry.triggers) { + expect(timerLifecycleValues).toContain(trigger.trigger); + expect(Object.hasOwn(automations, trigger.automationId)).toBe(true); + } + } + } + }); + + it('never sends outside this machine, whatever the user presses', () => { + // an automation only fires if something triggers it + const boundIds = new Set(triggers.map((trigger) => trigger.automationId)); + for (const rundown of Object.values(demoDb.rundowns)) { + for (const entry of Object.values(rundown.entries)) { + if (isOntimeEvent(entry)) { + entry.triggers.forEach((trigger) => boundIds.add(trigger.automationId)); + } + } + } + + for (const id of boundIds) { + for (const output of automations[id].outputs) { + expect(isOSCOutput(output)).toBe(false); + expect(isHTTPOutput(output)).toBe(false); + } + } + }); +}); diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index db21d6075..7e68fc583 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -1,4 +1,4 @@ -import { DatabaseModel, OntimeView } from 'ontime-types'; +import { DatabaseModel, OntimeView, TimerLifeCycle } from 'ontime-types'; import { backstageRundown, broadcastRundown, stageRundown } from './demoRundowns.js'; @@ -76,11 +76,56 @@ export const demoDb: DatabaseModel = { label: 'PowerPoint Slide', }, }, + /** + * The demo ships with working automations so the engine is visible the first time + * someone presses Play, rather than hidden behind an empty settings panel. + * + * Everything that actually fires is an Ontime action: the demo must not put traffic + * on whatever network it happens to be opened on. The OSC entry is there to be read + * and edited, and is deliberately left without a trigger. + * + * The ids are hand written and must match the map keys. Ids are only generated for + * automations created through the DAO, so literals are safe here. + */ automation: { - enabledAutomations: false, + enabledAutomations: true, + // never open a listening socket without the user asking for it enabledOscIn: false, oscPortIn: 8888, - triggers: [], - automations: {}, + triggers: [ + { + id: 'demo-trigger-aux', + title: 'Demo: aux timer on start', + trigger: TimerLifeCycle.onStart, + automationId: 'demo-aux-timer', + }, + ], + automations: { + 'demo-aux-timer': { + id: 'demo-aux-timer', + title: 'Demo: run Aux Timer 1 with the event', + filterRule: 'all', + filters: [], + outputs: [ + { type: 'ontime', action: 'aux1-set', time: '00:05:00' }, + { type: 'ontime', action: 'aux1-start' }, + ], + }, + 'demo-danger-message': { + id: 'demo-danger-message', + title: 'Demo: warn the stage at danger', + filterRule: 'all', + filters: [], + // self labelled, so nobody mistakes it for something Ontime does on its own + outputs: [{ type: 'ontime', action: 'message-set', text: 'Demo automation: please wrap up', visible: true }], + }, + 'demo-osc-example': { + id: 'demo-osc-example', + title: 'Demo: OSC to a lighting console (example, not wired up)', + filterRule: 'all', + filters: [], + outputs: [{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/ontime/go', args: '1' }], + }, + }, }, }; diff --git a/apps/server/src/models/demoRundowns.ts b/apps/server/src/models/demoRundowns.ts index 3f1a77744..aecfdf524 100644 --- a/apps/server/src/models/demoRundowns.ts +++ b/apps/server/src/models/demoRundowns.ts @@ -1,4 +1,4 @@ -import { SupportedEntry, TimeStrategy, EndAction, TimerType, Day, Rundown } from 'ontime-types'; +import { SupportedEntry, TimeStrategy, EndAction, TimerType, Day, Rundown, TimerLifeCycle } from 'ontime-types'; export const stageRundown: Rundown = { id: 'default', @@ -120,7 +120,16 @@ export const stageRundown: Rundown = { Audio_Notes: '1x Wireless Hand Held', PowerPoint_Name: 'HoldingSlide.pptx', }, - triggers: [], + // demonstrates that an automation can be attached to a single event, which is + // otherwise only discoverable by reading the docs. See demoProject.ts + triggers: [ + { + id: 'demo-event-trigger', + title: 'Wrap up warning', + trigger: TimerLifeCycle.onDanger, + automationId: 'demo-danger-message', + }, + ], }, fa593e: { id: 'fa593e',