Загрузка данных


diff --git a/.gitignore b/.gitignore
index aedbf73fca709713e1920f151c064d7c5c89d5c2..81032cd0664cf01d0646f2decaf1f8b0627f6d1b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ storybook-static
 .env.local
 .yalc
 yalc.lock
+coverage/
+.scannerwork/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 980c1aaa172272f1e5a281ddf62069c127054e67..d6f3b77b635eb8ee1a6c42a74f3a352157a66b87 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -24,6 +24,7 @@ include:
 
 stages:
   - build
+  - test
   - sast
   - publish
 
@@ -43,7 +44,7 @@ build:
     - |
       npm config set loglevel verbose
       npm config set ignore-scripts true
-      rm package-lock.json
+      rm -f package-lock.json
 
       VERSION=${CI_COMMIT_TAG:-"0.0.0"}
       npm version $VERSION --no-git-tag-version || true
@@ -62,6 +63,26 @@ build:
     - if: $CI_PIPELINE_SOURCE == 'merge_request_event' && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH
     - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
 
+test:
+  stage: test
+  image: moex-docker-static-hosted-nexus-dev.tech.moex.com/node:22.11.0-bookworm-jq
+  script:
+    - |
+      npm config set loglevel verbose
+      npm config set ignore-scripts true
+
+      npm install
+      npm run coverage
+
+      test -f coverage/lcov.info
+  artifacts:
+    paths:
+      - coverage/lcov.info
+    expire_in: 1 day
+  rules:
+    - if: $CI_PIPELINE_SOURCE == 'merge_request_event' && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+
 publish:
   stage: publish
   image: moex-docker-static-hosted-nexus-dev.tech.moex.com/node:22.11.0-bookworm-jq
@@ -73,11 +94,12 @@ publish:
     - |
       cd pack-result
       TGZ_FILE=$(ls -t ${PACKAGE_NAME}-*.tgz 2>/dev/null | head -n 1)
-      curl -v -H "Authorization: Basic ${DOP_NPM_NEXUS_TOKEN}" \
+      curl -v \
+        -H "Authorization: Basic ${DOP_NPM_NEXUS_TOKEN}" \
         -X POST \
         "https://nexus-dev.tech.moex.com/service/rest/v1/components?repository=trade-radar-npm-private-hosted" \
         -F "npm.asset=@./${TGZ_FILE};type=application/octet-stream" \
         --fail
   rules:
     - if: $CI_COMMIT_TAG
-      when: manual
\ No newline at end of file
+      when: manual
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa8198b8d229faf97c90cba0bddebae3550de1ee..74a642bc597b9a430024fe54eb400511305f21b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # latest
 
+- Исправлен баг, при котором элементы графика перекрывали шапку сайта moex
+
+# 0.1.22
+
 - Добавлен индикатор RSI
 - Исправлен баг, когда линия EMA уходила в будущее
 - Исправлен баг, при котором при завершении построения траектории добавлялась лишняя точка
diff --git a/jest.config.ts b/jest.config.ts
index d4450712a110219fa4c470b968e83ee0b221f078..f1af148e43fe95b67da6c9f33b97db47adda3a8c 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -7,9 +7,8 @@ import { pathsToModuleNameMapper } from 'ts-jest';
 
 import type { Config } from '@jest/types';
 
-
-const fileContent = readFileSync('./tsconfig.json')
-const fileContentJSON = JSON5.parse(fileContent.toString())
+const fileContent = readFileSync('./tsconfig.json');
+const fileContentJSON = JSON5.parse(fileContent.toString());
 
 const config: Config.InitialOptions = {
   // нужен пакет, который поддержит резолв scss модуля
@@ -28,7 +27,9 @@ const config: Config.InitialOptions = {
   moduleNameMapper: {
     ...pathsToModuleNameMapper(fileContentJSON.compilerOptions.paths),
     '\\.(css|scss|module.scss)$': '<rootDir>/__mocks__/styleMock.js',
-  }
-}
+  },
+  coverageDirectory: '<rootDir>/coverage',
+  coverageReporters: ['text', 'lcov'],
+};
 
-export default config
+export default config;
diff --git a/package.json b/package.json
index 7c47d5910430ef20cb536ab71acd875fa52ca8de..d13259a4ae9eb954f81a5fde9abe618aba4ee91e 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
     "build:lib:types": "tsc --project tsconfig.types.json && tsc-alias -p tsconfig.types.json",
     "build:lib": "npm run clean:dist && npm run clean:types && npm run build:lib:js && npm run build:lib:types",
     "test": "jest",
+    "test:related": "jest --findRelatedTests --passWithNoTests",
     "coverage": "jest --coverage",
     "lint:fix": "npx prettier src --write && npx eslint src --fix",
     "lint": "npx eslint src",
@@ -157,7 +158,11 @@
     "@storybook/mdx2-csf": "1.0.0"
   },
   "lint-staged": {
-    "*.{js,jsx,ts,tsx,css,scss,md,html}": [
+    "*.{ts,tsx}": [
+      "npm run lint:fix",
+      "npm run test:related --"
+    ],
+    "*.{js,jsx,css,scss,md,html}": [
       "npm run lint:fix"
     ]
   }
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 0000000000000000000000000000000000000000..e9343cdd6bb64b22a153a9cd0475adcaaf004bab
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,6 @@
+sonar.sources=src
+sonar.tests=src
+sonar.test.inclusions=**/*.test.ts,**/*.test.tsx
+sonar.coverage.exclusions=**/*.test.ts,**/*.test.tsx,**/*.d.ts,**/*.stories.ts,**/*.stories.tsx,jest.config.ts
+sonar.cpd.exclusions=**/*.test.ts,**/*.test.tsx,**/*.mock.ts,**/*.mock.tsx,**/*.stories.ts,**/*.stories.tsx
+sonar.javascript.lcov.reportPaths=coverage/lcov.info
diff --git a/src/components/Dropdown/index.module.scss b/src/components/Dropdown/index.module.scss
index b4b6fac69fd62e0a5a9b3db1979d3258a1431816..b531f637de8ab438fc3b4f407330276c882ef4d6 100644
--- a/src/components/Dropdown/index.module.scss
+++ b/src/components/Dropdown/index.module.scss
@@ -11,6 +11,8 @@ $menu-width: 200px;
 }
 
 .menu {
+  @include m.scrollbar;
+
   position: fixed;
   box-sizing: border-box;
   width: var(--dropdown-width, $menu-width);
diff --git a/src/core/ContainerManager.ts b/src/core/ContainerManager.ts
index b0be74ef0b2922a670b2b0bb44f2519ef16a5c21..0e1f93b2d15ba4c04a98961ff36a39e8badb23f0 100644
--- a/src/core/ContainerManager.ts
+++ b/src/core/ContainerManager.ts
@@ -15,9 +15,10 @@ interface CreateContainersOptions {
 
 enum ZIndex {
   Chart = '0',
-  Base = '10',
-  Floating = '20',
-  Modal = '1000',
+  Base = '1',
+  Floating = '2',
+  Legend = '3',
+  Modal = '100',
 }
 
 const ContainerLayoutConfig = {
@@ -176,7 +177,7 @@ export class ContainerManager {
     legendContainer.style.position = 'absolute';
     legendContainer.style.top = '0';
     legendContainer.style.left = '0';
-    legendContainer.style.zIndex = ZIndex.Base;
+    legendContainer.style.zIndex = ZIndex.Legend;
     legendContainer.style.pointerEvents = 'none';
 
     const paneOverlayContainer = document.createElement('div');
diff --git a/src/core/__tests__/ContainerManager.test.ts b/src/core/__tests__/ContainerManager.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ca0b7f77839ba03aca54d4eebc9294f6c457b4d5
--- /dev/null
+++ b/src/core/__tests__/ContainerManager.test.ts
@@ -0,0 +1,200 @@
+import {
+  CHART_DRAWING_TOOLBAR_CONTAINER,
+  CHART_MODAL_CONTAINER_CLASSNAME,
+  CHART_PANE_OVERLAY_CONTAINER,
+  CHART_ROOT_CLASSNAME,
+} from '@src/constants';
+
+import { ContainerManager } from '../ContainerManager';
+
+jest.mock('@src/constants', () => ({
+  CHART_DRAWING_TOOLBAR_CONTAINER: 'chart-drawing-toolbar-container',
+  CHART_MODAL_CONTAINER_CLASSNAME: 'chart-modal-container',
+  CHART_PANE_OVERLAY_CONTAINER: 'chart-pane-overlay-container',
+  CHART_ROOT_CLASSNAME: 'chart-root',
+}));
+
+describe('ContainerManager', () => {
+  let parentContainer: HTMLDivElement;
+
+  beforeEach(() => {
+    document.querySelectorAll('link[href*="fonts.googleapis.com"]').forEach((link) => link.remove());
+
+    parentContainer = document.createElement('div');
+    document.body.appendChild(parentContainer);
+  });
+
+  afterEach(() => {
+    parentContainer.remove();
+  });
+
+  describe('createContainers', () => {
+    it('должен создавать основные контейнеры графика с корректной структурой', () => {
+      const {
+        headerContainer,
+        footerContainer,
+        chartContainer,
+        chartAreaContainer,
+        toolBarContainer,
+        modalContainer,
+        controlBarContainer,
+        drawingToolbarContainer,
+      } = ContainerManager.createContainers({
+        parentContainer,
+        showBottomPanel: true,
+        showMenuButton: true,
+      });
+
+      expect(parentContainer.classList.contains(CHART_ROOT_CLASSNAME)).toBe(true);
+
+      expect(parentContainer.children[0]).toBe(headerContainer);
+      expect(parentContainer.children[1]).toBe(chartContainer);
+      expect(parentContainer.children[2]).toBe(footerContainer);
+
+      expect(chartContainer.contains(toolBarContainer)).toBe(true);
+      expect(chartContainer.contains(chartAreaContainer)).toBe(true);
+      expect(chartContainer.contains(modalContainer)).toBe(true);
+
+      expect(chartAreaContainer.contains(controlBarContainer)).toBe(true);
+      expect(chartAreaContainer.contains(drawingToolbarContainer)).toBe(true);
+
+      expect(drawingToolbarContainer.classList.contains(CHART_DRAWING_TOOLBAR_CONTAINER)).toBe(true);
+
+      expect(modalContainer.classList.contains(CHART_MODAL_CONTAINER_CLASSNAME)).toBe(true);
+
+      expect(modalContainer.hidden).toBe(true);
+    });
+
+    it('должен создавать сетку с нижней панелью', () => {
+      ContainerManager.createContainers({
+        parentContainer,
+        showBottomPanel: true,
+        showMenuButton: true,
+      });
+
+      expect(parentContainer.style.gridTemplateRows).toBe('36px minmax(0, 1fr) 32px');
+    });
+
+    it('должен создавать сетку без нижней панели', () => {
+      const { footerContainer } = ContainerManager.createContainers({
+        parentContainer,
+        showBottomPanel: false,
+        showMenuButton: true,
+      });
+
+      expect(parentContainer.style.gridTemplateRows).toBe('36px minmax(0, 1fr)');
+      expect(parentContainer.contains(footerContainer)).toBe(false);
+    });
+
+    it('должен отображать панель инструментов при включённой кнопке меню', () => {
+      const { chartContainer, toolBarContainer } = ContainerManager.createContainers({
+        parentContainer,
+        showMenuButton: true,
+      });
+
+      expect(chartContainer.contains(toolBarContainer)).toBe(true);
+      expect(chartContainer.style.gridTemplateColumns).toBe('42px minmax(0, 1fr)');
+    });
+
+    it('должен скрывать панель инструментов при выключенной кнопке меню', () => {
+      const { chartContainer, toolBarContainer } = ContainerManager.createContainers({
+        parentContainer,
+        showMenuButton: false,
+      });
+
+      expect(chartContainer.contains(toolBarContainer)).toBe(false);
+      expect(chartContainer.style.gridTemplateColumns).toBe('minmax(0, 1fr)');
+    });
+
+    it('должен переключать видимость панели инструментов', () => {
+      const { chartContainer, toolBarContainer, toggleToolbar } = ContainerManager.createContainers({
+        parentContainer,
+        showMenuButton: true,
+      });
+
+      expect(toggleToolbar()).toBe(false);
+      expect(chartContainer.contains(toolBarContainer)).toBe(false);
+      expect(chartContainer.style.gridTemplateColumns).toBe('minmax(0, 1fr)');
+
+      expect(toggleToolbar()).toBe(true);
+      expect(chartContainer.contains(toolBarContainer)).toBe(true);
+      expect(chartContainer.style.gridTemplateColumns).toBe('42px minmax(0, 1fr)');
+    });
+
+    it('должен удалять старое содержимое родительского контейнера перед созданием графика', () => {
+      const oldElement = document.createElement('div');
+
+      parentContainer.appendChild(oldElement);
+
+      ContainerManager.createContainers({
+        parentContainer,
+        showMenuButton: true,
+      });
+
+      expect(parentContainer.contains(oldElement)).toBe(false);
+    });
+
+    it('должен добавлять шрифт только один раз', () => {
+      ContainerManager.createContainers({
+        parentContainer,
+        showMenuButton: true,
+      });
+
+      ContainerManager.createContainers({
+        parentContainer,
+        showMenuButton: true,
+      });
+
+      const fontLinks = document.querySelectorAll('link[href*="fonts.googleapis.com"]');
+
+      expect(fontLinks).toHaveLength(1);
+      expect(fontLinks[0]?.getAttribute('href')).toBe(
+        'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap',
+      );
+    });
+  });
+
+  describe('createPaneContainers', () => {
+    it('должен создавать контейнеры легенды и оверлея панели', () => {
+      const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
+
+      expect(legendContainer.style.position).toBe('absolute');
+      expect(legendContainer.style.pointerEvents).toBe('none');
+
+      expect(paneOverlayContainer.classList.contains(CHART_PANE_OVERLAY_CONTAINER)).toBe(true);
+
+      expect(paneOverlayContainer.style.position).toBe('absolute');
+      expect(paneOverlayContainer.style.pointerEvents).toBe('none');
+    });
+
+    it('должен размещать слои графика в корректном порядке', () => {
+      const { controlBarContainer, drawingToolbarContainer, modalContainer } = ContainerManager.createContainers({
+        parentContainer,
+        showMenuButton: true,
+      });
+
+      const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
+
+      const controlBarZIndex = Number(controlBarContainer.style.zIndex);
+      const paneOverlayZIndex = Number(paneOverlayContainer.style.zIndex);
+      const drawingZIndex = Number(drawingToolbarContainer.style.zIndex);
+      const legendZIndex = Number(legendContainer.style.zIndex);
+      const modalZIndex = Number(modalContainer.style.zIndex);
+
+      expect(controlBarZIndex).toBe(paneOverlayZIndex);
+      expect(drawingZIndex).toBeGreaterThan(paneOverlayZIndex);
+      expect(legendZIndex).toBeGreaterThan(drawingZIndex);
+      expect(modalZIndex).toBeGreaterThan(legendZIndex);
+    });
+  });
+
+  describe('clearContainers', () => {
+    it('должен очищать содержимое родительского контейнера', () => {
+      parentContainer.innerHTML = '<div>content</div>';
+
+      ContainerManager.clearContainers(parentContainer);
+
+      expect(parentContainer.innerHTML).toBe('');
+    });
+  });
+});
diff --git a/src/core/styles.module.scss b/src/core/styles.module.scss
index 7de23753e704e4942e61710017a3be10494b84f1..685cbe25d7b51cc64a0c94cc11893cdca7ae91f1 100644
--- a/src/core/styles.module.scss
+++ b/src/core/styles.module.scss
@@ -1,32 +1,7 @@
-.scrollableBox {
-  &::-webkit-scrollbar {
-    height: 4px;
-  }
-
-  &::-webkit-scrollbar-button {
-    display: none;
-  }
-
-  &::-webkit-scrollbar-track {
-    background: #00000000;
-    border-radius: 4px;
-  }
-
-  &::-webkit-scrollbar-thumb {
-    background: #4a5568;
-    border-radius: 4px;
-  }
+@use '../theme/mixins' as m;
 
-  &::-webkit-scrollbar-thumb:hover {
-    background: var(--neutral-6);
-  }
-}
-
-@supports not selector(::-webkit-scrollbar) {
-  .scrollableBox {
-    scrollbar-width: thin;
-    scrollbar-color: var(--neutral-6) #00000000;
-  }
+.scrollableBox {
+  @include m.scrollbar;
 }
 
 .safariFullscreen {
diff --git a/src/styles/global.scss b/src/styles/global.scss
index 1ff16163940d4d90ded40ec0aea010cb7fbcd206..ac4bd31a770cfd0c8cee0c490fbe2e9afa8fb0ae 100644
--- a/src/styles/global.scss
+++ b/src/styles/global.scss
@@ -2,13 +2,15 @@
 
 .moex-chart-root {
   font-family: 'Inter', sans-serif;
+  position: relative;
+  z-index: 0;
 }
 
 .moex-chart-portal-host {
   position: fixed;
   inset: 0;
   pointer-events: none;
-  z-index: 1100;
+  z-index: 110;
 }
 
 .moex-chart-portal-host > * {
@@ -22,7 +24,7 @@
 .moex-price-scale-controls {
   position: absolute;
   inset: 0;
-  z-index: 10;
+  z-index: 2;
   display: flex;
   align-items: flex-end;
   justify-content: center;
diff --git a/src/theme/_mixins.scss b/src/theme/_mixins.scss
index 7c8a06cdc891f76d1b412e4bffe69ad92657fb79..e4df9451917fae86688298c2a45ce5ef7021b392 100644
--- a/src/theme/_mixins.scss
+++ b/src/theme/_mixins.scss
@@ -90,3 +90,40 @@
     border-right-color: var(--tooltip-bg) !important;
   }
 }
+
+@mixin scrollbar {
+  @supports selector(::-webkit-scrollbar) {
+    &::-webkit-scrollbar {
+      width: var(--space-0250);
+      height: var(--space-0250);
+    }
+
+    &::-webkit-scrollbar-button {
+      display: none;
+      width: 0;
+      height: 0;
+    }
+
+    &::-webkit-scrollbar-track {
+      background: transparent;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      background: var(--neutral-6);
+      border-radius: var(--space-2000);
+    }
+
+    &::-webkit-scrollbar-thumb:hover {
+      background: var(--neutral-6);
+    }
+
+    &::-webkit-scrollbar-corner {
+      background: transparent;
+    }
+  }
+
+  @supports not selector(::-webkit-scrollbar) {
+    scrollbar-width: thin;
+    scrollbar-color: var(--neutral-6) transparent;
+  }
+}
diff --git a/tsconfig.json b/tsconfig.json
index b33f3d4b95b9c1a839bf1d5af27338f7089b2051..27c78cf1810ec0e064002d9a61b8c82781735ddd 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -28,8 +28,8 @@
     "esModuleInterop": true,
     "skipLibCheck": true,
     "resolveJsonModule": true,
-    "sourceMap": true,
+    "sourceMap": true
   },
-  "include": ["src", "stories", ".storybook"],
+  "include": ["src", "stories", ".storybook", "jest.config.ts"],
   "exclude": ["dist", "node_modules"]
 }