Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: refactor pharos device SOFIE-2488 #333

Merged
merged 1 commit into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ export interface TimelineContentPharosTimeline extends TimelineContentPharos {

timeline: number
pause?: boolean
rate?: boolean
rate?: number
fade?: number
}
2 changes: 1 addition & 1 deletion packages/timeline-state-resolver/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module.exports = {
'ts-jest',
{
tsconfig: 'tsconfig.json',
diagnostics: { ignoreCodes: [6133] },
diagnostics: { ignoreCodes: [6133, 6192] },
},
],
},
Expand Down
14 changes: 3 additions & 11 deletions packages/timeline-state-resolver/src/conductor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
DeviceOptionsPanasonicPTZ,
DeviceOptionsLawo,
DeviceOptionsSofieChef,
DeviceOptionsPharos,
} from 'timeline-state-resolver-types'

import { DoOnTime } from './devices/doOnTime'
Expand All @@ -44,7 +45,6 @@ import { CommandWithContext } from './devices/device'
import { DeviceContainer } from './devices/deviceContainer'

import { CasparCGDevice, DeviceOptionsCasparCGInternal } from './integrations/casparCG'
import { PharosDevice, DeviceOptionsPharosInternal } from './integrations/pharos'
import { SisyfosMessageDevice, DeviceOptionsSisyfosInternal } from './integrations/sisyfos'
import { SingularLiveDevice, DeviceOptionsSingularLiveInternal } from './integrations/singularLive'
import { VMixDevice, DeviceOptionsVMixInternal } from './integrations/vmix'
Expand Down Expand Up @@ -510,15 +510,6 @@ export class Conductor extends EventEmitter<ConductorEvents> {
getCurrentTime,
threadedClassOptions
)
case DeviceType.PHAROS:
return DeviceContainer.create<DeviceOptionsPharosInternal, typeof PharosDevice>(
'../../dist/integrations/pharos/index.js',
'PharosDevice',
deviceId,
deviceOptions,
getCurrentTime,
threadedClassOptions
)
case DeviceType.SISYFOS:
return DeviceContainer.create<DeviceOptionsSisyfosInternal, typeof SisyfosMessageDevice>(
'../../dist/integrations/sisyfos/index.js',
Expand Down Expand Up @@ -591,6 +582,7 @@ export class Conductor extends EventEmitter<ConductorEvents> {
case DeviceType.OBS:
case DeviceType.OSC:
case DeviceType.PANASONIC_PTZ:
case DeviceType.PHAROS:
case DeviceType.SHOTOKU:
case DeviceType.SOFIE_CHEF:
case DeviceType.TCPSEND:
Expand Down Expand Up @@ -1505,7 +1497,7 @@ export type DeviceOptionsAnyInternal =
| DeviceOptionsPanasonicPTZ
| DeviceOptionsTCPSend
| DeviceOptionsHyperdeck
| DeviceOptionsPharosInternal
| DeviceOptionsPharos
| DeviceOptionsOBS
| DeviceOptionsOSC
| DeviceOptionsMultiOSCInternal
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,112 @@
import { Conductor } from '../../../conductor'
import { PharosDevice } from '..'
import {
Mappings,
DeviceType,
Mapping,
SomeMappingPharos,
TimelineContentTypePharos,
} from 'timeline-state-resolver-types'
import { MockTime } from '../../../__tests__/mockTime'
import { ThreadedClass } from 'threadedclass'
import { getMockCall } from '../../../__tests__/lib'
import * as WebSocket from '../../../__mocks__/ws'
import { getDeviceContext } from '../../__tests__/testlib'
import { EventEmitter } from 'events'
import type { Pharos, ProjectInfo } from '../connection'
import { makeTimelineObjectResolved } from '../../../__mocks__/objects'

class MockPharosApi
extends EventEmitter
implements
Pick<
Pharos,
| 'connect'
| 'dispose'
| 'getProjectInfo'
| 'releaseScene'
| 'releaseTimeline'
| 'pauseTimeline'
| 'resumeTimeline'
| 'setTimelineRate'
| 'startScene'
| 'startTimeline'
>
{
static instances: MockPharosApi[] = []
constructor() {
super()

MockPharosApi.instances.push(this)
}

connected = false

connect = jest.fn(async () => {
this.connected = true

setImmediate(() => this.emit('connected'))
})
dispose = jest.fn(async () => {
this.connected = false

setImmediate(() => this.emit('disconnected'))
})

getProjectInfo = jest.fn(async () => {
return {
author: 'Jest',
filename: 'filename',
name: 'Jest test mock',
unique_id: 'abcde123',
upload_date: '2018-10-22T08:09:02',
} satisfies ProjectInfo
})

commandCalls: any[] = []
releaseScene = jest.fn(async (scene: number, fade?: number) => {
this.commandCalls.push({ type: 'releaseScene', scene, fade })
})
releaseTimeline = jest.fn(async (timeline: number, fade?: number) => {
this.commandCalls.push({ type: 'releaseTimeline', timeline, fade })
})
pauseTimeline = jest.fn(async (timeline: number) => {
this.commandCalls.push({ type: 'pauseTimeline', timeline })
})
resumeTimeline = jest.fn(async (timeline: number) => {
this.commandCalls.push({ type: 'resumeTimeline', timeline })
})
setTimelineRate = jest.fn(async (timeline: number, rate: number) => {
this.commandCalls.push({ type: 'setTimelineRate', timeline, rate })
})
startScene = jest.fn(async (scene: number, fade?: number) => {
this.commandCalls.push({ type: 'startScene', scene, fade })
})
startTimeline = jest.fn(async (timeline: number, rate?: number) => {
this.commandCalls.push({ type: 'startTimeline', timeline, rate })
})
}
jest.mock('../connection', () => ({ Pharos: MockPharosApi }))
import { PharosDevice, PharosState } from '..'

describe('Pharos', () => {
jest.mock('ws', () => WebSocket)
const mockTime = new MockTime()
jest.mock('ws', () => null)
beforeEach(() => {
mockTime.init()

WebSocket.clearMockInstances()
MockPharosApi.instances = []
})

jest.useRealTimers()
setTimeout(() => {
const wsInstances = WebSocket.getMockInstances()
if (wsInstances.length !== 1) throw new Error('WebSocket Mock Instance not created')
WebSocket.getMockInstances()[0].mockSetConnected(true)
}, 200)
jest.useFakeTimers()
afterEach(() => {
// eslint-disable-next-line jest/no-standalone-expect
expect(MockPharosApi.instances).toHaveLength(1)
})

// Future: this tests should be rewritten to be less monolithic and more granular
test('Scene', async () => {
let device: any = undefined
const commandReceiver0: any = jest.fn((...args) => {
// pipe through the command
return device._defaultCommandReceiver(...args)
// return Promise.resolve()
const context = getDeviceContext()
const pharos = new PharosDevice(context)

await pharos.init({
host: '127.0.0.1',
})
expect(pharos).toBeTruthy()

const mockApi = MockPharosApi.instances[0]
expect(mockApi).toBeTruthy()

const myLayerMapping0: Mapping<SomeMappingPharos> = {
device: DeviceType.PHAROS,
deviceId: 'myPharos',
Expand All @@ -44,68 +116,15 @@ describe('Pharos', () => {
myLayer0: myLayerMapping0,
}

const myConductor = new Conductor({
multiThreadedResolver: false,
getCurrentTime: mockTime.getCurrentTime,
})
const errorHandler = jest.fn()
myConductor.on('error', errorHandler)

const mockReply = jest.fn((_ws: WebSocket, message: string) => {
const data = JSON.parse(message)
if (data.request === 'project') {
return JSON.stringify({
request: data.request,
author: 'Jest',
filename: 'filename',
name: 'Jest test mock',
unique_id: 'abcde123',
upload_date: '2018-10-22T08:09:02',
})
} else {
console.log(data)
}
return ''
})
WebSocket.mockConstructor((ws: WebSocket) => {
// @ts-ignore mock
ws.mockReplyFunction((message) => {
if (message === '') return '' // ping message

return mockReply(ws, message)
})
})

await myConductor.init()
await myConductor.addDevice('myPharos', {
type: DeviceType.PHAROS,
options: {
host: '127.0.0.1',
},
commandReceiver: commandReceiver0,
})
myConductor.setTimelineAndMappings([], myLayerMapping)

const wsInstances = WebSocket.getMockInstances()
expect(wsInstances).toHaveLength(1)
// let wsInstance = wsInstances[0]

await mockTime.advanceTimeToTicks(10100)

const deviceContainer = myConductor.getDevice('myPharos')
device = deviceContainer!.device as ThreadedClass<PharosDevice>

expect(mockReply).toHaveBeenCalledTimes(1)
expect(getMockCall(mockReply, 0, 1)).toMatch(/project/) // get project info
const state0: PharosState = {}
const commands0 = pharos.diffStates(undefined, state0, myLayerMapping)
expect(commands0).toHaveLength(0)

// Check that no commands has been scheduled:
expect(await device.queue).toHaveLength(0)

myConductor.setTimelineAndMappings([
{
const state1: PharosState = {
myLayer0: makeTimelineObjectResolved({
id: 'scene0',
enable: {
start: mockTime.now + 1000,
start: 1000,
duration: 5000,
},
layer: 'myLayer0',
Expand All @@ -115,11 +134,21 @@ describe('Pharos', () => {

scene: 1,
},
},
{
}),
}
const commands1 = pharos.diffStates(state0, state1, myLayerMapping)
expect(commands1).toHaveLength(1)

for (const command of commands1) await pharos.sendCommand(command)
expect(context.commandError).toHaveBeenCalledTimes(0)
expect(mockApi.commandCalls).toEqual([{ type: 'startScene', scene: 1 }])
mockApi.commandCalls = []

const state2: PharosState = {
myLayer0: makeTimelineObjectResolved({
id: 'scene1',
enable: {
start: '#scene0.start + 1000',
start: 2000,
duration: 5000,
},
layer: 'myLayer0',
Expand All @@ -129,11 +158,24 @@ describe('Pharos', () => {

scene: 2,
},
},
{
}),
}
const commands2 = pharos.diffStates(state1, state2, myLayerMapping)
expect(commands2).toHaveLength(2)

for (const command of commands2) await pharos.sendCommand(command)
expect(context.commandError).toHaveBeenCalledTimes(0)
expect(mockApi.commandCalls).toEqual([
{ type: 'releaseScene', scene: 1 },
{ type: 'startScene', scene: 2 },
])
mockApi.commandCalls = []

const state3: PharosState = {
myLayer0: makeTimelineObjectResolved({
id: 'scene2',
enable: {
start: '#scene1.start + 1000',
start: 3000,
duration: 1000,
},
layer: 'myLayer0',
Expand All @@ -144,51 +186,25 @@ describe('Pharos', () => {
scene: 2,
stopped: true,
},
},
}),
}
const commands3 = pharos.diffStates(state2, state3, myLayerMapping)
expect(commands3).toHaveLength(2)

for (const command of commands3) await pharos.sendCommand(command)
expect(context.commandError).toHaveBeenCalledTimes(0)
expect(mockApi.commandCalls).toEqual([
{ type: 'releaseScene', scene: 2 },
{ type: 'releaseScene', scene: 2 },
])
mockApi.commandCalls = []

const state4: PharosState = {}
const commands4 = pharos.diffStates(state3, state4, myLayerMapping)
expect(commands4).toHaveLength(1)

await mockTime.advanceTimeToTicks(10990)
expect(commandReceiver0).toHaveBeenCalledTimes(0)

mockReply.mockReset()
expect(mockReply).toHaveBeenCalledTimes(0)

await mockTime.advanceTimeToTicks(11500)
expect(commandReceiver0).toHaveBeenCalledTimes(1)
expect(getMockCall(commandReceiver0, 0, 1).content.args[0]).toEqual(1) // scene
expect(getMockCall(commandReceiver0, 0, 2)).toMatch(/added/) // context
expect(getMockCall(commandReceiver0, 0, 2)).toMatch(/scene0/) // context

await mockTime.advanceTimeToTicks(12500)
expect(commandReceiver0).toHaveBeenCalledTimes(3)
expect(getMockCall(commandReceiver0, 1, 1).content.args[0]).toEqual(1) // scene
expect(getMockCall(commandReceiver0, 1, 2)).toMatch(/changed from/) // context
expect(getMockCall(commandReceiver0, 1, 2)).toMatch(/scene0/) // context

expect(getMockCall(commandReceiver0, 2, 1).content.args[0]).toEqual(2) // scene
expect(getMockCall(commandReceiver0, 2, 2)).toMatch(/changed to/) // context
expect(getMockCall(commandReceiver0, 2, 2)).toMatch(/scene1/) // context

await mockTime.advanceTimeToTicks(13500)
expect(commandReceiver0).toHaveBeenCalledTimes(5)
expect(getMockCall(commandReceiver0, 3, 1).content.args[0]).toEqual(2) // scene
expect(getMockCall(commandReceiver0, 3, 2)).toMatch(/removed/) // context
expect(getMockCall(commandReceiver0, 3, 2)).toMatch(/scene1/) // context

expect(getMockCall(commandReceiver0, 4, 1).content.args[0]).toEqual(2) // scene
expect(getMockCall(commandReceiver0, 4, 2)).toMatch(/removed/) // context
expect(getMockCall(commandReceiver0, 4, 2)).toMatch(/scene2/) // context

await mockTime.advanceTimeToTicks(14500)
expect(commandReceiver0).toHaveBeenCalledTimes(6)
expect(getMockCall(commandReceiver0, 5, 1).content.args[0]).toEqual(2) // scene
expect(getMockCall(commandReceiver0, 5, 2)).toMatch(/added/) // context
expect(getMockCall(commandReceiver0, 5, 2)).toMatch(/scene1/) // context

await mockTime.advanceTimeToTicks(20000)
expect(commandReceiver0).toHaveBeenCalledTimes(7)
expect(getMockCall(commandReceiver0, 6, 1).content.args[0]).toEqual(2) // scene
expect(getMockCall(commandReceiver0, 6, 2)).toMatch(/removed/) // context
expect(getMockCall(commandReceiver0, 6, 2)).toMatch(/scene1/) // context
for (const command of commands4) await pharos.sendCommand(command)
expect(context.commandError).toHaveBeenCalledTimes(0)
expect(mockApi.commandCalls).toEqual([{ type: 'releaseScene', scene: 2 }])
})
})
Loading
Loading