proper scrolling in docs
This commit is contained in:
246
tests/renderer/components/DocumentationView.test.tsx
Normal file
246
tests/renderer/components/DocumentationView.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DocumentationView } from '../../../src/renderer/components/DocumentationView/DocumentationView';
|
||||
import { I18nProvider } from '../../../src/renderer/i18n';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
const openSpy = vi.fn();
|
||||
|
||||
Object.defineProperty(window, 'open', {
|
||||
value: openSpy,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('DocumentationView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
...window.location,
|
||||
hash: '',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
useAppStore.setState({
|
||||
picoTheme: 'slate',
|
||||
} as never);
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders fenced code blocks with language-specific classes for syntax highlighting', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
await screen.findByText('bDS User Guide');
|
||||
const pythonBlock = container.querySelector('pre > code.language-python');
|
||||
expect(pythonBlock).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders heading anchors and keeps in-document toc links in-page', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' });
|
||||
expect(targetHeading).toHaveAttribute('id', 'who-this-guide-is-for');
|
||||
|
||||
const tocLink = container.querySelector('a[href="#who-this-guide-is-for"]');
|
||||
expect(tocLink).not.toBeNull();
|
||||
|
||||
const scrollContainer = container.querySelector('.documentation-scroll');
|
||||
expect(scrollContainer).not.toBeNull();
|
||||
|
||||
Object.defineProperty(scrollContainer as HTMLElement, 'scrollTop', {
|
||||
value: 10,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(scrollContainer as HTMLElement, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(targetHeading, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 340, left: 0, width: 100, height: 20, right: 100, bottom: 360, x: 0, y: 340, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.click(tocLink as HTMLAnchorElement);
|
||||
|
||||
expect((scrollContainer as HTMLElement).scrollTop).toBe(238);
|
||||
const article = container.querySelector('.documentation-article') as HTMLElement;
|
||||
expect(article.dataset.lastJumpTarget).toBe('who-this-guide-is-for');
|
||||
expect(article.dataset.lastJumpTop).toBe('238');
|
||||
expect(window.location.hash).toBe('#who-this-guide-is-for');
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps scroll position unchanged for unresolved in-document links', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' });
|
||||
const tocLink = container.querySelector('a[href="#does-not-exist"]');
|
||||
if (!tocLink) {
|
||||
const article = container.querySelector('.documentation-article') as HTMLElement;
|
||||
const missingAnchor = document.createElement('a');
|
||||
missingAnchor.setAttribute('href', '#does-not-exist');
|
||||
missingAnchor.textContent = 'Missing';
|
||||
article.appendChild(missingAnchor);
|
||||
}
|
||||
|
||||
const unresolvedLink = container.querySelector('a[href="#does-not-exist"]');
|
||||
expect(unresolvedLink).not.toBeNull();
|
||||
|
||||
const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement;
|
||||
expect(scrollContainer).not.toBeNull();
|
||||
|
||||
Object.defineProperty(scrollContainer, 'scrollTop', {
|
||||
value: 222,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(() => fireEvent.click(unresolvedLink as HTMLAnchorElement)).not.toThrow();
|
||||
expect(scrollContainer.scrollTop).toBe(222);
|
||||
});
|
||||
|
||||
it('handles toc clicks when clicking nested content inside the anchor', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' });
|
||||
const tocLink = container.querySelector('a[href="#who-this-guide-is-for"]') as HTMLAnchorElement;
|
||||
expect(tocLink).not.toBeNull();
|
||||
|
||||
const nested = document.createElement('span');
|
||||
nested.textContent = 'Who this guide is for';
|
||||
tocLink.appendChild(nested);
|
||||
|
||||
const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement;
|
||||
Object.defineProperty(scrollContainer, 'scrollTop', {
|
||||
value: 5,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(scrollContainer, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(targetHeading, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 300, left: 0, width: 100, height: 20, right: 100, bottom: 320, x: 0, y: 300, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.click(nested);
|
||||
expect(scrollContainer.scrollTop).toBe(193);
|
||||
});
|
||||
|
||||
it('resolves hash target within the clicked documentation instance', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
await screen.findAllByRole('heading', { level: 2, name: 'Using macros' });
|
||||
|
||||
const wrappers = container.querySelectorAll('.documentation-view');
|
||||
expect(wrappers.length).toBe(2);
|
||||
|
||||
const firstScroll = wrappers[0].querySelector('.documentation-scroll') as HTMLElement;
|
||||
const secondScroll = wrappers[1].querySelector('.documentation-scroll') as HTMLElement;
|
||||
const secondLink = wrappers[1].querySelector('a[href="#using-macros"]') as HTMLAnchorElement;
|
||||
const secondArticle = wrappers[1].querySelector('.documentation-article') as HTMLElement;
|
||||
const secondHeading = Array.from(secondArticle.querySelectorAll<HTMLElement>('[id]')).find((node) => node.id === 'using-macros') as HTMLElement;
|
||||
|
||||
Object.defineProperty(firstScroll, 'scrollTop', { value: 7, writable: true });
|
||||
Object.defineProperty(secondScroll, 'scrollTop', { value: 11, writable: true });
|
||||
|
||||
Object.defineProperty(secondScroll, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 120, left: 0, width: 700, height: 500, right: 700, bottom: 620, x: 0, y: 120, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(secondHeading, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 420, left: 0, width: 300, height: 30, right: 300, bottom: 450, x: 0, y: 420, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.click(secondLink);
|
||||
|
||||
expect(firstScroll.scrollTop).toBe(7);
|
||||
expect(secondScroll.scrollTop).toBe(299);
|
||||
expect(secondArticle.dataset.lastJumpTarget).toBe('using-macros');
|
||||
});
|
||||
|
||||
it('falls back to heading text slug when heading id does not match hash', async () => {
|
||||
const { container } = render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Using scripting (early access)' });
|
||||
targetHeading.id = 'unexpected-id';
|
||||
|
||||
const tocLink = container.querySelector('a[href="#using-scripting-early-access"]') as HTMLAnchorElement;
|
||||
expect(tocLink).not.toBeNull();
|
||||
|
||||
const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement;
|
||||
Object.defineProperty(scrollContainer, 'scrollTop', {
|
||||
value: 50,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(scrollContainer, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(targetHeading, 'getBoundingClientRect', {
|
||||
value: () => ({ top: 450, left: 0, width: 200, height: 24, right: 200, bottom: 474, x: 0, y: 450, toJSON: () => ({}) }),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.click(tocLink);
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(388);
|
||||
expect(targetHeading.id).toBe('using-scripting-early-access');
|
||||
});
|
||||
|
||||
it('shows a copy button for fenced code blocks and copies the block content', async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<DocumentationView />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const copyButtons = await screen.findAllByRole('button', { name: /copy code/i });
|
||||
expect(copyButtons.length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(copyButtons[0]);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalled();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user