Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/miew-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"jquery": "^3.7.1",
"jquery.terminal": "^2.45.2",
"miew": "workspace:^",
"miew-react": "workspace:^",
"react": "^19.2.5",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.5",
Expand Down
6 changes: 3 additions & 3 deletions packages/miew-app/src/components/App.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';

import Menu from '../containers/MenuContainer';
import Viewer from '../containers/MiewViewerContainer';
import MiewViewport from '../containers/MiewViewportContainer';
import './App.scss';

export default class App extends React.Component {
Expand All @@ -20,9 +20,9 @@ export default class App extends React.Component {

render() {
return <div className="root">
<Viewer onChange={ this._onViewerChange }>
<MiewViewport onChange={ this._onViewerChange }>
<Menu/>
</Viewer>
</MiewViewport>
</div>;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useMemo } from 'react';

import Miew from 'MiewModule'; // eslint-disable-line import/no-unresolved
import Miew from 'miew';

import './DisplayPreference.scss';
import Thumbnail from './thumbnail/Thumbnail.jsx';
Expand Down
67 changes: 0 additions & 67 deletions packages/miew-app/src/components/viewer/MiewViewer.jsx

This file was deleted.

98 changes: 98 additions & 0 deletions packages/miew-app/src/components/viewport/MiewViewport.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React, {
useEffect, useMemo, useCallback, useState,
} from 'react';
import Viewer from 'miew-react';
import { MiewProvider } from '../../contexts/MiewContext';

export default function MiewViewport({
frozen, onChange, updateLoadingStage, children,
}) {
const [viewer, setViewer] = useState(null);

const handlePrefsChange = useCallback((prefs) => {
onChange({ prefs });
}, [onChange]);

const options = useMemo(() => ({
load: '1crn',
settings: {
axes: false,
fps: false,
},
}), []);

const handleInit = useCallback((miew) => {
window.miew = miew;
miew.logger.level = 'debug';
setViewer(miew);
onChange({ viewer: miew });
}, [onChange]);

const handleError = useCallback(() => {
updateLoadingStage('Failed to initialize viewer');
}, [updateLoadingStage]);

useEffect(() => {
if (!viewer) {
return undefined;
Comment thread
paulsmirnov marked this conversation as resolved.
}

const handleFetching = () => updateLoadingStage('Fetching...');
const handleParsing = () => updateLoadingStage('Parsing...');
const handleRebuilding = () => updateLoadingStage('Building geometry...');
const handleTitleChanged = (e) => updateLoadingStage(e.data);

viewer.addEventListener('fetching', handleFetching);
viewer.addEventListener('parsing', handleParsing);
viewer.addEventListener('rebuilding', handleRebuilding);
viewer.addEventListener('titleChanged', handleTitleChanged);
viewer.settings.addEventListener('change:axes', handlePrefsChange);
viewer.settings.addEventListener('change:autoRotation', handlePrefsChange);

return () => {
viewer.removeEventListener('fetching', handleFetching);
viewer.removeEventListener('parsing', handleParsing);
viewer.removeEventListener('rebuilding', handleRebuilding);
viewer.removeEventListener('titleChanged', handleTitleChanged);
viewer.settings.removeEventListener('change:axes', handlePrefsChange);
viewer.settings.removeEventListener('change:autoRotation', handlePrefsChange);
};
}, [viewer, updateLoadingStage, handlePrefsChange]);

useEffect(() => {
if (!viewer) {
return;
}

if (frozen) {
viewer.halt();
} else {
viewer.run();
}
}, [frozen, viewer]);

useEffect(() => () => {
if (viewer) {
if (window.miew === viewer) {
window.miew = null;
}
onChange({ viewer: null });
}
}, [viewer, onChange]);

return (
<MiewProvider viewer={viewer}>
<Viewer
className="miew-container"
options={options}
onInit={handleInit}
onError={handleError}
/>
{children}
</MiewProvider>
);
}

MiewViewport.defaultProps = {
frozen: false,
};
184 changes: 184 additions & 0 deletions packages/miew-app/src/components/viewport/MiewViewport.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React from 'react';
import { render, screen, act } from '@testing-library/react';
import MiewViewport from './MiewViewport.jsx';
import { useMiew } from '../../contexts/MiewContext';

let miewReactViewerProps;

jest.mock('miew-react', () => {
const MockMiewReactViewer = (props) => {
miewReactViewerProps = props;
return <div data-testid="mock-viewer" />;
};

return MockMiewReactViewer;
});

function createMockMiew() {
const handlers = {};
const settingHandlers = {};

return {
logger: { level: 'info' },
addEventListener: jest.fn((name, callback) => {
handlers[name] = callback;
}),
removeEventListener: jest.fn((name, callback) => {
if (handlers[name] === callback) {
delete handlers[name];
}
}),
settings: {
addEventListener: jest.fn((name, callback) => {
settingHandlers[name] = callback;
}),
removeEventListener: jest.fn((name, callback) => {
if (settingHandlers[name] === callback) {
delete settingHandlers[name];
}
}),
},
run: jest.fn(),
halt: jest.fn(),
emit(name, event = {}) {
if (handlers[name]) {
handlers[name](event);
}
},
emitSetting(name, event = {}) {
if (settingHandlers[name]) {
settingHandlers[name](event);
}
},
};
}

const MiewConsumer = () => {
const miew = useMiew();
return <div>{miew ? 'miew-ready' : 'miew-missing'}</div>;
};

describe('<MiewViewport>', () => {
beforeEach(() => {
miewReactViewerProps = undefined;
window.miew = null;
jest.clearAllMocks();
});

it('passes miew-react wrapper props and default options', () => {
const onChange = jest.fn();
const updateLoadingStage = jest.fn();

render(<MiewViewport onChange={onChange} updateLoadingStage={updateLoadingStage} />);

expect(screen.getByTestId('mock-viewer')).toBeDefined();
expect(miewReactViewerProps.className).toBe('miew-container');
expect(miewReactViewerProps.options).toEqual({
load: '1crn',
settings: { axes: false, fps: false },
});
expect(typeof miewReactViewerProps.onInit).toBe('function');
expect(typeof miewReactViewerProps.onError).toBe('function');
});

it('initializes Miew instance, updates context and forwards loading events', () => {
const onChange = jest.fn();
const updateLoadingStage = jest.fn();
const mockMiew = createMockMiew();

render(
<MiewViewport onChange={onChange} updateLoadingStage={updateLoadingStage}>
<MiewConsumer />
</MiewViewport>,
);

expect(screen.getByText('miew-missing')).toBeDefined();

act(() => {
miewReactViewerProps.onInit(mockMiew);
});

expect(window.miew).toBe(mockMiew);
expect(mockMiew.logger.level).toBe('debug');
expect(onChange).toHaveBeenCalledWith({ viewer: mockMiew });
expect(screen.getByText('miew-ready')).toBeDefined();

act(() => {
mockMiew.emit('fetching');
mockMiew.emit('parsing');
mockMiew.emit('rebuilding');
mockMiew.emit('titleChanged', { data: 'My Molecule' });
mockMiew.emitSetting('change:axes', { changed: 'axes' });
mockMiew.emitSetting('change:autoRotation', { changed: 'autoRotation' });
});

expect(updateLoadingStage).toHaveBeenCalledWith('Fetching...');
expect(updateLoadingStage).toHaveBeenCalledWith('Parsing...');
expect(updateLoadingStage).toHaveBeenCalledWith('Building geometry...');
expect(updateLoadingStage).toHaveBeenCalledWith('My Molecule');
expect(onChange).toHaveBeenCalledWith({ prefs: { changed: 'axes' } });
expect(onChange).toHaveBeenCalledWith({ prefs: { changed: 'autoRotation' } });
});

it('handles init errors from miew-react wrapper', () => {
const onChange = jest.fn();
const updateLoadingStage = jest.fn();

render(<MiewViewport onChange={onChange} updateLoadingStage={updateLoadingStage} />);

act(() => {
miewReactViewerProps.onError(new Error('init failed'));
});

expect(updateLoadingStage).toHaveBeenCalledWith('Failed to initialize viewer');
});

it('halts and runs Miew instance according to frozen prop changes', () => {
const onChange = jest.fn();
const updateLoadingStage = jest.fn();
const mockMiew = createMockMiew();

const { rerender } = render(
<MiewViewport onChange={onChange} updateLoadingStage={updateLoadingStage} frozen={false} />,
);

act(() => {
miewReactViewerProps.onInit(mockMiew);
});

expect(mockMiew.run).toHaveBeenCalledTimes(1);
expect(mockMiew.halt).toHaveBeenCalledTimes(0);

rerender(<MiewViewport onChange={onChange} updateLoadingStage={updateLoadingStage} frozen />);
expect(mockMiew.halt).toHaveBeenCalledTimes(1);

rerender(<MiewViewport onChange={onChange} updateLoadingStage={updateLoadingStage} frozen={false} />);
expect(mockMiew.run).toHaveBeenCalledTimes(2);
});

it('cleans up global Miew instance and notifies consumers on unmount', () => {
const onChange = jest.fn();
const updateLoadingStage = jest.fn();
const mockMiew = createMockMiew();

const { unmount } = render(
<MiewViewport onChange={onChange} updateLoadingStage={updateLoadingStage} />,
);

act(() => {
miewReactViewerProps.onInit(mockMiew);
});

onChange.mockClear();
unmount();

expect(window.miew).toBeNull();
expect(onChange).toHaveBeenCalledWith({ viewer: null });
expect(mockMiew.removeEventListener).toHaveBeenCalledWith('fetching', expect.any(Function));
expect(mockMiew.removeEventListener).toHaveBeenCalledWith('parsing', expect.any(Function));
expect(mockMiew.removeEventListener).toHaveBeenCalledWith('rebuilding', expect.any(Function));
expect(mockMiew.removeEventListener).toHaveBeenCalledWith('titleChanged', expect.any(Function));
expect(mockMiew.settings.removeEventListener).toHaveBeenCalledWith('change:axes', expect.any(Function));
expect(mockMiew.settings.removeEventListener).toHaveBeenCalledWith('change:autoRotation', expect.any(Function));
});
});
Loading
Loading