mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-27 12:04:56 +02:00
* Undo-redo for screenshot drawer, several bugfixes. Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Undo-redo for collaborative drawer. Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Drawing board refinement: bugfixes, component refactoring and unit testing Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * More tests, canvas resize fix, types for different points Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Version lock Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Names refactoring Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Formatting Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Formatting Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> --------- Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
//
|
|
// Copyright © 2025 Hardcore Engineering Inc.
|
|
//
|
|
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License. You may
|
|
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
//
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
import '@testing-library/jest-dom'
|
|
|
|
import { MenuBuilder } from '../../ui/titleBarMenu'
|
|
import { MenuBarAction } from '../../ui/types'
|
|
|
|
describe('MenuBuilder', () => {
|
|
let systemUnderTest: MenuBuilder
|
|
|
|
beforeEach(() => {
|
|
systemUnderTest = new MenuBuilder()
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
function verifyTopLevelMenu (menuBar: HTMLElement, topLevelMenu: number, expectedMenuName: string, expectedAccelerator: string): void {
|
|
const menuButton = menuBar.children[topLevelMenu].children[0] as HTMLElement
|
|
expect(menuButton.dataset.menu).toBe(expectedMenuName)
|
|
expect(menuButton.dataset.accelerator).toBe(expectedAccelerator)
|
|
}
|
|
|
|
describe('addTopLevelMenu', () => {
|
|
test('add a top-level menu', () => {
|
|
systemUnderTest.addTopLevelMenu('File', 'f')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
expect(builtMenu.children).toHaveLength(1)
|
|
verifyTopLevelMenu(builtMenu, 0, 'file', 'f')
|
|
})
|
|
|
|
test('method chaining', () => {
|
|
const result = systemUnderTest
|
|
.addTopLevelMenu('File', 'f')
|
|
.addTopLevelMenu('Edit', 'e')
|
|
|
|
expect(result).toBe(systemUnderTest)
|
|
})
|
|
|
|
test('add multiple top-level menus', () => {
|
|
systemUnderTest
|
|
.addTopLevelMenu('File', 'f')
|
|
.addTopLevelMenu('Edit', 'e')
|
|
.addTopLevelMenu('View', 'v')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
expect(builtMenu.children).toHaveLength(3)
|
|
verifyTopLevelMenu(builtMenu, 0, 'file', 'f')
|
|
verifyTopLevelMenu(builtMenu, 1, 'edit', 'e')
|
|
verifyTopLevelMenu(builtMenu, 2, 'view', 'v')
|
|
})
|
|
})
|
|
|
|
describe('addMenuItem', () => {
|
|
beforeEach(() => {
|
|
systemUnderTest.addTopLevelMenu('File', 'f')
|
|
})
|
|
|
|
test('add a menu item to existing top-level menu', () => {
|
|
systemUnderTest.addMenuItem(0, 'New', 'redo', 'Ctrl+N')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdown = builtMenu.children[0].children[1]
|
|
expect(dropdown.children).toHaveLength(1)
|
|
|
|
const menuItem = dropdown.children[0] as HTMLElement
|
|
expect(menuItem.dataset.action).toBe('redo')
|
|
})
|
|
|
|
test('add menu item with custom accelerator character', () => {
|
|
systemUnderTest.addMenuItem(0, 'Save As', 'select-all', 'Ctrl+Shift+S', 'a')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdown = builtMenu.children[0].children[1]
|
|
const menuItem = dropdown.children[0] as HTMLElement
|
|
expect(menuItem.dataset.accelerator).toBe('a')
|
|
})
|
|
|
|
test('default accelerator', () => {
|
|
systemUnderTest.addMenuItem(0, 'Open', 'undo', 'Ctrl+O')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdown = builtMenu.children[0].children[1]
|
|
const menuItem = dropdown.children[0] as HTMLElement
|
|
expect(menuItem.dataset.accelerator).toBe('o')
|
|
})
|
|
|
|
test.each([[999], [-1]])('invalid top-level menu index', (input: number) => {
|
|
systemUnderTest.addMenuItem(input, 'Invalid', 'invalid' as MenuBarAction, 'Ctrl+I')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdown = builtMenu.children[0].children[1]
|
|
expect(dropdown.children).toHaveLength(0)
|
|
})
|
|
|
|
test('add multiple menu items to the same top-level menu', () => {
|
|
systemUnderTest
|
|
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
|
|
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O')
|
|
.addMenuItem(0, 'Save', 'copy', 'Ctrl+S')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdown = builtMenu.children[0].children[1]
|
|
expect(dropdown.children).toHaveLength(3)
|
|
})
|
|
})
|
|
|
|
describe('addSeparator', () => {
|
|
beforeEach(() => {
|
|
systemUnderTest.addTopLevelMenu('File', 'f')
|
|
})
|
|
|
|
test('add a separator', () => {
|
|
systemUnderTest.addSeparator(0)
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdown = builtMenu.children[0].children[1]
|
|
expect(dropdown.children).toHaveLength(1)
|
|
expect(dropdown.children[0]).toHaveClass('desktop-app-dropdown-separator')
|
|
})
|
|
|
|
test.each([[999], [-1]])('invalid top-level menu index', (input: number) => {
|
|
systemUnderTest.addSeparator(999)
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdown = builtMenu.children[0].children[1]
|
|
expect(dropdown.children).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('build', () => {
|
|
test('build empty menu bar', () => {
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
expect(builtMenu).toHaveClass('desktop-app-menu-bar')
|
|
expect(builtMenu.children).toHaveLength(0)
|
|
})
|
|
|
|
test('menu bar with complex structure of a single top-level menu', () => {
|
|
const topLevelMenu = 0
|
|
systemUnderTest
|
|
.addTopLevelMenu('File', 'f')
|
|
.addMenuItem(topLevelMenu, 'New', 'paste', 'Ctrl+N')
|
|
.addSeparator(topLevelMenu)
|
|
.addMenuItem(topLevelMenu, 'Exit', 'exit', 'Alt+F4')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
expect(builtMenu).toHaveClass('desktop-app-menu-bar')
|
|
expect(builtMenu.children).toHaveLength(1)
|
|
|
|
const topLevelMenuItem = builtMenu.children[0]
|
|
expect(topLevelMenuItem).toHaveClass('desktop-app-menu-item')
|
|
expect(topLevelMenuItem.children).toHaveLength(2)
|
|
|
|
const topButton = topLevelMenuItem.children[0]
|
|
expect(topButton).toHaveClass('desktop-app-top-menu-button')
|
|
|
|
const dropdown = topLevelMenuItem.children[1]
|
|
expect(dropdown).toHaveClass('desktop-app-dropdown-menu')
|
|
expect(dropdown.id).toBe('file-menu')
|
|
expect(dropdown.children).toHaveLength(3) // New, separator, Exit
|
|
})
|
|
|
|
test('accelerator at the beginning of label', () => {
|
|
systemUnderTest.addTopLevelMenu('File', 'f')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const topButton = builtMenu.children[0].children[0]
|
|
expect(topButton.textContent).toBe('File')
|
|
|
|
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator')
|
|
expect(acceleratorSpan).toBeTruthy()
|
|
expect(acceleratorSpan?.textContent).toBe('F')
|
|
})
|
|
|
|
test('accelerator in the middle of label', () => {
|
|
systemUnderTest.addTopLevelMenu('Edit', 'i') // 'i' is in the middle of 'Edit'
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const topButton = builtMenu.children[0].children[0]
|
|
expect(topButton.textContent).toBe('Edit')
|
|
|
|
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator')
|
|
expect(acceleratorSpan).toBeTruthy()
|
|
expect(acceleratorSpan?.textContent).toBe('i')
|
|
})
|
|
|
|
test('accelerator not found in label', () => {
|
|
systemUnderTest.addTopLevelMenu('File', 'z') // 'z' is not in 'File'
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const topButton = builtMenu.children[0].children[0]
|
|
expect(topButton.textContent).toBe('File')
|
|
expect(topButton.querySelector('.desktop-app-accelerator')).toBeNull()
|
|
})
|
|
|
|
test('dropdown items with shortcuts', () => {
|
|
systemUnderTest
|
|
.addTopLevelMenu('File', 'f')
|
|
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement
|
|
expect(dropdownItem).toHaveClass('desktop-app-dropdown-item')
|
|
expect(dropdownItem.dataset.action).toBe('paste')
|
|
|
|
const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut')
|
|
expect(shortcutSpan).toBeTruthy()
|
|
expect(shortcutSpan?.textContent).toBe('Ctrl+N')
|
|
})
|
|
|
|
test('without shortcuts when empty', () => {
|
|
systemUnderTest
|
|
.addTopLevelMenu('File', 'f')
|
|
.addMenuItem(0, 'New', 'paste', '')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdownItem = builtMenu.children[0].children[1].children[0]
|
|
const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut')
|
|
expect(shortcutSpan).toBeNull()
|
|
})
|
|
|
|
test('accelerator characters in dropdown items', () => {
|
|
systemUnderTest
|
|
.addTopLevelMenu('File', 'f')
|
|
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
|
|
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O', 'p')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdown = builtMenu.children[0].children[1]
|
|
const newItem = dropdown.children[0] as HTMLElement
|
|
const openItem = dropdown.children[1] as HTMLElement
|
|
|
|
expect(newItem.dataset.accelerator).toBe('n')
|
|
expect(openItem.dataset.accelerator).toBe('p')
|
|
|
|
const newAccelerator = newItem.querySelector('.desktop-app-accelerator')
|
|
const openAccelerator = openItem.querySelector('.desktop-app-accelerator')
|
|
|
|
expect(newAccelerator?.textContent).toBe('N')
|
|
expect(openAccelerator?.textContent).toBe('p')
|
|
})
|
|
|
|
test('complex menu structure', () => {
|
|
systemUnderTest
|
|
.addTopLevelMenu('File', 'f')
|
|
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
|
|
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O')
|
|
.addSeparator(0)
|
|
.addMenuItem(0, 'Save', 'cut', 'Ctrl+S')
|
|
.addMenuItem(0, 'Save As', 'cut', 'Ctrl+Shift+S')
|
|
.addSeparator(0)
|
|
.addMenuItem(0, 'Exit', 'cut', 'Alt+F4')
|
|
.addTopLevelMenu('Edit', 'e')
|
|
.addMenuItem(1, 'Undo', 'cut', 'Ctrl+Z')
|
|
.addMenuItem(1, 'Redo', 'cut', 'Ctrl+Y')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
// Should have 2 top-level menus
|
|
expect(builtMenu.children).toHaveLength(2)
|
|
|
|
// File menu should have 7 items (5 menu items + 2 separators)
|
|
const fileDropdown = builtMenu.children[0].children[1]
|
|
expect(fileDropdown.children).toHaveLength(7)
|
|
|
|
// Edit menu should have 2 items
|
|
const editDropdown = builtMenu.children[1].children[1]
|
|
expect(editDropdown.children).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
test('empty labels', () => {
|
|
systemUnderTest
|
|
.addTopLevelMenu('', 'f')
|
|
.addMenuItem(0, '', 'cut', 'shortcut')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
expect(builtMenu.children).toHaveLength(1)
|
|
|
|
const dropdown = builtMenu.children[0].children[1]
|
|
expect(dropdown).toHaveClass('desktop-app-dropdown-menu')
|
|
|
|
expect(dropdown.children).toHaveLength(1)
|
|
})
|
|
|
|
test('special characters in labels', () => {
|
|
systemUnderTest
|
|
.addTopLevelMenu('File & Edit', 'f')
|
|
.addMenuItem(0, 'Save & Exit', 'cut', 'Ctrl+S')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
expect(builtMenu.children).toHaveLength(1)
|
|
|
|
const topButton = builtMenu.children[0].children[0]
|
|
expect(topButton.textContent).toBe('File & Edit')
|
|
})
|
|
|
|
test('case-insensitive accelerator matching', () => {
|
|
systemUnderTest
|
|
.addTopLevelMenu('File', 'F') // uppercase F
|
|
.addMenuItem(0, 'New', 'paste', 'Ctrl+N', 'N') // uppercase N
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement
|
|
expect(dropdownItem.dataset.accelerator).toBe('n')
|
|
|
|
const topButton = builtMenu.children[0].children[0] as HTMLElement
|
|
expect(topButton.dataset.accelerator).toBe('f')
|
|
|
|
// Check that the actual character from the label is preserved
|
|
const topAccelerator = topButton.querySelector('.desktop-app-accelerator')
|
|
const itemAccelerator = dropdownItem.querySelector('.desktop-app-accelerator')
|
|
|
|
expect(topAccelerator?.textContent).toBe('F')
|
|
expect(itemAccelerator?.textContent).toBe('N')
|
|
})
|
|
|
|
test('unicode characters', () => {
|
|
systemUnderTest
|
|
.addTopLevelMenu('Файл', 'ф')
|
|
.addMenuItem(0, 'Новый', 'undo', 'Ctrl+N')
|
|
|
|
const builtMenu = systemUnderTest.build()
|
|
|
|
const topButton = builtMenu.children[0].children[0]
|
|
expect(topButton.textContent).toBe('Файл')
|
|
|
|
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator')
|
|
expect(acceleratorSpan?.textContent).toBe('Ф')
|
|
})
|
|
})
|
|
})
|