diff --git a/packages/webui/package.json b/packages/webui/package.json index 14dd16f95d..5f9359c6e3 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -45,6 +45,7 @@ "@sofie-automation/meteor-lib": "1.52.0-in-development", "@sofie-automation/shared-lib": "1.52.0-in-development", "@sofie-automation/sorensen": "^1.4.3", + "@testing-library/user-event": "^14.5.2", "@types/sinon": "^10.0.20", "classnames": "^2.5.1", "cubic-spline": "^3.0.3", @@ -85,6 +86,7 @@ "devDependencies": { "@babel/preset-env": "^7.24.8", "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@types/classnames": "^2.3.1", "@types/deep-extend": "^0.6.2", diff --git a/packages/webui/src/client/__tests__/jest-setup.cjs b/packages/webui/src/client/__tests__/jest-setup.cjs index bb36d10e75..8856ce9d6a 100644 --- a/packages/webui/src/client/__tests__/jest-setup.cjs +++ b/packages/webui/src/client/__tests__/jest-setup.cjs @@ -1,4 +1,5 @@ /* eslint-disable node/no-unpublished-require */ +require('@testing-library/jest-dom') // used by code creating XML with the DOM API to return an XML string global.XMLSerializer = require('@xmldom/xmldom').XMLSerializer diff --git a/packages/webui/src/client/ui/UserEditOperations/__tests__/PropertiesPanel.test.tsx b/packages/webui/src/client/ui/UserEditOperations/__tests__/PropertiesPanel.test.tsx new file mode 100644 index 0000000000..3e70ccb829 --- /dev/null +++ b/packages/webui/src/client/ui/UserEditOperations/__tests__/PropertiesPanel.test.tsx @@ -0,0 +1,370 @@ +// Mock the ReactiveDataHelper: +jest.mock('../../../lib/reactiveData/ReactiveDataHelper', () => { + class MockReactiveDataHelper { + protected _subs: Array<{ stop: () => void }> = [] + + protected subscribe() { + const sub = { stop: jest.fn() } + this._subs.push(sub) + return sub + } + + protected autorun(f: () => void) { + f() + return { stop: jest.fn() } + } + + destroy() { + this._subs.forEach((sub) => sub.stop()) + this._subs = [] + } + } + + class MockWithManagedTracker extends MockReactiveDataHelper { + constructor() { + super() + } + } + + return { + __esModule: true, + WithManagedTracker: MockWithManagedTracker, + meteorSubscribe: jest.fn().mockReturnValue({ + stop: jest.fn(), + }), + } +}) + +jest.mock('i18next', () => ({ + use: jest.fn().mockReturnThis(), + init: jest.fn().mockImplementation(() => Promise.resolve()), + t: (key: string) => key, + changeLanguage: jest.fn().mockImplementation(() => Promise.resolve()), + language: 'en', + exists: jest.fn(), + on: jest.fn(), + off: jest.fn(), + options: {}, +})) + +// React-i18next with Promise support +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + changeLanguage: jest.fn().mockImplementation(() => Promise.resolve()), + language: 'en', + exists: jest.fn(), + use: jest.fn().mockReturnThis(), + init: jest.fn().mockImplementation(() => Promise.resolve()), + on: jest.fn(), + off: jest.fn(), + options: {}, + }, + }), + initReactI18next: { + type: '3rdParty', + init: jest.fn(), + }, +})) + +import React from 'react' +// eslint-disable-next-line node/no-unpublished-import +import { renderHook, act, render, screen, RenderResult } from '@testing-library/react' +// eslint-disable-next-line node/no-unpublished-import +import '@testing-library/jest-dom' +import { MeteorCall } from '../../../lib/meteorApi' +import { TFunction } from 'i18next' + +import userEvent from '@testing-library/user-event' +import { protectString } from '@sofie-automation/corelib/dist/protectedString' +import { UIParts } from '../../Collections' +import { Segments } from '../../../../client/collections' +import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' +import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' +import { UserEditingType, UserEditingButtonType } from '@sofie-automation/blueprints-integration' +import { SelectedElementProvider, useSelection } from '../../RundownView/SelectedElementsContext' +import { MongoMock } from '../../../../__mocks__/mongo' +import { PropertiesPanel } from '../PropertiesPanel' +import { UserAction } from '../../../lib/clientUserAction' + +const mockSegmentsCollection = MongoMock.getInnerMockCollection(Segments) +const mockPartsCollection = MongoMock.getInnerMockCollection(UIParts) + +// Mock Client User Action: +jest.mock('../../../lib/clientUserAction', () => ({ + doUserAction: jest.fn((_t: TFunction, e: unknown, _action: UserAction, callback: Function) => + callback(e, Date.now()) + ), +})) + +// Mock Userchange Operation: +jest.mock('../../../lib/meteorApi', () => ({ + __esModule: true, + MeteorCall: { + userAction: { + executeUserChangeOperation: jest.fn(), + }, + }, +})) + +// Mock SchemaFormInPlace Component +jest.mock('../../../lib/forms/SchemaFormInPlace', () => ({ + SchemaFormInPlace: () =>
Schema Form
, +})) + +describe('PropertiesPanel', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + beforeEach(() => { + mockSegmentsCollection.remove({}) + mockPartsCollection.remove({}) + jest.clearAllMocks() + }) + + const createMockSegment = (id: string): DBSegment => ({ + _id: protectString(id), + _rank: 1, + name: `Segment ${id}`, + rundownId: protectString('rundown1'), + externalId: `ext_${id}`, + userEditOperations: [ + { + id: 'operation1', + label: { key: 'TEST_LABEL' }, + type: UserEditingType.ACTION, + buttonType: UserEditingButtonType.SWITCH, + isActive: false, + }, + ], + }) + + const createMockPart = (id: string, segmentId: string): DBPart => ({ + _id: protectString(id), + _rank: 1, + expectedDurationWithTransition: 0, + title: `Part ${id}`, + rundownId: protectString('rundown1'), + segmentId: protectString(segmentId), + externalId: `ext_${id}`, + userEditOperations: [ + { + id: 'operation2', + label: { key: 'TEST_PART_LABEL' }, + type: UserEditingType.ACTION, + buttonType: UserEditingButtonType.BUTTON, + isActive: true, + }, + ], + }) + + test('renders empty when no element selected', () => { + const { container } = render(, { wrapper }) + expect(container.querySelector('.properties-panel')).toBeTruthy() + expect(container.querySelector('.propertiespanel-pop-up__contents')).toBeFalsy() + }) + + test('renders segment properties when segment is selected', async () => { + const mockSegment = createMockSegment('segment1') + mockSegmentsCollection.insert(mockSegment) + + // Create a custom wrapper that includes both providers + const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} + ) + + // Render both the hook and component in the same provider tree + const { result } = renderHook(() => useSelection(), { wrapper: TestWrapper }) + let rendered: RenderResult + + await act(async () => { + rendered = render(, { wrapper: TestWrapper }) + }) + + // Update selection + await act(async () => { + result.current.clearAndSetSelection({ + type: 'segment', + elementId: mockSegment._id, + }) + }) + //@ts-expect-error error because avoiding an undefined type + if (!rendered) throw new Error('Component not rendered') + + // Force a rerender + await act(async () => { + rendered.rerender() + }) + + // Wait for the header element to appear + await screen.findByText('SEGMENT : Segment segment1') + + const header = rendered.container.querySelector('.propertiespanel-pop-up__header') + const switchButton = rendered.container.querySelector('.propertiespanel-pop-up__switchbutton') + + expect(header).toHaveTextContent('SEGMENT : Segment segment1') + expect(switchButton).toBeTruthy() + }) + + test('renders part properties when part is selected', async () => { + const mockSegment = createMockSegment('segment1') + const mockPart = createMockPart('part1', String(mockSegment._id)) + + mockSegmentsCollection.insert(mockSegment) + mockPartsCollection.insert(mockPart) + + // Create a custom wrapper that includes both providers + const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} + ) + + // Render both the hook and component in the same provider tree + const { result } = renderHook(() => useSelection(), { wrapper: TestWrapper }) + let rendered: RenderResult + + await act(async () => { + rendered = render(, { wrapper: TestWrapper }) + }) + + // Update selection + await act(async () => { + result.current.clearAndSetSelection({ + type: 'part', + elementId: mockPart._id, + }) + }) + + //@ts-expect-error error because avoiding an undefined type + if (!rendered) throw new Error('Component not rendered') + + // Force a rerender + await act(async () => { + rendered.rerender() + }) + + // Wait for the header element to appear + await screen.findByText('PART : Part part1') + + const header = rendered.container.querySelector('.propertiespanel-pop-up__header') + const button = rendered.container.querySelector('.propertiespanel-pop-up__button') + + expect(header).toHaveTextContent('PART : Part part1') + expect(button).toBeTruthy() + }) + + test('handles user edit operations for segments', async () => { + const mockSegment = createMockSegment('segment1') + mockSegmentsCollection.insert(mockSegment) + + // First render the selection hook + const { result } = renderHook(() => useSelection(), { wrapper }) + + // Then render the properties panel + const { container } = render(, { wrapper }) + + // Update selection using the hook result + act(() => { + result.current.clearAndSetSelection({ + type: 'segment', + elementId: mockSegment._id, + }) + }) + + const switchButton = container.querySelector('.propertiespanel-pop-up__switchbutton') + expect(switchButton).toBeTruthy() + + // Toggle the switch + await userEvent.click(switchButton!) + + // Check if commit button is enabled + const commitButton = screen.getByText('COMMIT CHANGES') + expect(commitButton).toBeEnabled() + + // Commit changes + await userEvent.click(commitButton) + + expect(MeteorCall.userAction.executeUserChangeOperation).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + protectString('rundown1'), + { + segmentExternalId: mockSegment.externalId, + partExternalId: undefined, + pieceExternalId: undefined, + }, + { + id: 'operation1', + values: undefined, + } + ) + }) + + test('handles revert changes', async () => { + const mockSegment = createMockSegment('segment1') + mockSegmentsCollection.insert(mockSegment) + + // First render the selection hook + const { result } = renderHook(() => useSelection(), { wrapper }) + + // Then render the properties panel + const { container } = render(, { wrapper }) + + // Update selection using the hook result + act(() => { + result.current.clearAndSetSelection({ + type: 'segment', + elementId: mockSegment._id, + }) + }) + + // Make a change + const switchButton = container.querySelector('.propertiespanel-pop-up__switchbutton') + await userEvent.click(switchButton!) + + // Click revert button + const revertButton = screen.getByText('REVERT CHANGES') + await userEvent.click(revertButton) + + expect(MeteorCall.userAction.executeUserChangeOperation).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + protectString('rundown1'), + { + segmentExternalId: mockSegment.externalId, + partExternalId: undefined, + pieceExternalId: undefined, + }, + { + id: 'REVERT_SEGMENT', + } + ) + }) + + test('closes panel when close button is clicked', async () => { + const mockSegment = createMockSegment('segment1') + mockSegmentsCollection.insert(mockSegment) + + // First render the selection hook + const { result } = renderHook(() => useSelection(), { wrapper }) + + // Then render the properties panel + const { container } = render(, { wrapper }) + + // Update selection using the hook result + act(() => { + result.current.clearAndSetSelection({ + type: 'segment', + elementId: mockSegment._id, + }) + }) + + const closeButton = container.querySelector('.propertiespanel-pop-up_close') + expect(closeButton).toBeTruthy() + + await userEvent.click(closeButton!) + + expect(container.querySelector('.propertiespanel-pop-up__contents')).toBeFalsy() + }) +}) diff --git a/packages/yarn.lock b/packages/yarn.lock index 3b52f39466..538e14a02a 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -23,6 +23,13 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.4.0": + version: 4.4.0 + resolution: "@adobe/css-tools@npm:4.4.0" + checksum: 1f08fb49bf17fc7f2d1a86d3e739f29ca80063d28168307f1b0a962ef37501c5667271f6771966578897f2e94e43c4770fd802728a6e6495b812da54112d506a + languageName: node + linkType: hard + "@algolia/autocomplete-core@npm:1.9.3": version: 1.9.3 resolution: "@algolia/autocomplete-core@npm:1.9.3" @@ -5315,7 +5322,9 @@ __metadata: "@sofie-automation/shared-lib": 1.52.0-in-development "@sofie-automation/sorensen": ^1.4.3 "@testing-library/dom": ^10.4.0 + "@testing-library/jest-dom": ^6.6.3 "@testing-library/react": ^16.0.1 + "@testing-library/user-event": ^14.5.2 "@types/classnames": ^2.3.1 "@types/deep-extend": ^0.6.2 "@types/react": ^18.3.3 @@ -5915,6 +5924,21 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^6.6.3": + version: 6.6.3 + resolution: "@testing-library/jest-dom@npm:6.6.3" + dependencies: + "@adobe/css-tools": ^4.4.0 + aria-query: ^5.0.0 + chalk: ^3.0.0 + css.escape: ^1.5.1 + dom-accessibility-api: ^0.6.3 + lodash: ^4.17.21 + redent: ^3.0.0 + checksum: c1dc4260b05309a0084416639006cd105849acc5b102bef682a3b19bd6fce07ff6762085fc7f2599546c995a2fc66fdb1d70e50e22a634a0098524056cc9e511 + languageName: node + linkType: hard + "@testing-library/react@npm:^16.0.1": version: 16.0.1 resolution: "@testing-library/react@npm:16.0.1" @@ -5935,6 +5959,15 @@ __metadata: languageName: node linkType: hard +"@testing-library/user-event@npm:^14.5.2": + version: 14.5.2 + resolution: "@testing-library/user-event@npm:14.5.2" + peerDependencies: + "@testing-library/dom": ">=7.21.4" + checksum: d76937dffcf0082fbf3bb89eb2b81a31bf5448048dd61c33928c5f10e33a58e035321d39145cefd469bb5a499c68a5b4086b22f1a44e3e7c7e817dc5f6782867 + languageName: node + linkType: hard + "@tokenizer/token@npm:^0.3.0": version: 0.3.0 resolution: "@tokenizer/token@npm:0.3.0" @@ -7848,6 +7881,13 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:^5.0.0": + version: 5.3.2 + resolution: "aria-query@npm:5.3.2" + checksum: d971175c85c10df0f6d14adfe6f1292409196114ab3c62f238e208b53103686f46cc70695a4f775b73bc65f6a09b6a092fd963c4f3a5a7d690c8fc5094925717 + languageName: node + linkType: hard + "array-buffer-byte-length@npm:^1.0.1": version: 1.0.1 resolution: "array-buffer-byte-length@npm:1.0.1" @@ -9198,6 +9238,16 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"chalk@npm:^3.0.0": + version: 3.0.0 + resolution: "chalk@npm:3.0.0" + dependencies: + ansi-styles: ^4.1.0 + supports-color: ^7.1.0 + checksum: 8e3ddf3981c4da405ddbd7d9c8d91944ddf6e33d6837756979f7840a29272a69a5189ecae0ff84006750d6d1e92368d413335eab4db5476db6e6703a1d1e0505 + languageName: node + linkType: hard + "char-regex@npm:^1.0.2": version: 1.0.2 resolution: "char-regex@npm:1.0.2" @@ -10475,6 +10525,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: f6d38088d870a961794a2580b2b2af1027731bb43261cfdce14f19238a88664b351cc8978abc20f06cc6bbde725699dec8deb6fe9816b139fc3f2af28719e774 + languageName: node + linkType: hard + "cssesc@npm:^3.0.0": version: 3.0.0 resolution: "cssesc@npm:3.0.0" @@ -11614,6 +11671,13 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: c325b5144bb406df23f4affecffc117dbaec9af03daad9ee6b510c5be647b14d28ef0a4ea5ca06d696d8ab40bb777e5fed98b985976fdef9d8790178fa1d573f + languageName: node + linkType: hard + "dom-converter@npm:^0.2.0": version: 0.2.0 resolution: "dom-converter@npm:0.2.0"