Skip to content

Commit d3e0ca7

Browse files
committed
wip: tests for properties panel
1 parent ad78549 commit d3e0ca7

File tree

4 files changed

+437
-0
lines changed

4 files changed

+437
-0
lines changed

packages/webui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@sofie-automation/meteor-lib": "1.52.0-in-development",
4646
"@sofie-automation/shared-lib": "1.52.0-in-development",
4747
"@sofie-automation/sorensen": "^1.4.3",
48+
"@testing-library/user-event": "^14.5.2",
4849
"@types/sinon": "^10.0.20",
4950
"classnames": "^2.5.1",
5051
"cubic-spline": "^3.0.3",
@@ -85,6 +86,7 @@
8586
"devDependencies": {
8687
"@babel/preset-env": "^7.24.8",
8788
"@testing-library/dom": "^10.4.0",
89+
"@testing-library/jest-dom": "^6.6.3",
8890
"@testing-library/react": "^16.0.1",
8991
"@types/classnames": "^2.3.1",
9092
"@types/deep-extend": "^0.6.2",

packages/webui/src/client/__tests__/jest-setup.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable node/no-unpublished-require */
2+
require('@testing-library/jest-dom')
23

34
// used by code creating XML with the DOM API to return an XML string
45
global.XMLSerializer = require('@xmldom/xmldom').XMLSerializer
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
// Mock the ReactiveDataHelper:
2+
jest.mock('../../../lib/reactiveData/ReactiveDataHelper', () => {
3+
class MockReactiveDataHelper {
4+
protected _subs: Array<{ stop: () => void }> = []
5+
6+
protected subscribe() {
7+
const sub = { stop: jest.fn() }
8+
this._subs.push(sub)
9+
return sub
10+
}
11+
12+
protected autorun(f: () => void) {
13+
f()
14+
return { stop: jest.fn() }
15+
}
16+
17+
destroy() {
18+
this._subs.forEach((sub) => sub.stop())
19+
this._subs = []
20+
}
21+
}
22+
23+
class MockWithManagedTracker extends MockReactiveDataHelper {
24+
constructor() {
25+
super()
26+
}
27+
}
28+
29+
return {
30+
__esModule: true,
31+
WithManagedTracker: MockWithManagedTracker,
32+
meteorSubscribe: jest.fn().mockReturnValue({
33+
stop: jest.fn(),
34+
}),
35+
}
36+
})
37+
38+
jest.mock('i18next', () => ({
39+
use: jest.fn().mockReturnThis(),
40+
init: jest.fn().mockImplementation(() => Promise.resolve()),
41+
t: (key: string) => key,
42+
changeLanguage: jest.fn().mockImplementation(() => Promise.resolve()),
43+
language: 'en',
44+
exists: jest.fn(),
45+
on: jest.fn(),
46+
off: jest.fn(),
47+
options: {},
48+
}))
49+
50+
// React-i18next with Promise support
51+
jest.mock('react-i18next', () => ({
52+
useTranslation: () => ({
53+
t: (key: string) => key,
54+
i18n: {
55+
changeLanguage: jest.fn().mockImplementation(() => Promise.resolve()),
56+
language: 'en',
57+
exists: jest.fn(),
58+
use: jest.fn().mockReturnThis(),
59+
init: jest.fn().mockImplementation(() => Promise.resolve()),
60+
on: jest.fn(),
61+
off: jest.fn(),
62+
options: {},
63+
},
64+
}),
65+
initReactI18next: {
66+
type: '3rdParty',
67+
init: jest.fn(),
68+
},
69+
}))
70+
71+
import React from 'react'
72+
// eslint-disable-next-line node/no-unpublished-import
73+
import { renderHook, act, render, screen, RenderResult } from '@testing-library/react'
74+
// eslint-disable-next-line node/no-unpublished-import
75+
import '@testing-library/jest-dom'
76+
import { MeteorCall } from '../../../lib/meteorApi'
77+
import { TFunction } from 'i18next'
78+
79+
import userEvent from '@testing-library/user-event'
80+
import { protectString } from '@sofie-automation/corelib/dist/protectedString'
81+
import { UIParts } from '../../Collections'
82+
import { Segments } from '../../../../client/collections'
83+
import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment'
84+
import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part'
85+
import { UserEditingType, UserEditingButtonType } from '@sofie-automation/blueprints-integration'
86+
import { SelectedElementProvider, useSelection } from '../../RundownView/SelectedElementsContext'
87+
import { MongoMock } from '../../../../__mocks__/mongo'
88+
import { PropertiesPanel } from '../PropertiesPanel'
89+
import { UserAction } from '../../../lib/clientUserAction'
90+
91+
const mockSegmentsCollection = MongoMock.getInnerMockCollection(Segments)
92+
const mockPartsCollection = MongoMock.getInnerMockCollection(UIParts)
93+
94+
// Mock Client User Action:
95+
jest.mock('../../../lib/clientUserAction', () => ({
96+
doUserAction: jest.fn((_t: TFunction, e: unknown, _action: UserAction, callback: Function) =>
97+
callback(e, Date.now())
98+
),
99+
}))
100+
101+
// Mock Userchange Operation:
102+
jest.mock('../../../lib/meteorApi', () => ({
103+
__esModule: true,
104+
MeteorCall: {
105+
userAction: {
106+
executeUserChangeOperation: jest.fn(),
107+
},
108+
},
109+
}))
110+
111+
// Mock SchemaFormInPlace Component
112+
jest.mock('../../../lib/forms/SchemaFormInPlace', () => ({
113+
SchemaFormInPlace: () => <div data-testid="schema-form">Schema Form</div>,
114+
}))
115+
116+
describe('PropertiesPanel', () => {
117+
const wrapper = ({ children }: { children: React.ReactNode }) => (
118+
<SelectedElementProvider>{children}</SelectedElementProvider>
119+
)
120+
121+
beforeEach(() => {
122+
mockSegmentsCollection.remove({})
123+
mockPartsCollection.remove({})
124+
jest.clearAllMocks()
125+
})
126+
127+
const createMockSegment = (id: string): DBSegment => ({
128+
_id: protectString(id),
129+
_rank: 1,
130+
name: `Segment ${id}`,
131+
rundownId: protectString('rundown1'),
132+
externalId: `ext_${id}`,
133+
userEditOperations: [
134+
{
135+
id: 'operation1',
136+
label: { key: 'TEST_LABEL' },
137+
type: UserEditingType.ACTION,
138+
buttonType: UserEditingButtonType.SWITCH,
139+
isActive: false,
140+
},
141+
],
142+
})
143+
144+
const createMockPart = (id: string, segmentId: string): DBPart => ({
145+
_id: protectString(id),
146+
_rank: 1,
147+
expectedDurationWithTransition: 0,
148+
title: `Part ${id}`,
149+
rundownId: protectString('rundown1'),
150+
segmentId: protectString(segmentId),
151+
externalId: `ext_${id}`,
152+
userEditOperations: [
153+
{
154+
id: 'operation2',
155+
label: { key: 'TEST_PART_LABEL' },
156+
type: UserEditingType.ACTION,
157+
buttonType: UserEditingButtonType.BUTTON,
158+
isActive: true,
159+
},
160+
],
161+
})
162+
163+
test('renders empty when no element selected', () => {
164+
const { container } = render(<PropertiesPanel />, { wrapper })
165+
expect(container.querySelector('.properties-panel')).toBeTruthy()
166+
expect(container.querySelector('.propertiespanel-pop-up__contents')).toBeFalsy()
167+
})
168+
169+
test('renders segment properties when segment is selected', async () => {
170+
const mockSegment = createMockSegment('segment1')
171+
mockSegmentsCollection.insert(mockSegment)
172+
173+
// Create a custom wrapper that includes both providers
174+
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
175+
<SelectedElementProvider>{children}</SelectedElementProvider>
176+
)
177+
178+
// Render both the hook and component in the same provider tree
179+
const { result } = renderHook(() => useSelection(), { wrapper: TestWrapper })
180+
let rendered: RenderResult
181+
182+
await act(async () => {
183+
rendered = render(<PropertiesPanel />, { wrapper: TestWrapper })
184+
})
185+
186+
// Update selection
187+
await act(async () => {
188+
result.current.clearAndSetSelection({
189+
type: 'segment',
190+
elementId: mockSegment._id,
191+
})
192+
})
193+
//@ts-expect-error error because avoiding an undefined type
194+
if (!rendered) throw new Error('Component not rendered')
195+
196+
// Force a rerender
197+
await act(async () => {
198+
rendered.rerender(<PropertiesPanel />)
199+
})
200+
201+
// Wait for the header element to appear
202+
await screen.findByText('SEGMENT : Segment segment1')
203+
204+
const header = rendered.container.querySelector('.propertiespanel-pop-up__header')
205+
const switchButton = rendered.container.querySelector('.propertiespanel-pop-up__switchbutton')
206+
207+
expect(header).toHaveTextContent('SEGMENT : Segment segment1')
208+
expect(switchButton).toBeTruthy()
209+
})
210+
211+
test('renders part properties when part is selected', async () => {
212+
const mockSegment = createMockSegment('segment1')
213+
const mockPart = createMockPart('part1', String(mockSegment._id))
214+
215+
mockSegmentsCollection.insert(mockSegment)
216+
mockPartsCollection.insert(mockPart)
217+
218+
// Create a custom wrapper that includes both providers
219+
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
220+
<SelectedElementProvider>{children}</SelectedElementProvider>
221+
)
222+
223+
// Render both the hook and component in the same provider tree
224+
const { result } = renderHook(() => useSelection(), { wrapper: TestWrapper })
225+
let rendered: RenderResult
226+
227+
await act(async () => {
228+
rendered = render(<PropertiesPanel />, { wrapper: TestWrapper })
229+
})
230+
231+
// Update selection
232+
await act(async () => {
233+
result.current.clearAndSetSelection({
234+
type: 'part',
235+
elementId: mockPart._id,
236+
})
237+
})
238+
239+
//@ts-expect-error error because avoiding an undefined type
240+
if (!rendered) throw new Error('Component not rendered')
241+
242+
// Force a rerender
243+
await act(async () => {
244+
rendered.rerender(<PropertiesPanel />)
245+
})
246+
247+
// Wait for the header element to appear
248+
await screen.findByText('PART : Part part1')
249+
250+
const header = rendered.container.querySelector('.propertiespanel-pop-up__header')
251+
const button = rendered.container.querySelector('.propertiespanel-pop-up__button')
252+
253+
expect(header).toHaveTextContent('PART : Part part1')
254+
expect(button).toBeTruthy()
255+
})
256+
257+
test('handles user edit operations for segments', async () => {
258+
const mockSegment = createMockSegment('segment1')
259+
mockSegmentsCollection.insert(mockSegment)
260+
261+
// First render the selection hook
262+
const { result } = renderHook(() => useSelection(), { wrapper })
263+
264+
// Then render the properties panel
265+
const { container } = render(<PropertiesPanel />, { wrapper })
266+
267+
// Update selection using the hook result
268+
act(() => {
269+
result.current.clearAndSetSelection({
270+
type: 'segment',
271+
elementId: mockSegment._id,
272+
})
273+
})
274+
275+
const switchButton = container.querySelector('.propertiespanel-pop-up__switchbutton')
276+
expect(switchButton).toBeTruthy()
277+
278+
// Toggle the switch
279+
await userEvent.click(switchButton!)
280+
281+
// Check if commit button is enabled
282+
const commitButton = screen.getByText('COMMIT CHANGES')
283+
expect(commitButton).toBeEnabled()
284+
285+
// Commit changes
286+
await userEvent.click(commitButton)
287+
288+
expect(MeteorCall.userAction.executeUserChangeOperation).toHaveBeenCalledWith(
289+
expect.anything(),
290+
expect.anything(),
291+
protectString('rundown1'),
292+
{
293+
segmentExternalId: mockSegment.externalId,
294+
partExternalId: undefined,
295+
pieceExternalId: undefined,
296+
},
297+
{
298+
id: 'operation1',
299+
values: undefined,
300+
}
301+
)
302+
})
303+
304+
test('handles revert changes', async () => {
305+
const mockSegment = createMockSegment('segment1')
306+
mockSegmentsCollection.insert(mockSegment)
307+
308+
// First render the selection hook
309+
const { result } = renderHook(() => useSelection(), { wrapper })
310+
311+
// Then render the properties panel
312+
const { container } = render(<PropertiesPanel />, { wrapper })
313+
314+
// Update selection using the hook result
315+
act(() => {
316+
result.current.clearAndSetSelection({
317+
type: 'segment',
318+
elementId: mockSegment._id,
319+
})
320+
})
321+
322+
// Make a change
323+
const switchButton = container.querySelector('.propertiespanel-pop-up__switchbutton')
324+
await userEvent.click(switchButton!)
325+
326+
// Click revert button
327+
const revertButton = screen.getByText('REVERT CHANGES')
328+
await userEvent.click(revertButton)
329+
330+
expect(MeteorCall.userAction.executeUserChangeOperation).toHaveBeenCalledWith(
331+
expect.anything(),
332+
expect.anything(),
333+
protectString('rundown1'),
334+
{
335+
segmentExternalId: mockSegment.externalId,
336+
partExternalId: undefined,
337+
pieceExternalId: undefined,
338+
},
339+
{
340+
id: 'REVERT_SEGMENT',
341+
}
342+
)
343+
})
344+
345+
test('closes panel when close button is clicked', async () => {
346+
const mockSegment = createMockSegment('segment1')
347+
mockSegmentsCollection.insert(mockSegment)
348+
349+
// First render the selection hook
350+
const { result } = renderHook(() => useSelection(), { wrapper })
351+
352+
// Then render the properties panel
353+
const { container } = render(<PropertiesPanel />, { wrapper })
354+
355+
// Update selection using the hook result
356+
act(() => {
357+
result.current.clearAndSetSelection({
358+
type: 'segment',
359+
elementId: mockSegment._id,
360+
})
361+
})
362+
363+
const closeButton = container.querySelector('.propertiespanel-pop-up_close')
364+
expect(closeButton).toBeTruthy()
365+
366+
await userEvent.click(closeButton!)
367+
368+
expect(container.querySelector('.propertiespanel-pop-up__contents')).toBeFalsy()
369+
})
370+
})

0 commit comments

Comments
 (0)