test(*): add test cases (#6648)

This commit is contained in:
白熱
2026-03-07 23:13:36 +08:00
committed by GitHub
parent 66974b0f57
commit d2d526b58c
336 changed files with 43491 additions and 1244 deletions
+1 -2
View File
@@ -1,10 +1,9 @@
coverage:
patch: false
status:
project:
default:
# basic
target: auto
target: 50%
threshold: 0
base: auto
# advanced settings
+5 -2
View File
@@ -19,6 +19,8 @@ const { defineConfig, mergeConfig } = require('vitest/config');
function createConfig(options) {
return defineConfig(mergeConfig({
test: {
testTimeout: 30_000,
hookTimeout: 30_000,
css: {
modules: {
classNameStrategy: 'non-scoped',
@@ -29,15 +31,14 @@ function createConfig(options) {
reporter: ['html', 'json'],
provider: 'custom',
customProviderModule: require.resolve('@vitest/coverage-istanbul'),
include: ['src/**/*.{ts,tsx}'],
exclude: [
'coverage/**',
'dist/**',
'**/[.]**',
'packages/*/test?(s)/**',
'**/*.d.ts',
'**/virtual:*',
'**/__x00__*',
'**/\x00*',
'cypress/**',
'test?(s)/**',
'test?(-*).?(c|m)[jt]s?(x)',
@@ -51,6 +52,8 @@ function createConfig(options) {
'**/*.stories.tsx',
'**/__testing__/**',
'**/*/tailwind.config.ts',
'packages/slides/**',
'packages/slides-ui/**',
],
},
},
+8 -8
View File
@@ -46,14 +46,14 @@
},
"devDependencies": {
"@antfu/eslint-config": "^7.2.0",
"@commitlint/cli": "^20.4.2",
"@commitlint/config-conventional": "^20.4.2",
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@eslint-react/eslint-plugin": "^2.8.1",
"@playwright/test": "^1.57.0",
"@release-it-plugins/workspaces": "^5.0.3",
"@release-it/conventional-changelog": "^10.0.5",
"@types/fs-extra": "^11.0.4",
"@types/node": "^25.3.0",
"@types/node": "^25.3.5",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@univerjs-infra/shared": "workspace:*",
@@ -64,17 +64,17 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"fs-extra": "^11.3.3",
"fs-extra": "^11.3.4",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"posthog-node": "^5.25.0",
"lint-staged": "^16.3.2",
"posthog-node": "^5.28.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"release-it": "^19.2.4",
"serve": "^14.2.5",
"serve": "^14.2.6",
"tailwindcss": "3.4.18",
"tsx": "^4.21.0",
"turbo": "^2.8.10",
"turbo": "^2.8.14",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
@@ -0,0 +1,103 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { MessageType } from '@univerjs/design';
import { describe, expect, it, vi } from 'vitest';
import { CompleteRecordingActionCommand, StartRecordingActionCommand, StopRecordingActionCommand } from './record.command';
import { ReplayLocalRecordCommand, ReplayLocalRecordOnActiveCommand, ReplayLocalRecordOnNamesakeCommand } from './replay.command';
describe('action-recorder commands', () => {
it('should handle record commands', () => {
const startRecording = vi.fn();
const completeRecording = vi.fn();
const accessor = {
get: vi.fn(() => ({
startRecording,
completeRecording,
})),
};
expect(StartRecordingActionCommand.handler(accessor as never, { replaceId: true })).toBe(true);
expect(startRecording).toHaveBeenCalledWith(true);
expect(CompleteRecordingActionCommand.handler(accessor as never)).toBe(true);
expect(StopRecordingActionCommand.handler(accessor as never)).toBe(true);
expect(completeRecording).toHaveBeenCalledTimes(2);
});
it('should handle replay commands and success message branch', async () => {
const show = vi.fn();
const replayLocalJSON = vi.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const accessor = {
get: vi.fn()
.mockImplementationOnce(() => ({ replayLocalJSON }))
.mockImplementationOnce(() => ({ show }))
.mockImplementationOnce(() => ({ replayLocalJSON }))
.mockImplementationOnce(() => ({ replayLocalJSON }))
.mockImplementationOnce(() => ({ show }))
.mockImplementationOnce(() => ({ replayLocalJSON }))
.mockImplementationOnce(() => ({ show })),
};
await expect(ReplayLocalRecordCommand.handler(accessor as never)).resolves.toBe(true);
await expect(ReplayLocalRecordOnNamesakeCommand.handler(accessor as never)).resolves.toBe(false);
await expect(ReplayLocalRecordOnActiveCommand.handler(accessor as never)).resolves.toBe(true);
await expect(ReplayLocalRecordOnActiveCommand.handler(accessor as never)).resolves.toBe(false);
expect(replayLocalJSON).toHaveBeenCalledTimes(4);
expect(replayLocalJSON).toHaveBeenNthCalledWith(1);
expect(replayLocalJSON).toHaveBeenNthCalledWith(2, 'name');
expect(replayLocalJSON).toHaveBeenNthCalledWith(3, 'active');
expect(replayLocalJSON).toHaveBeenNthCalledWith(4, 'active');
expect(show).toHaveBeenNthCalledWith(1, {
type: MessageType.Success,
content: 'Successfully replayed local records',
});
expect(show).toHaveBeenNthCalledWith(2, {
type: MessageType.Success,
content: 'Successfully replayed local records',
});
expect(show).toHaveBeenCalledTimes(2);
});
it('should cover remaining replay command branches', async () => {
const replayFalse = vi.fn().mockResolvedValue(false);
const showFalse = vi.fn();
const accessorFalse = {
get: vi.fn()
.mockImplementationOnce(() => ({ replayLocalJSON: replayFalse })),
};
await expect(ReplayLocalRecordCommand.handler(accessorFalse as never)).resolves.toBe(false);
expect(showFalse).not.toHaveBeenCalled();
const replayTrue = vi.fn().mockResolvedValue(true);
const showTrue = vi.fn();
const accessorTrue = {
get: vi.fn()
.mockImplementationOnce(() => ({ replayLocalJSON: replayTrue }))
.mockImplementationOnce(() => ({ show: showTrue })),
};
await expect(ReplayLocalRecordOnNamesakeCommand.handler(accessorTrue as never)).resolves.toBe(true);
expect(showTrue).toHaveBeenCalledWith({
type: MessageType.Success,
content: 'Successfully replayed local records',
});
});
});
@@ -0,0 +1,32 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it, vi } from 'vitest';
import { CloseRecordPanelOperation, OpenRecordPanelOperation } from './operation';
describe('action-recorder operations', () => {
it('should toggle panel open/close', () => {
const togglePanel = vi.fn();
const accessor = {
get: vi.fn(() => ({ togglePanel })),
};
expect(OpenRecordPanelOperation.handler(accessor as never, undefined as never)).toBe(true);
expect(CloseRecordPanelOperation.handler(accessor as never, undefined as never)).toBe(true);
expect(togglePanel).toHaveBeenCalledWith(true);
expect(togglePanel).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,79 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { BehaviorSubject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { CompleteRecordingActionCommand, StartRecordingActionCommand, StopRecordingActionCommand } from '../commands/commands/record.command';
import { ReplayLocalRecordCommand, ReplayLocalRecordOnActiveCommand, ReplayLocalRecordOnNamesakeCommand } from '../commands/commands/replay.command';
import { CloseRecordPanelOperation, OpenRecordPanelOperation } from '../commands/operations/operation';
import { ActionRecorderController } from './action-recorder.controller';
import { menuSchema, OpenRecorderMenuItemFactory, RECORD_MENU_ITEM_ID, RecordMenuItemFactory, ReplayLocalRecordMenuItemFactory, ReplayLocalRecordOnActiveMenuItemFactory, ReplayLocalRecordOnNamesakeMenuItemFactory } from './action-recorder.menu';
describe('action-recorder controller/menu', () => {
it('should create menu items', () => {
const recordItem = RecordMenuItemFactory();
expect(recordItem.id).toBe(RECORD_MENU_ITEM_ID);
const panelOpened$ = new BehaviorSubject(false);
const openItem = OpenRecorderMenuItemFactory({
get: vi.fn(() => ({ panelOpened$: panelOpened$.asObservable() })),
} as never);
expect(openItem.id).toBe(OpenRecordPanelOperation.id);
expect(openItem.disabled$).toBeDefined();
expect(ReplayLocalRecordMenuItemFactory().id).toBe(ReplayLocalRecordCommand.id);
expect(ReplayLocalRecordOnNamesakeMenuItemFactory().id).toBe(ReplayLocalRecordOnNamesakeCommand.id);
expect(ReplayLocalRecordOnActiveMenuItemFactory().id).toBe(ReplayLocalRecordOnActiveCommand.id);
expect(Object.keys(menuSchema).length).toBeGreaterThan(0);
});
it('should register commands/ui/menu and sheet-recorded commands', () => {
const registerCommand = vi.fn();
const registerComponent = vi.fn();
const mergeMenu = vi.fn();
const registerIcon = vi.fn(() => ({ dispose: vi.fn() }));
const registerRecordedCommand = vi.fn();
const controller = new ActionRecorderController(
{ registerCommand } as never,
{ registerComponent } as never,
{ mergeMenu } as never,
{ register: registerIcon } as never,
{ registerRecordedCommand } as never,
{} as never
);
expect(registerCommand).toHaveBeenCalledWith(StartRecordingActionCommand);
expect(registerCommand).toHaveBeenCalledWith(StopRecordingActionCommand);
expect(registerCommand).toHaveBeenCalledWith(CompleteRecordingActionCommand);
expect(registerCommand).toHaveBeenCalledWith(OpenRecordPanelOperation);
expect(registerCommand).toHaveBeenCalledWith(CloseRecordPanelOperation);
expect(registerCommand).toHaveBeenCalledWith(ReplayLocalRecordCommand);
expect(registerCommand).toHaveBeenCalledWith(ReplayLocalRecordOnNamesakeCommand);
expect(registerCommand).toHaveBeenCalledWith(ReplayLocalRecordOnActiveCommand);
expect(registerComponent).toHaveBeenCalledTimes(1);
const componentFactory = registerComponent.mock.calls[0][1] as () => unknown;
expect(componentFactory()).toBeDefined();
expect(registerIcon).toHaveBeenCalledWith('RecordIcon', expect.anything());
expect(mergeMenu).toHaveBeenCalledWith(menuSchema);
expect(registerRecordedCommand).toHaveBeenCalled();
expect(registerRecordedCommand.mock.calls.length).toBeGreaterThan(20);
controller.dispose();
});
});
@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { CommandType } from '@univerjs/core';
import { SetSelectionsOperation } from '@univerjs/sheets';
import { describe, expect, it, vi } from 'vitest';
import { ActionRecorderService } from './action-recorder.service';
describe('ActionRecorderService', () => {
it('should record commands, replace selection entries and complete/stop', () => {
let commandCallback: ((commandInfo: { id: string; type: CommandType; params?: Record<string, unknown> }) => void) | undefined;
const recorderDisposable = { dispose: vi.fn() };
const onCommandExecuted = vi.fn((callback: typeof commandCallback) => {
commandCallback = callback;
return recorderDisposable;
});
const downloadFile = vi.fn();
const logError = vi.fn();
const service = new ActionRecorderService(
{ onCommandExecuted } as never,
{ error: logError } as never,
{ downloadFile } as never,
{
getFocusedUnit: vi.fn(() => ({ getUnitId: () => 'unit-1' })),
getUnit: vi.fn(() => ({
getSheetBySheetId: vi.fn(() => ({ getName: () => 'Sheet-A' })),
})),
} as never
);
expect(() =>
service.registerRecordedCommand({
id: 'mutation-id',
type: CommandType.MUTATION,
} as never)
).toThrow('[CommandRecorderService] Cannot record mutation commands.');
service.registerRecordedCommand({ id: 'cmd-1', type: CommandType.COMMAND } as never);
service.registerRecordedCommand({ id: SetSelectionsOperation.id, type: CommandType.OPERATION } as never);
const panelStates: boolean[] = [];
const recordingStates: boolean[] = [];
const commandStates: string[][] = [];
service.panelOpened$.subscribe((v) => panelStates.push(v));
service.recording$.subscribe((v) => recordingStates.push(v));
service.recordedCommands$.subscribe((v) => commandStates.push(v.map((cmd) => cmd.id)));
service.togglePanel(true);
service.startRecording(true);
expect(service.recording).toBe(true);
expect(recordingStates[recordingStates.length - 1]).toBe(true);
commandCallback?.({
id: 'cmd-1',
type: CommandType.COMMAND,
params: { unitId: 'unit-1', subUnitId: 'sheet-1' },
});
commandCallback?.({
id: SetSelectionsOperation.id,
type: CommandType.OPERATION,
params: { unitId: 'unit-1', subUnitId: 'sheet-1', mark: 1 },
});
commandCallback?.({
id: SetSelectionsOperation.id,
type: CommandType.OPERATION,
params: { unitId: 'unit-1', subUnitId: 'sheet-1', mark: 2 },
});
commandCallback?.({
id: 'ignored',
type: CommandType.COMMAND,
params: {},
});
expect(commandStates[commandStates.length - 1]).toEqual(['cmd-1']);
service.completeRecording();
expect(downloadFile).toHaveBeenCalledTimes(1);
expect(logError).toHaveBeenCalled();
expect(recorderDisposable.dispose).toHaveBeenCalled();
expect(recordingStates[recordingStates.length - 1]).toBe(false);
service.startRecording();
commandCallback?.({
id: 'cmd-1',
type: CommandType.COMMAND,
params: {},
});
service.togglePanel(false);
expect(panelStates[panelStates.length - 1]).toBe(false);
expect(recordingStates[recordingStates.length - 1]).toBe(false);
});
});
@@ -0,0 +1,172 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { MessageType } from '@univerjs/design';
import { describe, expect, it, vi } from 'vitest';
import { ActionReplayService, ReplayMode } from './replay.service';
vi.mock('@univerjs/core', async () => {
const actual = await vi.importActual<typeof import('@univerjs/core')>('@univerjs/core');
return {
...actual,
awaitTime: vi.fn(async () => undefined),
};
});
describe('ActionReplayService', () => {
it('should replay local json with success/failure branches', async () => {
const show = vi.fn();
const executeCommand = vi.fn(async () => true);
const focusedUnit = {
getUnitId: () => 'unit-1',
getSheetBySheetName: vi.fn(() => ({ getSheetId: () => 'sheet-1' })),
getActiveSheet: vi.fn(() => ({ getSheetId: () => 'active-sheet' })),
};
const localFileService = {
openFile: vi.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ name: 'bad.json', text: async () => '{bad' }])
.mockResolvedValueOnce([{ name: 'ok.json', text: async () => JSON.stringify([{ id: 'c1', params: {} }]) }]),
};
const service = new ActionReplayService(
{ show } as never,
{ getFocusedUnit: vi.fn(() => focusedUnit) } as never,
localFileService as never,
{ error: vi.fn() } as never,
{ executeCommand } as never
);
await expect(service.replayLocalJSON()).resolves.toBe(false);
await expect(service.replayLocalJSON()).resolves.toBe(false);
await expect(service.replayLocalJSON()).resolves.toBe(true);
expect(show).toHaveBeenCalledWith({
type: MessageType.Error,
content: 'Failed to replay commands from local file bad.json.',
});
});
it('should replay commands with mode branches and failure paths', async () => {
const logError = vi.fn();
const executeCommandMain = vi.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
const focusedUnit = {
getUnitId: () => 'unit-focused',
getSheetBySheetName: vi.fn((name: string) => (name === 'exists' ? { getSheetId: () => 'sheet-exists' } : undefined)),
getActiveSheet: vi.fn(() => ({ getSheetId: () => 'active-sheet-id' })),
};
const service = new ActionReplayService(
{ show: vi.fn() } as never,
{ getFocusedUnit: vi.fn(() => focusedUnit) } as never,
{ openFile: vi.fn() } as never,
{ error: logError } as never,
{ executeCommand: executeCommandMain } as never
);
await expect(
service.replayCommands([
{ id: 'cmd-name', params: { unitId: 'u', subUnitId: 'exists' } },
{ id: 'cmd-no-params' },
] as never, { mode: ReplayMode.NAME })
).resolves.toBe(true);
await expect(
service.replayCommands([{ id: 'cmd-name-miss', params: { unitId: 'u', subUnitId: 'missing' } }] as never, { mode: ReplayMode.NAME })
).resolves.toBe(true);
await expect(
service.replayCommands([{ id: 'cmd-active', params: { unitId: 'u', subUnitId: 'x' } }] as never, { mode: ReplayMode.ACTIVE })
).resolves.toBe(true);
await expect(
service.replayCommands([{ id: 'cmd-fail', params: { unitId: 'u' } }] as never, { mode: ReplayMode.DEFAULT })
).resolves.toBe(false);
await expect(
service.replayCommands([{ id: 'cmd-no-params-fail' }] as never, { mode: ReplayMode.DEFAULT })
).resolves.toBe(false);
const activeMissingService = new ActionReplayService(
{ show: vi.fn() } as never,
{
getFocusedUnit: vi.fn(() => ({
getUnitId: () => 'unit-focused',
getSheetBySheetName: vi.fn(() => undefined),
getActiveSheet: vi.fn(() => undefined),
})),
} as never,
{ openFile: vi.fn() } as never,
{ error: logError } as never,
{ executeCommand: vi.fn(async () => true) } as never
);
await expect(
activeMissingService.replayCommands([{ id: 'cmd-active-miss', params: { subUnitId: 'x' } }] as never, { mode: ReplayMode.ACTIVE })
).resolves.toBe(true);
const noFocusService = new ActionReplayService(
{ show: vi.fn() } as never,
{ getFocusedUnit: vi.fn(() => undefined) } as never,
{ openFile: vi.fn() } as never,
{ error: logError } as never,
{ executeCommand: vi.fn(async () => true) } as never
);
await expect(noFocusService.replayCommands([{ id: 'cmd-no-focus' }] as never)).resolves.toBe(true);
const noFocusDelayExec = vi.fn()
.mockResolvedValueOnce(false);
const noFocusDelayService = new ActionReplayService(
{ show: vi.fn() } as never,
{ getFocusedUnit: vi.fn(() => undefined) } as never,
{ openFile: vi.fn() } as never,
{ error: logError } as never,
{ executeCommand: noFocusDelayExec } as never
);
await expect(noFocusDelayService.replayCommandsWithDelay([{ id: 'cmd-delay-fail' }] as never)).resolves.toBe(false);
const focusedDelayExec = vi.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const focusedDelayService = new ActionReplayService(
{ show: vi.fn() } as never,
{ getFocusedUnit: vi.fn(() => focusedUnit) } as never,
{ openFile: vi.fn() } as never,
{ error: logError } as never,
{ executeCommand: focusedDelayExec } as never
);
await expect(
focusedDelayService.replayCommandsWithDelay([{ id: 'cmd-delay-param-fail', params: { subUnitId: 'x' } }] as never)
).resolves.toBe(false);
await expect(
focusedDelayService.replayCommandsWithDelay([{ id: 'cmd-delay-no-params-ok' }] as never)
).resolves.toBe(true);
const focusedDelaySuccessService = new ActionReplayService(
{ show: vi.fn() } as never,
{ getFocusedUnit: vi.fn(() => focusedUnit) } as never,
{ openFile: vi.fn() } as never,
{ error: logError } as never,
{ executeCommand: vi.fn(async () => true) } as never
);
await expect(
focusedDelaySuccessService.replayCommandsWithDelay([{ id: 'cmd-delay-param-ok', params: { unitId: 'will-change' } }] as never)
).resolves.toBe(true);
expect(logError).toHaveBeenCalled();
});
});
@@ -0,0 +1,145 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { ICommandService } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { CompleteRecordingActionCommand, StartRecordingActionCommand, StopRecordingActionCommand } from '../../commands/commands/record.command';
import { CloseRecordPanelOperation } from '../../commands/operations/operation';
import { RecorderPanel } from './RecorderPanel';
const mocked = vi.hoisted(() => ({
useDependency: vi.fn(),
useObservable: vi.fn(),
callbacks: [] as Array<(...args: unknown[]) => unknown>,
}));
vi.mock('@univerjs/ui', async () => {
const actual = await vi.importActual<typeof import('@univerjs/ui')>('@univerjs/ui');
return {
...actual,
useDependency: mocked.useDependency,
useObservable: mocked.useObservable,
};
});
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react');
return {
...actual,
useCallback: <T extends (...args: never[]) => unknown>(fn: T) => {
mocked.callbacks.push(fn as unknown as (...args: unknown[]) => unknown);
return fn;
},
};
});
function getButtonsFromPanel(panelElement: {
props: { children: Array<{ props: Record<string, unknown> }> };
}) {
const buttonContainer = panelElement.props.children[2] as {
props: { children: Array<{ props: { onClick: () => void } }> };
};
return buttonContainer.props.children;
}
describe('RecorderPanel', () => {
it('should return null when panel is closed', () => {
mocked.useDependency.mockReturnValue({ panelOpened$: {} });
mocked.useObservable.mockReturnValue(false);
expect(RecorderPanel()).toBeNull();
});
it('should trigger close/start/start(N) commands when not recording', () => {
mocked.callbacks.length = 0;
const executeCommand = vi.fn();
mocked.useDependency.mockImplementation((token: unknown) => {
if (token === ICommandService) {
return { executeCommand };
}
return { panelOpened$: {}, recording$: {}, recordedCommands$: {} };
});
mocked.useObservable
.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
.mockReturnValueOnce([]);
const root = RecorderPanel() as { type: (props: unknown) => unknown; props: unknown };
const panelElement = root.type(root.props) as {
props: { children: Array<{ props: Record<string, unknown> }> };
};
const buttons = getButtonsFromPanel(panelElement);
buttons[0].props.onClick();
buttons[1].props.onClick();
buttons[2].props.onClick();
mocked.callbacks[2]();
mocked.callbacks[3]();
expect(executeCommand).toHaveBeenCalledWith(CloseRecordPanelOperation.id);
expect(executeCommand).toHaveBeenCalledWith(StartRecordingActionCommand.id, { replaceId: undefined });
expect(executeCommand).toHaveBeenCalledWith(StartRecordingActionCommand.id, { replaceId: true });
});
it('should trigger cancel/save commands when recording', () => {
mocked.callbacks.length = 0;
const executeCommand = vi.fn();
mocked.useDependency.mockImplementation((token: unknown) => {
if (token === ICommandService) {
return { executeCommand };
}
return { panelOpened$: {}, recording$: {}, recordedCommands$: {} };
});
mocked.useObservable
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce([{ id: 'last.command' }]);
const root = RecorderPanel() as { type: (props: unknown) => unknown; props: unknown };
const panelElement = root.type(root.props) as {
props: { children: Array<{ props: Record<string, unknown> }> };
};
const buttons = getButtonsFromPanel(panelElement);
buttons[0].props.onClick();
buttons[1].props.onClick();
mocked.callbacks[0]();
mocked.callbacks[1](true);
expect(executeCommand).toHaveBeenCalledWith(StopRecordingActionCommand.id);
expect(executeCommand).toHaveBeenCalledWith(CompleteRecordingActionCommand.id);
});
it('should show recording placeholder title when command list is empty/undefined', () => {
mocked.callbacks.length = 0;
mocked.useDependency.mockImplementation((token: unknown) => {
if (token === ICommandService) {
return { executeCommand: vi.fn() };
}
return { panelOpened$: {}, recording$: {}, recordedCommands$: {} };
});
mocked.useObservable
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(undefined);
const root = RecorderPanel() as { type: (props: unknown) => unknown; props: unknown };
const panelElement = root.type(root.props) as {
props: { children: Array<{ props: { children: unknown } }> };
};
const title = panelElement.props.children[1].props.children;
expect(title).toBe('Recording...');
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
describe('core entry', () => {
it('should expose major APIs from index', async () => {
const mod = await import('../index');
expect(typeof mod.Univer).toBe('function');
expect(typeof mod.Skeleton).toBe('function');
expect(typeof mod.Workbook).toBe('function');
expect(typeof mod.Range).toBe('function');
});
});
@@ -0,0 +1,131 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { ILocales } from '../shared/locale';
import { defaultTheme } from '@univerjs/themes';
import { describe, expect, it, vi } from 'vitest';
import { Injector } from '../common/di';
import { UniverInstanceType } from '../common/unit';
import { COMMAND_LOG_EXECUTION_CONFIG_KEY } from '../services/command/command.service';
import { IConfigService } from '../services/config/config.service';
import { LifecycleStages } from '../services/lifecycle/lifecycle';
import { LifecycleService } from '../services/lifecycle/lifecycle.service';
import { LocaleService } from '../services/locale/locale.service';
import { LogLevel } from '../services/log/log.service';
import { Skeleton } from '../skeleton';
import { LocaleType } from '../types/enum/locale-type';
import { Univer } from '../univer';
describe('Skeleton', () => {
it('should update dirty state and release locale data on dispose', () => {
const skeleton = new Skeleton(new LocaleService());
expect(skeleton.dirty).toBe(true);
expect(skeleton.getFontLocale()).toBeUndefined();
skeleton.makeDirty(false);
expect(skeleton.dirty).toBe(false);
skeleton.dispose();
expect(skeleton.getFontLocale()).toBeNull();
});
});
describe('Univer', () => {
it('should apply constructor config and expose locale/config methods', () => {
const locales: ILocales = {
[LocaleType.EN_US]: {
test: {
greeting: 'Hello {0}',
},
},
} as unknown as ILocales;
const univer = new Univer({
theme: defaultTheme,
darkMode: true,
locales,
locale: LocaleType.EN_US,
logLevel: LogLevel.VERBOSE,
logCommandExecution: true,
});
const injector = univer.__getInjector();
const localeService = injector.get(LocaleService);
expect(localeService.getCurrentLocale()).toBe(LocaleType.EN_US);
expect(localeService.t('test.greeting', 'Univer')).toBe('Hello Univer');
expect(injector.get(IConfigService).getConfig(COMMAND_LOG_EXECUTION_CONFIG_KEY)).toBe(true);
univer.setLocale(LocaleType.ZH_CN);
expect(localeService.getCurrentLocale()).toBe(LocaleType.ZH_CN);
univer.dispose();
});
it('should support add/remove dispose callbacks', () => {
const univer = new Univer();
const removedCallback = vi.fn();
const activeCallback = vi.fn();
const disposable = univer.onDispose(removedCallback);
univer.onDispose(activeCallback);
disposable.dispose();
univer.dispose();
expect(removedCallback).not.toHaveBeenCalled();
expect(activeCallback).toHaveBeenCalledTimes(1);
});
it('should create units via deprecated and current APIs', () => {
const univer = new Univer();
const injector = univer.__getInjector();
const sheetA = univer.createUniverSheet({ id: 'sheet-a' });
const sheetB = univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'sheet-b' });
const sheetC = univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'sheet-c' });
const doc = univer.createUniverDoc({ id: 'doc-a' });
const slide = univer.createUniverSlide({ id: 'slide-a' });
expect(sheetA.getUnitId()).toBe('sheet-a');
expect(sheetB.getUnitId()).toBe('sheet-b');
expect(sheetC.getUnitId()).toBe('sheet-c');
expect(doc.getUnitId()).toBe('doc-a');
expect(slide.getUnitId()).toBe('slide-a');
expect(injector.get(LifecycleService).stage).toBe(LifecycleStages.Ready);
univer.dispose();
});
it('should delegate plugin registration for tuple-style APIs and support parent injector', () => {
const parentInjector = new Injector([]);
const univer = new Univer({}, parentInjector);
const registerPluginSpy = vi
.spyOn((univer as any)._pluginService, 'registerPlugin')
.mockImplementation(() => undefined);
univer.registerPlugins([
[class FakePluginA {} as never, { enabled: true }],
[class FakePluginB {} as never],
] as never);
expect(registerPluginSpy).toHaveBeenCalledTimes(2);
registerPluginSpy.mockRestore();
univer.dispose();
});
});
@@ -0,0 +1,70 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { invertColorByHSL, stringToRgb } from '../invert-color/invert-hsl';
import { invertColorByMatrix } from '../invert-color/invert-rgb';
import { denormalizeRGBColor, normalizeRGBColor } from '../invert-color/utils';
function expectRgbColorInRange(color: [number, number, number]) {
color.forEach((channel) => {
expect(Number.isInteger(channel)).toBe(true);
expect(channel).toBeGreaterThanOrEqual(0);
expect(channel).toBeLessThanOrEqual(255);
});
}
describe('invert color helpers', () => {
it('should normalize and denormalize rgb colors', () => {
expect(normalizeRGBColor([0, 128, 255])).toEqual([0, 128 / 255, 1]);
expect(denormalizeRGBColor([0, 0.5, 1])).toEqual([0, 128, 255]);
});
it('should parse rgb strings and ignore trailing channels', () => {
expect(stringToRgb('12,34,56')).toEqual([12, 34, 56]);
expect(stringToRgb('12,34,56,0.5')).toEqual([12, 34, 56]);
});
it('should invert colors with the rgb matrix implementation', () => {
expect(invertColorByMatrix([0, 0, 0])).toEqual([255, 255, 255]);
expect(invertColorByMatrix([255, 255, 255])).toEqual([0, 0, 0]);
expect(invertColorByMatrix([255, 0, 0])).toEqual([255, 85, 85]);
});
it('should invert grayscale colors with the hsl implementation', () => {
expect(invertColorByHSL([0, 0, 0])).toEqual([255, 255, 255]);
expect(invertColorByHSL([255, 255, 255])).toEqual([3, 3, 3]);
expect(invertColorByHSL([128, 128, 128])).toEqual([127, 127, 127]);
});
it('should invert chromatic colors through all hue branches', () => {
const redDominantLowBlue = invertColorByHSL([255, 128, 0]);
const redDominantHighBlue = invertColorByHSL([255, 0, 128]);
const greenDominant = invertColorByHSL([0, 255, 0]);
const blueDominant = invertColorByHSL([0, 0, 255]);
const brightChromatic = invertColorByHSL([255, 200, 100]);
[redDominantLowBlue, redDominantHighBlue, greenDominant, blueDominant, brightChromatic].forEach((color) => {
expectRgbColorInRange(color);
});
expect(redDominantLowBlue).toHaveLength(3);
expect(redDominantHighBlue).toHaveLength(3);
expect(greenDominant).toHaveLength(3);
expect(blueDominant).toHaveLength(3);
expect(brightChromatic).toHaveLength(3);
});
});
@@ -0,0 +1,51 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { Registry, RegistryAsMap } from '../registry';
describe('registry helpers', () => {
it('should add unique items into Registry and delete existing ones', () => {
const registry = Registry.create<string>();
registry.add('a');
registry.add('a');
registry.add('b');
expect(registry.getData()).toEqual(['a', 'b']);
registry.delete('a');
expect(registry.getData()).toEqual(['b']);
});
it('should add unique keyed items into RegistryAsMap and delete by key', () => {
const registry = RegistryAsMap.create();
registry.add('a', { value: 1 });
registry.add('a', { value: 2 });
registry.add('b', { value: 3 });
expect(Array.from(registry.getData().entries())).toEqual([
['a', { value: 1 }],
['b', { value: 3 }],
]);
registry.delete('a');
expect(Array.from(registry.getData().entries())).toEqual([
['b', { value: 3 }],
]);
});
});
@@ -0,0 +1,119 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installShims } from '../shims';
type RestorableDescriptor = PropertyDescriptor | undefined;
function restoreProperty(target: object, key: PropertyKey, descriptor: RestorableDescriptor) {
if (descriptor) {
Object.defineProperty(target, key, descriptor);
} else {
Reflect.deleteProperty(target, key);
}
}
describe('installShims', () => {
let requestIdleDescriptor: RestorableDescriptor;
let cancelIdleDescriptor: RestorableDescriptor;
let findLastDescriptor: RestorableDescriptor;
let findLastIndexDescriptor: RestorableDescriptor;
let stringAtDescriptor: RestorableDescriptor;
beforeEach(() => {
vi.useFakeTimers();
requestIdleDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'requestIdleCallback');
cancelIdleDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cancelIdleCallback');
findLastDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'findLast');
findLastIndexDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'findLastIndex');
stringAtDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'at');
Object.defineProperty(globalThis, 'requestIdleCallback', {
configurable: true,
writable: true,
value: undefined,
});
Object.defineProperty(globalThis, 'cancelIdleCallback', {
configurable: true,
writable: true,
value: undefined,
});
// eslint-disable-next-line no-extend-native
Object.defineProperty(Array.prototype, 'findLast', {
configurable: true,
writable: true,
value: undefined,
});
// eslint-disable-next-line no-extend-native
Object.defineProperty(Array.prototype, 'findLastIndex', {
configurable: true,
writable: true,
value: undefined,
});
// eslint-disable-next-line no-extend-native
Object.defineProperty(String.prototype, 'at', {
configurable: true,
writable: true,
value: undefined,
});
});
afterEach(() => {
restoreProperty(globalThis, 'requestIdleCallback', requestIdleDescriptor);
restoreProperty(globalThis, 'cancelIdleCallback', cancelIdleDescriptor);
restoreProperty(Array.prototype, 'findLast', findLastDescriptor);
restoreProperty(Array.prototype, 'findLastIndex', findLastIndexDescriptor);
restoreProperty(String.prototype, 'at', stringAtDescriptor);
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should install requestIdleCallback and allow cancellation', () => {
const callback = vi.fn();
installShims();
const canceledId = globalThis.requestIdleCallback?.(callback as never);
globalThis.cancelIdleCallback?.(canceledId as number);
vi.advanceTimersByTime(5);
expect(callback).not.toHaveBeenCalled();
globalThis.requestIdleCallback?.(callback as never);
vi.advanceTimersByTime(5);
expect(callback).toHaveBeenCalledTimes(1);
const idleDeadline = callback.mock.calls[0][0] as { didTimeout: boolean; timeRemaining: () => number };
expect(idleDeadline.didTimeout).toBe(false);
expect(idleDeadline.timeRemaining()).toBeGreaterThanOrEqual(0);
});
it('should install array findLastIndex and findLast polyfills', () => {
installShims();
expect([1, 2, 3, 2].findLastIndex((value) => value === 2)).toBe(3);
expect([1, 2, 3, 2].findLast((value) => value === 2)).toBe(2);
expect(() => Array.prototype.findLastIndex.call([1], null as never)).toThrowError(/callback must be a function/);
});
it('should install string at polyfill', () => {
installShims();
expect('abcd'.at(1)).toBe('b');
expect('abcd'.at(-1)).toBe('d');
expect('abcd'.at(10)).toBeUndefined();
});
});
+1 -14
View File
@@ -17,7 +17,7 @@
import type { CommandListener, DocumentDataModel, IDisposable, IDocumentData, IExecutionOptions, ILanguagePack, IParagraphStyle, ITextDecoration, ITextStyle, LifecycleStages, LocaleType } from '@univerjs/core';
import type { Subscription } from 'rxjs';
import type { ICommandEvent, IEventParamConfig } from './f-event';
import { CanceledError, ColorBuilder, Disposable, ICommandService, Inject, Injector, IUniverInstanceService, LifecycleService, LocaleService, ParagraphStyleBuilder, ParagraphStyleValue, RedoCommand, RichTextBuilder, RichTextValue, TextDecorationBuilder, TextStyleBuilder, TextStyleValue, ThemeService, toDisposable, UndoCommand, Univer, UniverInstanceType } from '@univerjs/core';
import { CanceledError, Disposable, ICommandService, Inject, Injector, IUniverInstanceService, LifecycleService, LocaleService, ParagraphStyleBuilder, ParagraphStyleValue, RedoCommand, RichTextBuilder, RichTextValue, TextDecorationBuilder, TextStyleBuilder, TextStyleValue, ThemeService, toDisposable, UndoCommand, Univer, UniverInstanceType } from '@univerjs/core';
import { FBlob } from './f-blob';
import { FDoc } from './f-doc';
import { FEnum } from './f-enum';
@@ -522,19 +522,6 @@ export class FUniver extends Disposable {
return this._injector.createInstance(FBlob);
}
/**
* Create a new color.
* @returns {ColorBuilder} The new color instance
* @example
* ```ts
* const color = univerAPI.newColor();
* ```
* @deprecated
*/
newColor(): ColorBuilder {
return new ColorBuilder();
}
/**
* Create a new rich text.
* @param {IDocumentData} data
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { EventState } from '../observable';
import { describe, expect, it, vi } from 'vitest';
import { EventState, EventSubject, fromEventSubject } from '../observable';
describe('EventState', () => {
it('should initialize with skipNextObservers set to false', () => {
@@ -29,3 +29,94 @@ describe('EventState', () => {
expect(eventState.isStopPropagation).toBe(true);
});
});
describe('EventSubject', () => {
it('should notify observers by priority and keep last return value', () => {
const subject = new EventSubject<string>();
const order: string[] = [];
subject.subscribeEvent({
priority: 10,
next: ([event]) => {
order.push(`late:${event}`);
return 'late';
},
});
subject.subscribeEvent({
priority: 1,
next: ([event]) => {
order.push(`early:${event}`);
return 'early';
},
});
const result = subject.emitEvent('evt');
expect(order).toEqual(['early:evt', 'late:evt']);
expect(result).toEqual({
handled: true,
lastReturnValue: 'late',
stopPropagation: false,
});
});
it('should stop on skipNextObservers and expose propagation state', () => {
const subject = new EventSubject<string>();
const nextSpy = vi.fn();
subject.subscribeEvent((event, state) => {
state.stopPropagation();
state.skipNextObservers = true;
return `${event}:stopped`;
});
subject.subscribeEvent(nextSpy);
const result = subject.emitEvent('evt');
expect(nextSpy).not.toHaveBeenCalled();
expect(result).toEqual({
handled: true,
lastReturnValue: 'evt:stopped',
stopPropagation: true,
});
});
it('should clear observers on complete and throw after unsubscribe', () => {
const subject = new EventSubject<string>();
const completeSpy = vi.fn();
subject.subscribeEvent({ complete: completeSpy });
subject.clearObservers();
expect(completeSpy).toHaveBeenCalledTimes(1);
expect(subject.emitEvent('evt')).toEqual({
handled: false,
lastReturnValue: 'evt',
stopPropagation: false,
});
subject.complete();
expect(subject.emitEvent('evt')).toEqual({
handled: false,
lastReturnValue: 'evt',
stopPropagation: false,
});
subject.unsubscribe();
expect(() => subject.emitEvent('evt')).toThrowError(/closed subject/);
});
it('should forward events through fromEventSubject and unsubscribe cleanly', () => {
const subject = new EventSubject<string>();
const received: string[] = [];
const subscription = fromEventSubject(subject).subscribe((value) => {
received.push(value);
});
subject.emitEvent('first');
subscription.unsubscribe();
subject.emitEvent('second');
subject.unsubscribe();
expect(received).toEqual(['first']);
});
});
@@ -0,0 +1,189 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { IWorkbookData } from '../../../sheets/typedef';
import type { Workbook } from '../../../sheets/workbook';
import type { IDocumentData } from '../../../types/interfaces/i-document-data';
import type { ISlideData } from '../../../types/interfaces/i-slide-data';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Injector } from '../../../common/di';
import { UniverInstanceType } from '../../../common/unit';
import { DocumentDataModel } from '../../../docs/data-model/document-data-model';
import { Workbook as WorkbookModel } from '../../../sheets/workbook';
import { SlideDataModel } from '../../../slides/slide-model';
import { FOCUSING_DOC, FOCUSING_SHEET, FOCUSING_SLIDE, FOCUSING_UNIT } from '../../context/context';
import { ContextService } from '../../context/context.service';
import { DesktopLogService, LogLevel } from '../../log/log.service';
import { UniverInstanceService } from '../instance.service';
function createWorkbookData(id = 'sheet-unit'): Partial<IWorkbookData> {
return {
id,
name: 'Workbook',
styles: {},
sheetOrder: ['sheet-1'],
sheets: {
'sheet-1': {
id: 'sheet-1',
name: 'Sheet1',
cellData: {},
rowCount: 5,
columnCount: 5,
},
},
};
}
function createDocData(id = 'doc-unit'): Partial<IDocumentData> {
return {
id,
body: { dataStream: 'Hello\r\n' },
documentStyle: {
pageSize: { width: 100, height: 100 },
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
},
};
}
function createSlideData(id = 'slide-unit'): Partial<ISlideData> {
return {
id,
title: 'Slide',
body: { pages: {}, pageOrder: [] },
};
}
describe('UniverInstanceService', () => {
let service: UniverInstanceService;
let contextService: ContextService;
let logService: DesktopLogService;
beforeEach(() => {
contextService = new ContextService();
logService = new DesktopLogService();
logService.setLogLevel(LogLevel.SILENT);
service = new UniverInstanceService(new Injector(), contextService, logService);
service.registerCtorForType(UniverInstanceType.UNIVER_SHEET, WorkbookModel as never);
service.registerCtorForType(UniverInstanceType.UNIVER_DOC, DocumentDataModel as never);
service.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, SlideDataModel as never);
service.__setCreateHandler((type, data, _ctor, options) => {
let unit;
if (type === UniverInstanceType.UNIVER_SHEET) {
unit = new WorkbookModel(data as Partial<IWorkbookData>, logService);
} else if (type === UniverInstanceType.UNIVER_DOC) {
unit = new DocumentDataModel(data as Partial<IDocumentData>);
} else {
unit = new SlideDataModel(data as Partial<ISlideData>);
}
service.__addUnit(unit, options);
return unit;
});
});
afterEach(() => {
service.dispose();
contextService.dispose();
logService.dispose();
});
it('should create units, set current unit and expose lookup APIs', () => {
const added: string[] = [];
service.getTypeOfUnitAdded$<Workbook>(UniverInstanceType.UNIVER_SHEET).subscribe((unit) => {
added.push(unit.getUnitId());
});
const workbook = service.createUnit<Partial<IWorkbookData>, WorkbookModel>(UniverInstanceType.UNIVER_SHEET, createWorkbookData());
const doc = service.createUnit<Partial<IDocumentData>, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData(), { makeCurrent: false });
expect(added).toEqual(['sheet-unit']);
expect(workbook.getUnitId()).toBe('sheet-unit');
expect(service.getCurrentUnitOfType<WorkbookModel>(UniverInstanceType.UNIVER_SHEET)?.getUnitId()).toBe('sheet-unit');
expect(service.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)).toBeUndefined();
expect(service.getUnit<WorkbookModel>('sheet-unit', UniverInstanceType.UNIVER_SHEET)?.getUnitId()).toBe('sheet-unit');
expect(service.getUnit('sheet-unit', UniverInstanceType.UNIVER_DOC)).toBeNull();
expect(service.getAllUnitsForType<WorkbookModel>(UniverInstanceType.UNIVER_SHEET)).toHaveLength(1);
expect(service.getUnitType(doc.getUnitId())).toBe(UniverInstanceType.UNIVER_DOC);
expect(service.getUnitType('missing')).toBe(UniverInstanceType.UNRECOGNIZED);
});
it('should focus sheet, doc, slide and reset contexts on null focus', () => {
const workbook = service.createUnit<Partial<IWorkbookData>, WorkbookModel>(UniverInstanceType.UNIVER_SHEET, createWorkbookData());
const doc = service.createUnit<Partial<IDocumentData>, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData());
const slide = service.createUnit<Partial<ISlideData>, SlideDataModel>(UniverInstanceType.UNIVER_SLIDE, createSlideData());
service.focusUnit(workbook.getUnitId());
expect(contextService.getContextValue(FOCUSING_UNIT)).toBe(true);
expect(contextService.getContextValue(FOCUSING_SHEET)).toBe(true);
expect(contextService.getContextValue(FOCUSING_DOC)).toBe(false);
service.focusUnit(doc.getUnitId());
expect(service.getFocusedUnit()?.getUnitId()).toBe(doc.getUnitId());
expect(contextService.getContextValue(FOCUSING_DOC)).toBe(true);
expect(contextService.getContextValue(FOCUSING_SHEET)).toBe(false);
service.focusUnit(slide.getUnitId());
expect(contextService.getContextValue(FOCUSING_SLIDE)).toBe(true);
expect(contextService.getContextValue(FOCUSING_DOC)).toBe(false);
service.focusUnit(null);
expect(service.getFocusedUnit()).toBeNull();
expect(contextService.getContextValue(FOCUSING_UNIT)).toBe(false);
expect(contextService.getContextValue(FOCUSING_DOC)).toBe(false);
expect(contextService.getContextValue(FOCUSING_SHEET)).toBe(false);
expect(contextService.getContextValue(FOCUSING_SLIDE)).toBe(false);
});
it('should replace docs and dispose units while resetting focus and current', () => {
const disposed: string[] = [];
service.getTypeOfUnitDisposed$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).subscribe((unit) => {
disposed.push(unit.getUnitId());
});
const doc = service.createUnit<Partial<IDocumentData>, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData());
service.focusUnit(doc.getUnitId());
const replacement = new DocumentDataModel(createDocData('doc-unit'));
service.changeDoc(doc.getUnitId(), replacement);
expect(service.getUniverDocInstance('doc-unit')).toBe(replacement);
expect(service.disposeUnit('doc-unit')).toBe(true);
expect(disposed).toEqual(['doc-unit']);
expect(service.getCurrentUniverDocInstance()).toBeNull();
expect(service.getFocusedUnit()).toBeUndefined();
expect(service.disposeUnit('missing')).toBe(false);
});
it('should throw on duplicate unit id and support current type stream', () => {
const currentIds: Array<string | null> = [];
service.getCurrentTypeOfUnit$<WorkbookModel>(UniverInstanceType.UNIVER_SHEET).subscribe((unit) => {
currentIds.push(unit?.getUnitId() ?? null);
});
service.createUnit<Partial<IWorkbookData>, WorkbookModel>(UniverInstanceType.UNIVER_SHEET, createWorkbookData());
service.createUnit<Partial<IWorkbookData>, WorkbookModel>(UniverInstanceType.UNIVER_SHEET, createWorkbookData('sheet-unit-2'));
service.setCurrentUnitForType('sheet-unit-2');
expect(() => service.__addUnit(new WorkbookModel(createWorkbookData('sheet-unit-2'), logService))).toThrowError(/same unit id/);
expect(() => service.setCurrentUnitForType('missing')).toThrowError(/no document with unitId missing/);
expect(currentIds).toContain('sheet-unit');
expect(currentIds).toContain('sheet-unit-2');
});
});
@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopLogService, LogLevel } from '../log.service';
describe('DesktopLogService', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should respect log level thresholds', () => {
const service = new DesktopLogService();
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.setLogLevel(LogLevel.WARN);
service.debug('debug');
service.log('log');
service.warn('warn');
service.error('error');
expect(debugSpy).not.toHaveBeenCalled();
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith('warn');
expect(errorSpy).toHaveBeenCalledWith('error');
});
it('should format tagged messages when logging', () => {
const service = new DesktopLogService();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
service.log('[core]', 'message');
expect(logSpy).toHaveBeenCalledWith('\x1B[97;104m[core]\x1B[0m', 'message');
});
it('should deduplicate deprecate logs and clear cache on dispose', () => {
const service = new DesktopLogService();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.deprecate('[deprecated]', { key: 'value' });
service.deprecate('[deprecated]', { key: 'value' });
expect(errorSpy).toHaveBeenCalledTimes(1);
service.dispose();
service.deprecate('[deprecated]', { key: 'value' });
expect(errorSpy).toHaveBeenCalledTimes(2);
});
it('should suppress all output at silent level', () => {
const service = new DesktopLogService();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.setLogLevel(LogLevel.SILENT);
service.warn('warn');
service.error('error');
service.deprecate('deprecated');
expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,137 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { UniverInstanceType } from '../../../common/unit';
import { DesktopLogService, LogLevel } from '../../log/log.service';
import { ResourceManagerService } from '../resource-manager.service';
describe('ResourceManagerService', () => {
let logService: DesktopLogService;
let service: ResourceManagerService;
beforeEach(() => {
logService = new DesktopLogService();
logService.setLogLevel(LogLevel.SILENT);
service = new ResourceManagerService(logService);
});
afterEach(() => {
service.dispose();
logService.dispose();
});
it('should register hooks, emit register event and filter resources by type', () => {
const registered: string[] = [];
service.register$.subscribe((hook) => registered.push(hook.pluginName));
service.registerPluginResource({
pluginName: 'SHEET_TEST_PLUGIN',
businesses: [UniverInstanceType.UNIVER_SHEET],
onLoad: () => {},
onUnLoad: () => {},
toJson: (unitId) => JSON.stringify({ unitId }),
parseJson: JSON.parse,
});
service.registerPluginResource({
pluginName: 'DOC_TEST_PLUGIN',
businesses: [UniverInstanceType.UNIVER_DOC],
onLoad: () => {},
onUnLoad: () => {},
toJson: () => 'doc',
parseJson: JSON.parse,
});
expect(registered).toEqual(['SHEET_TEST_PLUGIN', 'DOC_TEST_PLUGIN']);
expect(service.getAllResourceHooks()).toHaveLength(2);
expect(service.getResources('u1')).toEqual([
{ name: 'SHEET_TEST_PLUGIN', data: '{"unitId":"u1"}' },
{ name: 'DOC_TEST_PLUGIN', data: 'doc' },
]);
expect(service.getResourcesByType('u1', UniverInstanceType.UNIVER_DOC)).toEqual([
{ name: 'DOC_TEST_PLUGIN', data: 'doc' },
]);
});
it('should unregister resources through returned disposable and explicit disposal', () => {
const disposable = service.registerPluginResource({
pluginName: 'SHEET_TEST_PLUGIN',
businesses: [UniverInstanceType.UNIVER_SHEET],
onLoad: () => {},
onUnLoad: () => {},
toJson: () => 'sheet',
parseJson: JSON.parse,
});
expect(() => service.registerPluginResource({
pluginName: 'SHEET_TEST_PLUGIN',
businesses: [UniverInstanceType.UNIVER_SHEET],
onLoad: () => {},
onUnLoad: () => {},
toJson: () => 'sheet',
parseJson: JSON.parse,
})).toThrowError(/registered/);
disposable.dispose();
expect(service.getAllResourceHooks()).toHaveLength(0);
service.registerPluginResource({
pluginName: 'DOC_TEST_PLUGIN',
businesses: [UniverInstanceType.UNIVER_DOC],
onLoad: () => {},
onUnLoad: () => {},
toJson: () => 'doc',
parseJson: JSON.parse,
});
service.disposePluginResource('DOC_TEST_PLUGIN');
expect(service.getAllResourceHooks()).toHaveLength(0);
});
it('should load, unload and log parsing errors', () => {
const loaded: unknown[] = [];
const unloaded: string[] = [];
const errorSpy = vi.spyOn(logService, 'error');
service.registerPluginResource({
pluginName: 'SHEET_TEST_PLUGIN',
businesses: [UniverInstanceType.UNIVER_SHEET],
onLoad: (_unitId, resource) => loaded.push(resource),
onUnLoad: (unitId) => unloaded.push(unitId),
toJson: () => 'sheet',
parseJson: JSON.parse,
});
service.registerPluginResource({
pluginName: 'DOC_TEST_PLUGIN',
businesses: [UniverInstanceType.UNIVER_DOC],
onLoad: () => {},
onUnLoad: () => {},
toJson: () => 'doc',
parseJson: () => {
throw new Error('bad json');
},
});
service.loadResources('unit-1', [
{ name: 'SHEET_TEST_PLUGIN', data: '{"ok":true}' },
{ name: 'DOC_TEST_PLUGIN', data: 'boom' },
]);
service.unloadResources('unit-1', UniverInstanceType.UNIVER_SHEET);
expect(loaded).toEqual([{ ok: true }]);
expect(unloaded).toEqual(['unit-1']);
expect(errorSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,209 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { ICommand } from '../../command/command.service';
import type { IUniverInstanceService } from '../../instance/instance.service';
import { BehaviorSubject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '../../../common/const';
import { Injector } from '../../../common/di';
import { CommandService, CommandType, ICommandService } from '../../command/command.service';
import { ConfigService, IConfigService } from '../../config/config.service';
import { EDITOR_ACTIVATED, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHEET } from '../../context/context';
import { ContextService, IContextService } from '../../context/context.service';
import { DesktopLogService, ILogService, LogLevel } from '../../log/log.service';
import { IUndoRedoService, LocalUndoRedoService, RedoCommandId, UndoCommandId } from '../undoredo.service';
const MUTATION_ID = 'test.mutation';
class FocusedUnit {
constructor(private readonly _unitId: string) {}
getUnitId() {
return this._unitId;
}
}
describe('LocalUndoRedoService', () => {
let injector: Injector;
let commandService: ICommandService;
let contextService: ContextService;
let logService: DesktopLogService;
let focused$: BehaviorSubject<FocusedUnit | null>;
let instanceService: IUniverInstanceService;
let undoRedoService: LocalUndoRedoService;
let mutationLog: string[];
beforeEach(() => {
injector = new Injector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
commandService = injector.get(ICommandService);
contextService = injector.get(IContextService) as ContextService;
logService = injector.get(ILogService) as DesktopLogService;
logService.setLogLevel(LogLevel.SILENT);
focused$ = new BehaviorSubject<FocusedUnit | null>(new FocusedUnit('unit-1'));
instanceService = {
focused$: focused$.asObservable(),
getFocusedUnit: () => focused$.value,
} as IUniverInstanceService;
undoRedoService = new LocalUndoRedoService(instanceService, commandService, contextService);
injector.add([IUndoRedoService, { useValue: undoRedoService }]);
mutationLog = [];
commandService.registerCommand({
id: MUTATION_ID,
type: CommandType.MUTATION,
handler: (_accessor, params?: { label: string; fail?: boolean }) => {
mutationLog.push(params?.label ?? 'unknown');
return !params?.fail;
},
} as ICommand);
});
afterEach(() => {
undoRedoService.dispose();
focused$.complete();
logService.dispose();
contextService.dispose();
});
it('should push undo items, clear redo stack and expose focused status', () => {
const statuses: Array<{ undos: number; redos: number }> = [];
undoRedoService.undoRedoStatus$.subscribe((status) => {
statuses.push(status);
});
undoRedoService.pushUndoRedo({
unitID: 'unit-1',
undoMutations: [{ id: MUTATION_ID, params: { label: 'undo-1' } }],
redoMutations: [{ id: MUTATION_ID, params: { label: 'redo-1' } }],
id: 'item-1',
});
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('item-1');
expect(undoRedoService.pitchTopRedoElement()).toBeNull();
undoRedoService.popUndoToRedo();
expect(undoRedoService.pitchTopUndoElement()).toBeNull();
expect(undoRedoService.pitchTopRedoElement()?.id).toBe('item-1');
undoRedoService.pushUndoRedo({
unitID: 'unit-1',
undoMutations: [{ id: MUTATION_ID, params: { label: 'undo-2' } }],
redoMutations: [{ id: MUTATION_ID, params: { label: 'redo-2' } }],
id: 'item-2',
});
expect(undoRedoService.pitchTopRedoElement()).toBeNull();
expect(statuses.at(-1)).toEqual({ undos: 1, redos: 0 });
});
it('should execute undo and redo commands against registered mutations', () => {
undoRedoService.pushUndoRedo({
unitID: 'unit-1',
undoMutations: [{ id: MUTATION_ID, params: { label: 'undo-run' } }],
redoMutations: [{ id: MUTATION_ID, params: { label: 'redo-run' } }],
id: 'run',
});
expect(commandService.syncExecuteCommand(UndoCommandId)).toBe(true);
expect(mutationLog).toEqual(['undo-run']);
expect(undoRedoService.pitchTopRedoElement()?.id).toBe('run');
expect(commandService.syncExecuteCommand(RedoCommandId)).toBe(true);
expect(mutationLog).toEqual(['undo-run', 'redo-run']);
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('run');
});
it('should rollback the latest undo item and support batching', () => {
const batching = undoRedoService.__tempBatchingUndoRedo('unit-1');
undoRedoService.pushUndoRedo({
unitID: 'unit-1',
undoMutations: [{ id: MUTATION_ID, params: { label: 'undo-batch-1' } }],
redoMutations: [{ id: MUTATION_ID, params: { label: 'redo-batch-1' } }],
id: 'batch',
});
undoRedoService.pushUndoRedo({
unitID: 'unit-1',
undoMutations: [{ id: MUTATION_ID, params: { label: 'undo-batch-2' } }],
redoMutations: [{ id: MUTATION_ID, params: { label: 'redo-batch-2' } }],
id: 'batch',
});
batching.dispose();
const top = undoRedoService.pitchTopUndoElement();
expect(top?.undoMutations).toHaveLength(2);
expect(top?.redoMutations).toHaveLength(2);
undoRedoService.rollback('batch');
expect(mutationLog).toEqual(['undo-batch-1', 'undo-batch-2']);
expect(undoRedoService.pitchTopUndoElement()).toBeNull();
});
it('should resolve focused unit id from sheet editor contexts and clear unit stacks', () => {
contextService.setContextValue(FOCUSING_SHEET, true);
contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, true);
undoRedoService.pushUndoRedo({
unitID: DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
undoMutations: [{ id: MUTATION_ID, params: { label: 'fx-undo' } }],
redoMutations: [],
id: 'fx',
});
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('fx');
contextService.setContextValue(FOCUSING_FX_BAR_EDITOR, false);
contextService.setContextValue(EDITOR_ACTIVATED, true);
undoRedoService.pushUndoRedo({
unitID: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
undoMutations: [{ id: MUTATION_ID, params: { label: 'doc-undo' } }],
redoMutations: [],
id: 'doc',
});
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('doc');
undoRedoService.clearUndoRedo(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
expect(undoRedoService.pitchTopUndoElement()).toBeNull();
});
it('should still move stacks on failed undo or redo execution and reject nested batching', () => {
undoRedoService.pushUndoRedo({
unitID: 'unit-1',
undoMutations: [{ id: MUTATION_ID, params: { label: 'undo-fail', fail: true } }],
redoMutations: [{ id: MUTATION_ID, params: { label: 'redo-fail', fail: true } }],
id: 'fail',
});
expect(commandService.syncExecuteCommand(UndoCommandId)).toBe(true);
expect(undoRedoService.pitchTopUndoElement()).toBeNull();
expect(undoRedoService.pitchTopRedoElement()?.id).toBe('fail');
expect(commandService.syncExecuteCommand(RedoCommandId)).toBe(true);
expect(undoRedoService.pitchTopRedoElement()).toBeNull();
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('fail');
const batching = undoRedoService.__tempBatchingUndoRedo('unit-1');
expect(() => undoRedoService.__tempBatchingUndoRedo('unit-1')).toThrowError(/cannot batching undo redo twice/);
batching.dispose();
});
});
@@ -0,0 +1,78 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { ColorKit, isBlackColor, isWhiteColor } from '../color/color-kit';
describe('ColorKit', () => {
it('should parse named, hex, rgb, hsl and hsv colors', () => {
expect(new ColorKit('white').toRgb()).toEqual({ r: 255, g: 255, b: 255, a: 1 });
expect(new ColorKit('#ffffff00').toRgb()).toEqual({ r: 255, g: 255, b: 255, a: 0 });
expect(new ColorKit('rgb(1,2,3)').toRgb()).toEqual({ r: 1, g: 2, b: 3 });
expect(new ColorKit('hsl(0,100,50)').toRgb()).toEqual({ r: 255, g: 0, b: 0 });
expect(new ColorKit('hsv(240,100,100)').toRgb()).toEqual({ r: 0, g: 0, b: 255 });
});
it('should stringify colors and support short hex output when possible', () => {
const color = new ColorKit({ r: 255, g: 255, b: 255, a: 0 });
expect(color.toRgbString()).toBe('rgba(255,255,255,0)');
expect(color.toString()).toBe('rgba(255,255,255,0)');
expect(color.toHexString()).toBe('#ffffff00');
expect(color.toHexString(true)).toBe('#fff0');
});
it('should adjust lightness and alpha and calculate brightness metrics', () => {
const color = new ColorKit('#808080');
expect(color.lighten(30).toHexString()).toBe('#cccccc');
expect(color.darken(60).toHexString()).toBe('#000000');
expect(color.setAlpha(0.25).toRgbString()).toBe('rgba(128,128,128,0.25)');
expect(color.getAlpha()).toBe(1);
expect(color.getBrightness()).toBe(128);
expect(color.getLuminance()).toBeGreaterThan(0);
expect(color.isDark()).toBe(false);
expect(color.isLight()).toBe(true);
});
it('should mix colors, clamp amount and compute contrast ratio', () => {
expect(ColorKit.mix('#000000', '#ffffff', -1).toHexString()).toBe('#000000');
expect(ColorKit.mix('#000000', '#ffffff', 2).toHexString()).toBe('#ffffff');
expect(ColorKit.mix('#000000', '#ffffff', 0.5).toHexString()).toBe('#808080');
expect(ColorKit.getContrastRatio('#000000', '#ffffff')).toBe(21);
});
it('should mark invalid colors and throw on malformed known formats', () => {
const invalid = new ColorKit('not-a-color');
expect(invalid.isValid).toBe(false);
expect(invalid.toRgb()).toEqual({ r: 0, g: 0, b: 0, a: 0 });
expect(() => new ColorKit('#1')).toThrowError(/illegal hex color/);
expect(() => new ColorKit('rgb(1,2)')).toThrowError(/illegal rgb color/);
});
it('should recognize black and white strings across formats', () => {
expect(isBlackColor('#000')).toBe(true);
expect(isBlackColor('rgba(0,0,0,0.5)')).toBe(true);
expect(isBlackColor('hsl(0,0,0)')).toBe(true);
expect(isBlackColor('rgb(1,0,0)')).toBe(false);
expect(isWhiteColor('#fff')).toBe(true);
expect(isWhiteColor('rgba(255,255,255,0.5)')).toBe(true);
expect(isWhiteColor('hsl(0,0,100)')).toBe(true);
expect(isWhiteColor('rgb(254,255,255)')).toBe(false);
});
});
@@ -14,8 +14,60 @@
* limitations under the License.
*/
import type { ICellData, ICellWithCoord, IRange, ISelectionCell } from '../../sheets/typedef';
import type { IDocumentData } from '../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { cellToRange, isFormulaId, isFormulaString } from '../common';
import {
RANGE_TYPE,
} from '../../sheets/typedef';
import { BorderStyleTypes, ThemeColorType } from '../../types/enum';
import {
BaselineOffset,
HorizontalAlign,
TextDirection,
VerticalAlign,
WrapStrategy,
} from '../../types/enum/text-style';
import {
cellToRange,
convertCellToRange,
covertCellValue,
covertCellValues,
getBorderStyleType,
getColorStyle,
getDocsUpdateBody,
handleStyleToString,
isCellCoverable,
isEmptyCell,
isFormulaId,
isFormulaString,
isValidRange,
makeCellRangeToRangeData,
} from '../common';
function createCellWithCoord(overrides?: Partial<ICellWithCoord>): ICellWithCoord {
return {
actualRow: 1,
actualColumn: 2,
startX: 10,
endX: 20,
startY: 30,
endY: 40,
isMerged: false,
isMergedMainCell: false,
mergeInfo: {
startRow: 0,
startColumn: 0,
endRow: 2,
endColumn: 3,
startX: 1,
endX: 4,
startY: 5,
endY: 6,
},
...overrides,
} as ICellWithCoord;
}
describe('Test common', () => {
it('Test cellToRange', () => {
@@ -45,4 +97,191 @@ describe('Test common', () => {
expect(isFormulaId({})).toBe(false);
expect(isFormulaId({ f: '' })).toBe(false);
});
it('should convert merged and merged-main cells into ranges', () => {
expect(convertCellToRange(createCellWithCoord())).toEqual({
startRow: 1,
startColumn: 2,
endRow: 1,
endColumn: 2,
startY: 30,
endY: 40,
startX: 10,
endX: 20,
});
expect(convertCellToRange(createCellWithCoord({ isMerged: true }))).toEqual({
startRow: 0,
startColumn: 0,
endRow: 2,
endColumn: 3,
startY: 5,
endY: 6,
startX: 1,
endX: 4,
});
expect(convertCellToRange(createCellWithCoord({ isMergedMainCell: true }))).toEqual({
startRow: 1,
startColumn: 2,
endRow: 2,
endColumn: 3,
startY: 5,
endY: 6,
startX: 1,
endX: 4,
});
});
it('should build plain ranges and check empty or coverable cells', () => {
expect(makeCellRangeToRangeData(null)).toBeUndefined();
expect(makeCellRangeToRangeData({
actualRow: 4,
actualColumn: 5,
isMerged: true,
isMergedMainCell: false,
startRow: 1,
startColumn: 2,
endRow: 6,
endColumn: 7,
} as ISelectionCell)).toEqual({
startRow: 1,
startColumn: 2,
endRow: 6,
endColumn: 7,
});
expect(isEmptyCell(null)).toBe(true);
expect(isEmptyCell({ v: '', p: null } as ICellData)).toBe(true);
expect(isEmptyCell({ v: '', p: { body: { dataStream: 'x' } } } as unknown as ICellData)).toBe(false);
expect(isCellCoverable({ v: '', p: null } as unknown as ICellData)).toBe(true);
expect(isCellCoverable({ v: '', p: null, coverable: false } as never)).toBe(false);
});
it('should resolve colors and style strings', () => {
expect(getColorStyle({ rgb: 'rgb(255, 0, 0)' })).toBe('#ff0000');
expect(getColorStyle({ th: ThemeColorType.ACCENT1 })).toBe('rgb(68,114,196)');
expect(getColorStyle(null)).toBeNull();
const style = handleStyleToString({
ff: 'Mono',
fs: 20,
it: 1,
bl: 1,
ul: { s: 1, cl: { rgb: '#00ff00' }, t: 'solid' as never },
st: { s: 1, cl: { rgb: '#ff0000' }, t: 'solid' as never },
ol: { s: 1, cl: { rgb: '#0000ff' }, t: 'solid' as never },
bg: { rgb: '#ffffff' },
bd: { b: { s: BorderStyleTypes.THIN, cl: { rgb: '#000000' } } },
cl: { rgb: '#111111' },
va: BaselineOffset.SUPERSCRIPT,
td: TextDirection.RIGHT_TO_LEFT,
tr: { a: 45, v: 1 },
ht: HorizontalAlign.CENTER,
vt: VerticalAlign.MIDDLE,
tb: WrapStrategy.WRAP,
pd: { t: 1, r: 2, b: 3, l: 4 },
} as never);
expect(style).toContain('font-family: Mono;');
expect(style).toContain('font-size: 10pt;');
expect(style).toContain('font-style: italic;');
expect(style).toContain('font-weight: bold;');
expect(style).toContain('text-decoration');
expect(style).toContain('background: #ffffff;');
expect(style).toContain('border-bottom: 0.5pt solid #000000;');
expect(style).toContain('color: #111111;');
expect(style).toContain('vertical-align: super;');
expect(style).toContain('direction: rtl;');
expect(style).toContain('--data-rotate: (45deg ,1);');
expect(style).toContain('text-align: center;');
expect(style).toContain('white-space: normal;');
expect(style).toContain('padding-left: 4pt;');
const cellStyle = handleStyleToString({
bd: { b: { s: BorderStyleTypes.THIN, cl: { rgb: '#000000' } } },
tr: { a: 30 },
tb: WrapStrategy.CLIP,
} as never, true);
expect(cellStyle).toBe('');
});
it('should map border types and document segments', () => {
expect(getBorderStyleType('none')).toBe(BorderStyleTypes.NONE);
expect(getBorderStyleType('0.5pt dashed')).toBe(BorderStyleTypes.DASHED);
expect(getBorderStyleType('1pt solid')).toBe(BorderStyleTypes.MEDIUM);
expect(getBorderStyleType('unknown solid')).toBe(BorderStyleTypes.THIN);
const model: IDocumentData = {
id: 'doc-1',
body: { dataStream: 'body' },
headers: { h1: { headerId: 'h1', body: { dataStream: 'header' } } },
footers: { f1: { footerId: 'f1', body: { dataStream: 'footer' } } },
documentStyle: {
pageSize: { width: 100, height: 100 },
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
},
} as IDocumentData;
expect(getDocsUpdateBody(model)?.dataStream).toBe('body');
expect(getDocsUpdateBody(model, 'h1')?.dataStream).toBe('header');
expect(getDocsUpdateBody(model, 'f1')?.dataStream).toBe('footer');
});
it('should validate ranges and convert cell values or matrices', () => {
const worksheet = {
getRowCount: () => 10,
getColumnCount: () => 10,
};
expect(isValidRange({ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }, worksheet as never)).toBe(true);
expect(isValidRange({ startRow: -1, endRow: 1, startColumn: 0, endColumn: 1 })).toBe(false);
expect(isValidRange({ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1, rangeType: RANGE_TYPE.COLUMN })).toBe(false);
expect(isValidRange({ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1, rangeType: RANGE_TYPE.ROW })).toBe(false);
expect(isValidRange({ startRow: 0, endRow: 10, startColumn: 0, endColumn: 1 }, worksheet as never)).toBe(false);
expect(isValidRange({
startRow: Number.NaN,
endRow: Number.NaN,
startColumn: 0,
endColumn: 1,
rangeType: RANGE_TYPE.COLUMN,
})).toBe(true);
expect(covertCellValue('=SUM(1,2)')).toEqual({ f: '=SUM(1,2)', v: null, p: null });
expect(covertCellValue(1)).toEqual({ v: 1, p: null, f: null });
const cell = { v: 'text', p: null, f: null } as ICellData;
expect(covertCellValue(cell)).toBe(cell);
const mixedValues = [
[1, '=A1'],
['2%', null],
] as unknown as Parameters<typeof covertCellValues>[0];
expect(covertCellValues(mixedValues, { startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 } as IRange)).toEqual({
0: {
0: { v: 1, p: null, f: null },
1: { f: '=A1', v: null, p: null },
},
1: {
0: {
v: 0.02,
p: null,
f: null,
s: { n: { pattern: '0%' } },
},
1: null,
},
});
expect(covertCellValues({
3: { 4: '=B2' },
}, { startRow: 3, endRow: 3, startColumn: 4, endColumn: 4 } as IRange)).toEqual({
3: {
4: { f: '=B2', v: null, p: null },
},
});
});
});
@@ -0,0 +1,36 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { generateIntervalsByPoints, mergeIntervals } from '../intervals';
describe('interval helpers', () => {
it('should generate merged intervals from points and ranges', () => {
expect(generateIntervalsByPoints([])).toEqual([]);
expect(generateIntervalsByPoints([0, [1, 3], 4, 5, [8, 10], 12, 11])).toEqual([
[0, 5],
[8, 12],
]);
});
it('should merge overlapping and contiguous intervals', () => {
expect(mergeIntervals([])).toEqual([]);
expect(mergeIntervals([[5, 6], [1, 2], [2, 4], [9, 10], [8, 8]])).toEqual([
[1, 6],
[8, 10],
]);
});
});
@@ -0,0 +1,95 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Disposable, DisposableCollection, RCDisposable, RxDisposable, toDisposable } from '../lifecycle';
class TestDisposable extends Disposable {
assertUsable() {
this.ensureNotDisposed();
}
}
class TestRxDisposable extends RxDisposable {
getDispose$() {
return this.dispose$;
}
}
describe('lifecycle helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should convert callbacks, subscriptions and empty values to disposables', () => {
const callback = vi.fn();
const callbackDisposable = toDisposable(callback);
callbackDisposable.dispose();
callbackDisposable.dispose();
expect(callback).toHaveBeenCalledTimes(1);
const subject = new Subject<void>();
const unsubscribeSpy = vi.spyOn(subject, 'unsubscribe');
toDisposable(subject).dispose();
expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
expect(() => toDisposable(undefined as never).dispose()).not.toThrow();
});
it('should manage a disposable collection and optional self-retention', () => {
const collection = new DisposableCollection();
const callback = vi.fn();
const handle = collection.add(callback);
handle.dispose(true);
expect(callback).not.toHaveBeenCalled();
collection.add(callback);
collection.dispose();
expect(callback).toHaveBeenCalledTimes(1);
});
it('should track disposed state in Disposable and RxDisposable', () => {
const disposable = new TestDisposable();
const child = { dispose: vi.fn() };
disposable.disposeWithMe(child);
disposable.assertUsable();
disposable.dispose();
expect(child.dispose).toHaveBeenCalledTimes(1);
expect(() => disposable.assertUsable()).toThrowError(/disposed/);
const rxDisposable = new TestRxDisposable();
const completed = vi.fn();
rxDisposable.getDispose$().subscribe({ complete: completed });
rxDisposable.dispose();
expect(completed).toHaveBeenCalledTimes(1);
});
it('should dispose root resource when RCDisposable reference reaches zero', () => {
const root = { dispose: vi.fn() };
const rcDisposable = new RCDisposable(root);
rcDisposable.inc();
rcDisposable.inc();
rcDisposable.dec();
expect(root.dispose).not.toHaveBeenCalled();
rcDisposable.dec();
expect(root.dispose).toHaveBeenCalledTimes(1);
expect(() => rcDisposable.inc()).toThrowError(/disposed/);
});
});
@@ -0,0 +1,58 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import {
DEFAULT_NUMBER_FORMAT,
DEFAULT_TEXT_FORMAT,
DEFAULT_TEXT_FORMAT_EXCEL,
getNumfmtParseValueFilter,
isDefaultFormat,
isPatternEqualWithoutDecimal,
isTextFormat,
} from '../numfmt';
describe('numfmt helpers', () => {
it('should recognize text and default number formats', () => {
expect(isTextFormat(DEFAULT_TEXT_FORMAT)).toBe(true);
expect(isTextFormat(DEFAULT_TEXT_FORMAT_EXCEL)).toBe(true);
expect(isTextFormat('0.00')).toBe(false);
expect(isDefaultFormat(null)).toBe(true);
expect(isDefaultFormat(undefined)).toBe(true);
expect(isDefaultFormat(DEFAULT_NUMBER_FORMAT)).toBe(true);
expect(isDefaultFormat('0.00')).toBe(false);
});
it('should compare patterns while ignoring decimal precision differences', () => {
expect(isPatternEqualWithoutDecimal('0.00', '0.0')).toBe(true);
expect(isPatternEqualWithoutDecimal('$#,##0.00', '$#,##0')).toBe(true);
expect(isPatternEqualWithoutDecimal('0.00', '0%')).toBe(false);
expect(isPatternEqualWithoutDecimal('', '0.0')).toBe(false);
});
it('should filter invalid parse results and keep valid numfmt parses', () => {
expect(getNumfmtParseValueFilter('1 23')).toBeNull();
expect(getNumfmtParseValueFilter('5A')).toBeNull();
expect(getNumfmtParseValueFilter('1000,')).toBeNull();
expect(getNumfmtParseValueFilter('1,00,0')).toBeNull();
expect(getNumfmtParseValueFilter('2/3')?.z).toBe('m/d');
expect(getNumfmtParseValueFilter('5 A')?.z).toBe('h:mm AM/PM');
expect(getNumfmtParseValueFilter('$1000')?.z).toBe('$#,##0');
expect(getNumfmtParseValueFilter('25%')?.z).toBe('0%');
});
});
@@ -15,7 +15,16 @@
*/
import { describe, expect, it } from 'vitest';
import { moveMatrixArray, ObjectMatrix, spliceArray } from '../object-matrix';
import {
concatMatrixArray,
getArrayLength,
insertMatrixArray,
mapObjectMatrix,
moveMatrixArray,
ObjectMatrix,
sliceMatrixArray,
spliceArray,
} from '../object-matrix';
describe('test ObjectMatrix', () => {
const getPrimitiveObj = () => ({
@@ -50,7 +59,7 @@ describe('test ObjectMatrix', () => {
const rowList: number[] = [];
const colList: number[] = [];
matrix.forValue((row, col, value) => {
matrix.forValue((row, col, _value) => {
rowList.push(row);
colList.push(col);
});
@@ -207,4 +216,62 @@ describe('test ObjectMatrix', () => {
startRow: 0,
}]);
});
it('should map and measure sparse object matrices', () => {
expect(getArrayLength({ 2: 'a', 5: 'b' })).toBe(6);
expect(mapObjectMatrix({ 1: { 2: 3 }, 4: { 5: 6 } }, (row, col, value) => row + col + value)).toEqual({
1: { 2: 6 },
4: { 5: 15 },
});
});
it('should insert, concat and slice matrix arrays', () => {
const array = { 0: 'a', 2: 'c' };
insertMatrixArray(1, 'b', array);
expect(array).toEqual({ 0: 'a', 1: 'b', 3: 'c' });
expect(concatMatrixArray({ 0: 'a', 3: 'b' }, { 1: 'c' })).toEqual({
0: 'a',
1: 'b',
2: 'c',
});
expect(sliceMatrixArray(1, 3, { 0: 'a', 1: 'b', 3: 'd' })).toEqual({
0: 'b',
1: 'd',
});
});
it('should expose fragment, slice and array conversions', () => {
const matrix = new ObjectMatrix({
1: { 2: { value: 1 }, 3: { value: 2 } },
2: { 2: { value: 3 } },
});
expect(matrix.getFragment(1, 2, 2, 3).getMatrix()).toEqual({
0: { 0: { value: 1 }, 1: { value: 2 } },
1: { 0: { value: 3 }, 1: undefined },
});
const slice = matrix.getSlice(1, 2, 2, 3);
expect(slice.getMatrix()).toEqual({
1: { 2: { value: 1 }, 3: { value: 2 } },
2: { 2: { value: 3 } },
});
expect(slice.getValue(1, 2)).not.toBe(matrix.getValue(1, 2));
const expectedArray = new Array(3);
expectedArray[1] = new Array(4);
expectedArray[1][2] = { value: 1 };
expectedArray[1][3] = { value: 2 };
expectedArray[2] = new Array(3);
expectedArray[2][2] = { value: 3 };
expect(matrix.toArray()).toEqual(expectedArray);
expect(matrix.toFullArray()).toEqual([
[undefined, undefined, undefined, undefined],
[undefined, undefined, { value: 1 }, { value: 2 }],
[undefined, undefined, { value: 3 }, undefined],
]);
expect(matrix.toNativeArray()).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }]);
});
});
@@ -0,0 +1,43 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { ABCToNumber, numberToABC, numberToListABC, repeatStringNumTimes } from '../sequence';
describe('sequence helpers', () => {
it('should convert between letters and zero-based indices', () => {
expect(ABCToNumber('A')).toBe(0);
expect(ABCToNumber('Z')).toBe(25);
expect(ABCToNumber('AA')).toBe(26);
expect(ABCToNumber('az')).toBe(51);
expect(ABCToNumber('')).toBeNaN();
expect(ABCToNumber(null as never)).toBeNaN();
expect(numberToABC(0)).toBe('A');
expect(numberToABC(25)).toBe('Z');
expect(numberToABC(26)).toBe('AA');
expect(numberToABC(51)).toBe('AZ');
});
it('should repeat strings and build list-style letters', () => {
expect(repeatStringNumTimes('ab', 3)).toBe('ababab');
expect(repeatStringNumTimes('ab', 0)).toBe('');
expect(numberToListABC(0)).toBe('a');
expect(numberToListABC(25, true)).toBe('Z');
expect(numberToListABC(26)).toBe('aa');
expect(numberToListABC(27, true)).toBe('BB');
});
});
@@ -0,0 +1,128 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { afterEach, describe, expect, it, vi } from 'vitest';
import { createREGEXFromWildChar, generateRandomId, Tools } from '../tools';
class CustomProto {
value = 1;
}
describe('Tools extra coverage', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should handle basic string and index helpers', () => {
expect(Tools.deleteNull({ a: 1, b: null, c: undefined })).toEqual({ a: 1 });
expect(Tools.stringAt(0)).toBe('A');
expect(Tools.stringAt(26)).toBe('AA');
expect(Tools.indexAt('A')).toBe(0);
expect(Tools.indexAt('AA')).toBe(26);
expect(Tools.deleteBlank(' a b\n c ')).toBe('abc');
expect(Tools.deleteBlank()).toBeUndefined();
expect(Tools.getClassName(new CustomProto())).toBe('CustomProto');
});
it('should merge, compare and clone complex values', () => {
const merged = Tools.deepMerge(
{ a: { b: 1 }, list: [1], keep: true },
{ a: { c: 2 }, list: [2, 3], extra: 'x' }
);
expect(merged).toEqual({ a: { b: 1, c: 2 }, list: [2, 3], keep: true, extra: 'x' });
expect(Tools.numberFixed(1.236, 2)).toBe(1.24);
expect(Tools.diffValue([1, { a: 2 }], [1, { a: 2 }])).toBe(true);
expect(Tools.diffValue(new Date('2024-01-01'), new Date('2024-01-01'))).toBe(true);
expect(Tools.diffValue(/a/i, /a/i)).toBe(true);
expect(Tools.diffValue({ a: 1 }, { a: 2 })).toBe(false);
const original = new CustomProto();
const complex = {
date: new Date('2024-01-01T00:00:00.000Z'),
regexp: /test/gi,
nested: [1, { x: 2 }],
proto: original,
};
const cloned = Tools.deepClone(complex);
expect(cloned).not.toBe(complex);
expect(cloned.date).not.toBe(complex.date);
expect(cloned.date.getTime()).toBe(complex.date.getTime());
expect(cloned.regexp).not.toBe(complex.regexp);
expect(cloned.regexp.toString()).toBe(complex.regexp.toString());
expect(cloned.nested).not.toBe(complex.nested);
expect(cloned.nested).toEqual(complex.nested);
expect(cloned.proto).not.toBe(original);
expect(Object.getPrototypeOf(cloned.proto)).toBe(CustomProto.prototype);
});
it('should expose type guards and primitive helpers', () => {
expect(Tools.isDefine(0)).toBe(true);
expect(Tools.isDefine(null)).toBe(false);
expect(Tools.isBlank(' ')).toBe(true);
expect(Tools.isBlank('x')).toBe(false);
expect(Tools.isBlank(null)).toBe(true);
expect(Tools.isPlainObject({ a: 1 })).toBe(true);
expect(Tools.isPlainObject(new CustomProto())).toBe(false);
expect(Tools.isDate(new Date())).toBe(true);
expect(Tools.isRegExp(/a/)).toBe(true);
expect(Tools.isArray([1])).toBe(true);
expect(Tools.isString('x')).toBe(true);
expect(Tools.isNumber(1)).toBe(true);
expect(Tools.isStringNumber('1.2')).toBe(true);
expect(Tools.isStringNumber('abc')).toBe(false);
expect(Tools.isObject({})).toBe(true);
expect(Tools.isEmptyObject({})).toBe(true);
expect(Tools.isEmptyObject({ a: 1 })).toBe(false);
});
it('should handle collection and numeric helpers', () => {
const input = { a: 1, b: null, c: { d: undefined, e: 2 } };
expect(Tools.removeNull(input)).toEqual({ a: 1, c: { e: 2 } });
expect(Tools.fillTwoDimensionalArray(2, 3, 'x')).toEqual([
['x', 'x', 'x'],
['x', 'x', 'x'],
]);
expect(Tools.numToWord(27)).toBe('AA');
expect(Tools.ABCatNum('AZ')).toBe(51);
expect(Tools.ABCatNum('')).toBeNaN();
expect(Tools.chatAtABC(51)).toBe('AZ');
expect(Tools.commonExtend({ a: 1, b: 2 }, { b: null, c: 3 })).toEqual({ a: 1, b: 2, c: 3 });
expect(Tools.hasIntersectionBetweenTwoRanges(1, 3, 3, 5)).toBe(true);
expect(Tools.hasIntersectionBetweenTwoRanges(1, 2, 3, 4)).toBe(false);
expect(Tools.isStartValidPosition('_name')).toBe(true);
expect(Tools.isStartValidPosition('1name')).toBe(false);
expect(Tools.isValidParameter('valid_name')).toBe(true);
expect(Tools.isValidParameter('bad name')).toBe(false);
expect(Tools.clamp(20, 1, 10)).toBe(10);
expect(Tools.clamp(-1, 1, 10)).toBe(1);
});
it('should read timing, ids and wildcard regex helpers', () => {
vi.spyOn(globalThis.performance, 'now').mockReturnValue(123.456);
expect(Tools.now()).toBe(123.456);
const customId = generateRandomId(6, 'ab');
expect(customId).toMatch(/^[ab]{6}$/);
expect(generateRandomId(5)).toHaveLength(5);
const regex = createREGEXFromWildChar('file-??-*.ts');
expect(regex.test('file-ab-index.ts')).toBe(true);
expect(regex.test('file-a-index.ts')).toBe(false);
});
});
-497
View File
@@ -1,497 +0,0 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { Nullable } from '../types';
import { THEME_COLORS } from '../../types/const/theme-color-map';
import { ColorType, ThemeColors, ThemeColorType } from '../../types/enum';
/**
* @deprecated
*/
export class ColorBuilder {
private _themeValue: ThemeColorType = ThemeColorType.LIGHT1;
private _themeColors: ThemeColors;
private _themeTint: number;
private _rgbValue: string = '';
private _colorType: ColorType;
constructor() {
this._colorType = ColorType.UNSUPPORTED;
this._themeColors = ThemeColors.OFFICE;
this._themeTint = 0;
}
asRgbColor(): RgbColor {
return new RgbColor(this._rgbValue, this);
}
asThemeColor(): ThemeColor {
return new ThemeColor(this._themeValue, this._themeTint, this._themeColors, this);
}
build(): Nullable<Color> {
switch (this._colorType) {
case ColorType.THEME: {
return this.asThemeColor();
}
case ColorType.RGB: {
return this.asRgbColor();
}
case ColorType.UNSUPPORTED: {
throw new Error('unsupported color type');
}
}
}
setRgbColor(cssString: string): ColorBuilder {
this._colorType = ColorType.RGB;
this._rgbValue = cssString;
return this;
}
setThemeColors(value: ThemeColors) {
this._colorType = ColorType.THEME;
this._themeColors = value;
}
setThemeTint(value: number) {
this._colorType = ColorType.THEME;
this._themeTint = value;
}
setThemeColor(theme: ThemeColorType): ColorBuilder {
this._colorType = ColorType.THEME;
this._themeValue = theme;
return this;
}
getColorType(): ColorType {
return this._colorType;
}
}
/**
* @deprecated
*/
export class Color {
protected _builder: ColorBuilder;
constructor(builder: ColorBuilder) {
this._builder = builder;
}
static rgbColorToHexValue(color: RgbColor): string {
return `#${((1 << 24) + (color.getRed() << 16) + (color.getGreen() << 8) + color.getBlue())
.toString(16)
.slice(1)}`;
}
static hexValueToRgbColor(hexValue: string): RgbColor {
if (hexValue) {
if (hexValue.indexOf('#') > -1) {
hexValue = hexValue.substring(1);
}
} else {
hexValue = '#000000';
}
const r = +`0x${hexValue[0]}${hexValue[1]}`;
const g = +`0x${hexValue[2]}${hexValue[3]}`;
const b = +`0x${hexValue[4]}${hexValue[5]}`;
return new ColorBuilder().setRgbColor(`rgb(${r},${g},${b})`).asRgbColor();
}
static hexToRgbString(hex: string): Nullable<string> {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
let string = null;
if (result) {
const r = Number.parseInt(result[1], 16);
const g = Number.parseInt(result[2], 16);
const b = Number.parseInt(result[3], 16);
string = `rgba(${r},${g},${b})`;
}
return string;
}
asRgbColor(): RgbColor {
return this._builder.asRgbColor();
}
asThemeColor(): ThemeColor {
return this._builder.asThemeColor();
}
getColorType(): ColorType {
return this._builder.getColorType();
}
clone(): Color {
return new Color(this._builder);
}
equals(color: Color): boolean {
return false;
}
}
export class HLSColor {
private _saturation: number = 0;
private _hue: number = 0;
private _lightness: number = 0;
private _alpha: number = 0;
constructor(rgbColor: RgbColor) {
const red = rgbColor.getRed() / 255;
const green = rgbColor.getGreen() / 255;
const blue = rgbColor.getBlue() / 255;
const alpha = rgbColor.getAlpha() / 255;
const min = Math.min(red, Math.min(green, blue));
const max = Math.max(red, Math.max(green, blue));
const delta = max - min;
if (max === min) {
this._hue = 0;
this._saturation = 0;
this._lightness = max;
return;
}
this._lightness = (min + max) / 2;
if (this._lightness < 0.5) {
this._saturation = delta / (max + min);
} else {
this._saturation = delta / (2.0 - max - min);
}
if (red === max) {
this._hue = (green - blue) / delta;
}
if (green === max) {
this._hue = 2.0 + (blue - red) / delta;
}
if (blue === max) {
this._hue = 4.0 + (red - green) / delta;
}
this._hue *= 60;
if (this._hue < 0) {
this._hue += 360;
}
this._alpha = alpha;
}
asRgbColor(): RgbColor {
const builder = new ColorBuilder();
if (this._saturation === 0) {
builder.setRgbColor(
`rgba(${this._lightness * 255},${this._lightness * 255},${this._lightness * 255},${this._alpha * 255})`
);
return builder.asRgbColor();
}
let t1;
if (this._lightness < 0.5) {
t1 = this._lightness * (1.0 + this._saturation);
} else {
t1 = this._lightness + this._saturation - this._lightness * this._saturation;
}
const t2 = 2.0 * this._lightness - t1;
const hue = this._hue / 360;
const tR = hue + 1.0 / 3.0;
const red = this.setColor(t1, t2, tR);
const green = this.setColor(t1, t2, hue);
const tB = hue - 1.0 / 3.0;
const blue = this.setColor(t1, t2, tB);
builder.setRgbColor(
`rgba(${Math.round(red * 255)},${Math.round(green * 255)},${Math.round(blue * 255)},${this._alpha * 255})`
);
return builder.asRgbColor();
}
getLightness() {
return this._lightness;
}
getHue() {
return this._hue;
}
getSaturation() {
return this._saturation;
}
getAlpha() {
return this._alpha;
}
setColor(t1: number, t2: number, t3: number): number {
if (t3 < 0) {
t3 += 1.0;
}
if (t3 > 1) {
t3 -= 1.0;
}
let color: number;
if (6.0 * t3 < 1) {
color = t2 + (t1 - t2) * 6.0 * t3;
} else if (2.0 * t3 < 1) {
color = t1;
} else if (3.0 * t3 < 2) {
color = t2 + (t1 - t2) * (2.0 / 3.0 - t3) * 6.0;
} else {
color = t2;
}
return color;
}
setLightness(lightness: number): void {
this._lightness = lightness;
}
}
export class RgbColor extends Color {
static RGB_COLOR_AMT: number = 0;
static RGBA_EXTRACT: RegExp = new RegExp(
'\\s*rgba\\s*\\((\\s*\\d+\\s*),(\\s*\\d+\\s*),(\\s*\\d+\\s*),(\\s*\\d.\\d|\\d\\s*)\\)\\s*'
);
static RGB_EXTRACT: RegExp = new RegExp('\\s*rgb\\s*\\((\\s*\\d+\\s*),(\\s*\\d+\\s*),(\\s*\\d+\\s*)\\)\\s*');
private _cssString: string;
private _red: number;
private _green: number;
private _blue: number;
private _alpha: number;
constructor(cssString: string, builder: ColorBuilder) {
super(builder);
let match = cssString.match(RgbColor.RGBA_EXTRACT);
if (match) {
const red = +match[1];
const green = +match[2];
const blue = +match[3];
const alpha = +match[4];
this._cssString = cssString;
this._red = red;
this._green = green;
this._blue = blue;
this._alpha = alpha;
return;
}
match = cssString.match(RgbColor.RGB_EXTRACT);
if (match) {
const red = +match[1];
const green = +match[2];
const blue = +match[3];
this._cssString = cssString;
this._red = red;
this._green = green;
this._blue = blue;
this._alpha = 1;
return;
}
throw new Error('Invalid rgba or rgb color');
}
asHexString(): string {
return Color.rgbColorToHexValue(this);
}
getRed(): number {
let r = this._red + RgbColor.RGB_COLOR_AMT;
if (r > 255) {
r = 255;
} else if (r < 0) {
r = 0;
}
return r;
}
getGreen(): number {
let g = this._green + RgbColor.RGB_COLOR_AMT;
if (g > 255) {
g = 255;
} else if (g < 0) {
g = 0;
}
return g;
}
getBlue(): number {
let b = this._blue + RgbColor.RGB_COLOR_AMT;
if (b > 255) {
b = 255;
} else if (b < 0) {
b = 0;
}
return b;
}
getAlpha(): number {
return this._alpha;
}
override getColorType(): ColorType {
return ColorType.RGB;
}
override clone(): RgbColor {
return new RgbColor(this._cssString, this._builder);
}
override asThemeColor(): ThemeColor {
throw new Error('rgb color not support to themeColor');
}
override equals(color: Color): boolean {
if (color instanceof RgbColor) {
return (
color._red === this._red &&
color._blue === this._blue &&
color._green === this._green &&
color._alpha === this._alpha
);
}
return false;
}
getCssString() {
return this._cssString;
}
}
export class ThemeColor extends Color {
private static _cacheThemeColor = new Map<ThemeColors, Map<ThemeColorType, RgbColor>>();
private _themeColorType: ThemeColorType;
private _themeTint: number;
private _themeColors: ThemeColors;
constructor(theme: ThemeColorType, themeTint: number, themeColors: ThemeColors, builder: ColorBuilder) {
super(builder);
this._themeColorType = theme;
this._themeTint = themeTint;
this._themeColors = themeColors;
}
lumValue(tint: number, lum: number) {
if (tint == null) {
return lum;
}
let value: number;
if (tint < 0) {
value = lum * (1.0 + tint);
} else {
value = lum * (1.0 - tint) + (255 - 255 * (1.0 - tint));
}
return value;
}
override asRgbColor(): RgbColor {
const themeColors = THEME_COLORS[this._themeColors];
if (themeColors == null) {
throw new Error('not find themeColors type');
}
const hexValue = themeColors[this._themeColorType];
if (hexValue == null) {
throw new Error('not find themeColors value');
}
let themeCache;
if (ThemeColor._cacheThemeColor.has(this._themeColors)) {
themeCache = ThemeColor._cacheThemeColor.get(this._themeColors) as Map<ThemeColorType, RgbColor>;
if (themeCache.has(this._themeColorType)) {
return themeCache.get(this._themeColorType) as RgbColor;
}
} else {
themeCache = new Map<ThemeColorType, RgbColor>();
ThemeColor._cacheThemeColor.set(this._themeColors, themeCache);
}
const hlsColor = new HLSColor(Color.hexValueToRgbColor(hexValue));
hlsColor.setLightness(this.lumValue(this._themeTint, hlsColor.getLightness() * 255) / 255);
const rgbColor = hlsColor.asRgbColor();
themeCache.set(this._themeColorType, rgbColor);
return rgbColor;
}
override clone(): ThemeColor {
return new ThemeColor(this._themeColorType, this._themeTint, this._themeColors, this._builder);
}
override equals(color: Color): boolean {
if (color instanceof ThemeColor) {
return color._themeColorType === this._themeColorType;
}
return false;
}
override getColorType(): ColorType {
return ColorType.THEME;
}
getThemeColorType(): ThemeColorType {
return this._themeColorType;
}
}
+7 -3
View File
@@ -21,15 +21,16 @@ import type { IColorStyle, IStyleData } from '../types/interfaces/i-style-data';
import type { IObjectMatrixPrimitiveType } from './object-matrix';
import type { Nullable } from './types';
import { isCellV, isICellData, RANGE_TYPE } from '../sheets/typedef';
import { THEME_COLORS } from '../types/const/theme-color-map';
import {
BaselineOffset,
BorderStyleTypes,
HorizontalAlign,
TextDirection,
ThemeColors,
VerticalAlign,
WrapStrategy,
} from '../types/enum';
import { ColorBuilder } from './color/color';
import { ColorKit } from './color/color-kit';
import { DEFAULT_NUMBER_FORMAT, getNumfmtParseValueFilter } from './numfmt';
import { ObjectMatrix } from './object-matrix';
@@ -151,8 +152,11 @@ export function getColorStyle(color: Nullable<IColorStyle>): Nullable<string> {
return new ColorKit(color.rgb).toHexString();
}
if (color.th) {
return new ColorBuilder().setThemeColor(color.th).asThemeColor().asRgbColor().getCssString();
if (color.th != null) {
const themeColor = THEME_COLORS[ThemeColors.OFFICE]?.[color.th];
if (themeColor) {
return new ColorKit(themeColor).toRgbString();
}
}
}
-1
View File
@@ -18,7 +18,6 @@ export { afterInitApply } from './after-init-apply';
export * from './array-search';
export * from './blob';
export { checkIfMove, MOVE_BUFFER_VALUE, ROTATE_BUFFER_VALUE } from './check-if-move';
export * from './color/color';
export { ColorKit, COLORS, type IRgbColor, RGB_PAREN, RGBA_PAREN } from './color/color-kit';
export * from './command-enum';
export * from './common';
@@ -89,6 +89,20 @@ function createTestWorksheetData(rowCount: number, colCount: number): IWorksheet
};
}
function benchmarkClone(fn: () => void, iterations: number, rounds: number) {
const samples: number[] = [];
for (let round = 0; round < rounds; round++) {
const start = performance.now();
for (let iteration = 0; iteration < iterations; iteration++) {
fn();
}
samples.push(performance.now() - start);
}
return samples.sort((left, right) => left - right)[Math.floor(samples.length / 2)]!;
}
describe('cloneWorksheetData', () => {
it('should correctly clone worksheet data', () => {
const original = createTestWorksheetData(10, 10);
@@ -129,40 +143,23 @@ describe('cloneWorksheetData', () => {
});
it('should be faster than Tools.deepClone for large worksheets', () => {
const testCases = [
{ rows: 100, cols: 50, label: '100x50 (5,000 cells)' },
{ rows: 500, cols: 100, label: '500x100 (50,000 cells)' },
];
const original = createTestWorksheetData(500, 100);
for (const { rows, cols, label } of testCases) {
const original = createTestWorksheetData(rows, cols);
// Warm up
for (let i = 0; i < 5; i++) {
cloneWorksheetData(original);
Tools.deepClone(original);
// Benchmark cloneWorksheetData
const iterations = 5;
const startOptimized = performance.now();
for (let i = 0; i < iterations; i++) {
cloneWorksheetData(original);
}
const endOptimized = performance.now();
const optimizedTime = endOptimized - startOptimized;
// Benchmark Tools.deepClone
const startGeneric = performance.now();
for (let i = 0; i < iterations; i++) {
Tools.deepClone(original);
}
const endGeneric = performance.now();
const genericTime = endGeneric - startGeneric;
const speedup = genericTime / optimizedTime;
// The optimized version should be at least 2x faster
expect(speedup).toBeGreaterThan(1.5);
}
const iterations = 3;
const rounds = 7;
const optimizedMedian = benchmarkClone(() => {
cloneWorksheetData(original);
}, iterations, rounds);
const genericMedian = benchmarkClone(() => {
Tools.deepClone(original);
}, iterations, rounds);
expect(optimizedMedian).toBeLessThanOrEqual(genericMedian * 1.1);
});
it('should handle empty cellData', () => {
@@ -15,8 +15,11 @@
*/
import type { Univer } from '../../univer';
import type { IWorkbookData } from '../typedef';
import type { Workbook } from '../workbook';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { BooleanNumber } from '../../types/enum';
import { LocaleType } from '../../types/enum/locale-type';
import { createCoreTestBed } from './create-core-test-bed';
describe('Test workbook', () => {
@@ -57,5 +60,90 @@ describe('Test workbook', () => {
const newSheetName2 = workbook.generateNewSheetName('Sheet');
expect(newSheetName2).toBe('Sheet2');
});
it('should save snapshot clone and update basic workbook metadata', () => {
const saved = workbook.save();
expect(saved).toEqual(workbook.getSnapshot());
expect(saved).not.toBe(workbook.getSnapshot());
expect(workbook.getName()).toBe('');
workbook.setName('Renamed');
workbook.setRev(5);
workbook.incrementRev();
workbook.setCustomMetadata({ owner: 'tester' } as never);
expect(workbook.getSnapshot().name).toBe('Renamed');
expect(workbook.getRev()).toBe(6);
expect(workbook.getCustomMetadata()).toEqual({ owner: 'tester' });
expect(workbook.getConfig()).toBe(workbook.getSnapshot());
});
it('should manage sheets and lookups', () => {
expect(workbook.addWorksheet('sheet1', 1, { id: 'sheet1', name: 'Dup' })).toBe(false);
expect(workbook.addWorksheet('sheet2', 1, { id: 'sheet2', name: 'Sheet-002' })).toBe(true);
expect(workbook.getSheetOrders()).toEqual(['sheet1', 'sheet2']);
expect(workbook.getSheetSize()).toBe(2);
expect(workbook.getIndexBySheetId('sheet2')).toBe(1);
expect(workbook.getSheetBySheetId('sheet2')?.getName()).toBe('Sheet-002');
expect(workbook.getSheetBySheetName('Sheet-002')?.getSheetId()).toBe('sheet2');
expect(workbook.getSheetByIndex(1)?.getSheetId()).toBe('sheet2');
expect(workbook.getSheetsName()).toEqual(['Sheet-001', 'Sheet-002']);
expect(workbook.getSheetIndex(workbook.getSheetBySheetId('sheet2')!)).toBe(1);
expect(workbook.getSheets()).toHaveLength(2);
expect(workbook.removeSheet('sheet2')).toBe(true);
expect(workbook.removeSheet('sheet2')).toBe(false);
});
it('should ensure unique sheet order and preserve style changes', () => {
workbook.getSnapshot().sheetOrder.push('sheet1', 'sheet1');
workbook.ensureSheetOrderUnique();
expect(workbook.getSheetOrders()).toEqual(['sheet1']);
workbook.addStyles({ custom: { bg: { rgb: '#ffffff' } } });
expect(workbook.getStyles().get('custom')).toEqual({ bg: { rgb: '#ffffff' } });
workbook.removeStyles(['custom']);
expect(workbook.getStyles().get('custom')).toBeUndefined();
});
it('should select active sheet and report hidden or visible sheets', () => {
const customData: IWorkbookData = {
id: 'book-hidden',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'Hidden Test',
styles: {},
sheetOrder: ['s1', 's2'],
sheets: {
s1: { id: 's1', name: 'Hidden', hidden: BooleanNumber.TRUE },
s2: { id: 's2', name: 'Visible' },
},
};
const hiddenTestBed = createCoreTestBed(customData);
const hiddenWorkbook = hiddenTestBed.sheet;
expect(hiddenWorkbook.ensureActiveSheet().getSheetId()).toBe('s2');
expect(hiddenWorkbook.getActiveSheet().getSheetId()).toBe('s2');
expect(hiddenWorkbook.getActiveSheetIndex()).toBe(1);
expect(hiddenWorkbook.getHiddenWorksheets()).toEqual(['s1']);
expect(hiddenWorkbook.getUnhiddenWorksheets()).toEqual(['s2']);
expect(hiddenWorkbook.checkSheetName('visible')).toBe(true);
hiddenTestBed.univer.dispose();
});
it('should load external snapshot data directly', () => {
const newConfig = {
...workbook.getSnapshot(),
name: 'Loaded Workbook',
};
workbook.load(newConfig);
expect(workbook.getSnapshot()).toBe(newConfig);
expect(workbook.getSnapshot().name).toBe('Loaded Workbook');
});
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { cleanup, render } from '@testing-library/react';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
@@ -64,4 +64,33 @@ describe('Calendar', () => {
// 15th is selected
expect(getByText('15').className).toMatch(/univer-bg-primary-600/);
});
it('should switch year when navigating across month boundaries', () => {
const date = new Date(2023, 0, 15);
const { getByLabelText, getByText } = render(
<ConfigProvider mountContainer={null} locale={{ Calendar: enUS.design.Calendar }}>
<Calendar value={date} onValueChange={onChange} />
</ConfigProvider>
);
fireEvent.click(getByLabelText('Previous Month'));
expect(getByText('2022')).toBeInTheDocument();
fireEvent.click(getByLabelText('Next Month'));
expect(getByText('2023')).toBeInTheDocument();
});
it('should disable out-of-range days and propagate time changes', () => {
const min = new Date(2023, 7, 10, 0, 0, 0);
const max = new Date(2023, 7, 20, 23, 59, 59);
const { getByText, container } = renderCalendar({ min, max, showTime: true });
const outOfRangeDay = getByText('9').closest('button') as HTMLButtonElement;
expect(outOfRangeDay).toBeDisabled();
fireEvent.click(outOfRangeDay);
const timeInput = container.querySelector('input[type="time"]') as HTMLInputElement;
fireEvent.change(timeInput, { target: { value: '11:22:33' } });
expect(onChange).toHaveBeenCalled();
});
});
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CascaderList } from '../CascaderList';
afterEach(cleanup);
@@ -47,4 +47,56 @@ describe('CascaderList', () => {
expect(container).toBeTruthy();
});
it('should select parent and child options', () => {
const onChange = vi.fn();
const options = [
{
label: 'A',
value: 'a',
children: [
{ label: 'A-1', value: 'a-1' },
{ label: 'A-2', value: 'a-2' },
],
},
{
label: 'B',
value: 'b',
},
];
const { getByText, rerender } = render(<CascaderList value={[]} options={options} onChange={onChange} />);
fireEvent.click(getByText('A'));
expect(onChange).toHaveBeenCalledWith(['a']);
rerender(<CascaderList value={['a']} options={options} onChange={onChange} />);
fireEvent.click(getByText('A-2'));
expect(onChange).toHaveBeenCalledWith(['a', 'a-2']);
});
it('should ignore same value click and rewrite path when parent changes', () => {
const onChange = vi.fn();
const options = [
{
label: 'A',
value: 'a',
children: [{ label: 'A-1', value: 'a-1' }],
},
{
label: 'B',
value: 'b',
children: [{ label: 'B-1', value: 'b-1' }],
},
];
const { getByText } = render(
<CascaderList value={['a', 'a-1']} options={options} onChange={onChange} />
);
fireEvent.click(getByText('A'));
expect(onChange).not.toHaveBeenCalledWith(['a', 'a-1']);
fireEvent.click(getByText('B'));
expect(onChange).toHaveBeenCalledWith(['b']);
});
});
@@ -0,0 +1,163 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { fireEvent, render } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { AlphaSlider } from '../AlphaSlider';
import { ColorInput } from '../ColorInput';
import '@testing-library/jest-dom/vitest';
describe('AlphaSlider', () => {
it('should update alpha during pointer interaction and emit onChanged', () => {
const onChange = vi.fn();
const onChanged = vi.fn();
const { container } = render(
<AlphaSlider hsv={[0, 100, 100]} alpha={0.3} onChange={onChange} onChanged={onChanged} />
);
const slider = container.querySelector('.univer-h-2') as HTMLDivElement;
const thumb = container.querySelector('.univer-size-2') as HTMLDivElement;
expect(slider).toBeInTheDocument();
expect(thumb).toBeInTheDocument();
Object.defineProperty(thumb, 'clientWidth', { value: 10, configurable: true });
vi.spyOn(slider, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
width: 100,
height: 8,
right: 100,
bottom: 8,
toJSON: () => ({}),
} as DOMRect);
fireEvent.pointerDown(slider, { clientX: 50 });
fireEvent.pointerMove(window, { clientX: 80 });
fireEvent.pointerUp(window);
expect(onChange).toHaveBeenCalled();
expect(onChanged).toHaveBeenCalledWith(0.3);
});
});
describe('ColorInput', () => {
it('should handle hex input validation and reset invalid value on blur', () => {
const onChange = vi.fn();
function Wrapper() {
const [hsv, setHsv] = useState<[number, number, number]>([0, 100, 100]);
return (
<ColorInput
hsv={hsv}
alpha={1}
format="hex"
onChange={(h, s, v) => {
setHsv([h, s, v]);
onChange(h, s, v);
}}
/>
);
}
const { container } = render(<Wrapper />);
const hexInput = container.querySelector('input[maxlength="6"]') as HTMLInputElement;
expect(hexInput).toBeInTheDocument();
fireEvent.change(hexInput, { target: { value: 'GGGGGG' } });
expect(onChange).not.toHaveBeenCalled();
fireEvent.change(hexInput, { target: { value: '00ff00' } });
expect(onChange).toHaveBeenCalled();
fireEvent.change(hexInput, { target: { value: '0' } });
fireEvent.blur(hexInput);
expect(hexInput.value.length).toBe(6);
});
it('should handle rgba channels and alpha bounds', () => {
const onChange = vi.fn();
function Wrapper() {
const [hsv, setHsv] = useState<[number, number, number]>([0, 100, 100]);
const [alpha, setAlpha] = useState(0.5);
return (
<ColorInput
hsv={hsv}
alpha={alpha}
format="rgba"
onChange={(h, s, v, a) => {
setHsv([h, s, v]);
setAlpha(a ?? alpha);
onChange(h, s, v, a);
}}
/>
);
}
const { container } = render(<Wrapper />);
const rgbInputs = Array.from(container.querySelectorAll('input[maxlength="3"]')) as HTMLInputElement[];
const alphaInput = container.querySelector('input[maxlength="4"]') as HTMLInputElement;
fireEvent.change(rgbInputs[0], { target: { value: '255' } });
fireEvent.change(rgbInputs[1], { target: { value: '255' } });
fireEvent.change(rgbInputs[2], { target: { value: '255' } });
expect(onChange).toHaveBeenCalled();
const callCount = onChange.mock.calls.length;
fireEvent.change(alphaInput, { target: { value: '1.5' } });
expect(onChange.mock.calls.length).toBe(callCount);
fireEvent.change(alphaInput, { target: { value: '0.75' } });
expect(onChange).toHaveBeenCalled();
});
it('should guard invalid length and character input branches', () => {
const onChange = vi.fn();
const { container } = render(
<ColorInput hsv={[0, 100, 100]} alpha={0.5} format="rgba" onChange={onChange} />
);
const hexInput = container.querySelector('input[maxlength="6"]') as HTMLInputElement;
const rgbInputs = Array.from(container.querySelectorAll('input[maxlength="3"]')) as HTMLInputElement[];
const alphaInput = container.querySelector('input[maxlength="4"]') as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: '1234567' } });
expect(onChange).not.toHaveBeenCalled();
fireEvent.change(rgbInputs[0], { target: { value: '1a' } });
fireEvent.change(rgbInputs[1], { target: { value: '300' } });
fireEvent.change(alphaInput, { target: { value: 'abc' } });
expect(onChange).not.toHaveBeenCalled();
});
it('should reset rgb local values on blur to current hsv values', () => {
const onChange = vi.fn();
const { container } = render(
<ColorInput hsv={[0, 100, 100]} alpha={0.5} format="rgba" onChange={onChange} />
);
const rInput = container.querySelector('input[maxlength="3"]') as HTMLInputElement;
fireEvent.change(rInput, { target: { value: '1' } });
expect(onChange).toHaveBeenCalled();
fireEvent.blur(rInput);
expect(rInput.value).toBe('255');
});
});
@@ -0,0 +1,56 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { hexToHsv, hsvToHex, hsvToRgb, hsvToRgba, parseRgba, rgbToHex, rgbToHsv } from '../color-conversion';
describe('color-conversion', () => {
it('should convert hsv to rgb across hue segments', () => {
expect(hsvToRgb(0, 100, 100)).toEqual([255, 0, 0]);
expect(hsvToRgb(60, 100, 100)).toEqual([255, 255, 0]);
expect(hsvToRgb(120, 100, 100)).toEqual([0, 255, 0]);
expect(hsvToRgb(180, 100, 100)).toEqual([0, 255, 255]);
expect(hsvToRgb(240, 100, 100)).toEqual([0, 0, 255]);
expect(hsvToRgb(300, 100, 100)).toEqual([255, 0, 255]);
});
it('should convert rgb and hsv values', () => {
expect(rgbToHex(255, 0, 16)).toBe('#ff0010');
expect(hsvToHex(0, 100, 100)).toBe('#ff0000');
expect(hsvToRgba(0, 100, 100, 0.5)).toBe('rgba(255, 0, 0, 0.5)');
const fromRed = rgbToHsv(255, 0, 0);
const fromGreen = rgbToHsv(0, 255, 0);
const fromBlue = rgbToHsv(0, 0, 255);
const fromGray = rgbToHsv(128, 128, 128);
expect(Math.round(fromRed[0])).toBe(0);
expect(Math.round(fromGreen[0])).toBe(120);
expect(Math.round(fromBlue[0])).toBe(240);
expect(fromGray[1]).toBe(0);
});
it('should parse hex and rgba strings', () => {
const shortHex = hexToHsv('#f00');
const longHex = hexToHsv('#00ff00');
expect(Math.round(shortHex[0])).toBe(0);
expect(Math.round(longHex[0])).toBe(120);
expect(parseRgba('rgba(255, 128, 0, 0.25)')).toEqual([255, 128, 0, 0.25]);
expect(parseRgba('rgb(10, 20, 30)')).toEqual([10, 20, 30, 1]);
expect(() => parseRgba('not-a-color')).toThrow('Invalid RGBA string');
});
});
@@ -0,0 +1,98 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ColorPicker } from '../ColorPicker';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe('ColorPicker extra', () => {
it('should handle rgba mode and confirm custom color', () => {
const onChange = vi.fn();
const { container } = render(
<ColorPicker format="rgba" value="rgba(10, 20, 30, 0.5)" onChange={onChange} />
);
const moreLink = container.querySelector('[data-u-comp="color-picker"] a') as HTMLAnchorElement;
expect(moreLink).toBeTruthy();
fireEvent.click(moreLink);
const buttons = Array.from(document.querySelectorAll('button'));
const confirmBtn = buttons[buttons.length - 1] as HTMLButtonElement;
expect(confirmBtn).toBeTruthy();
fireEvent.click(confirmBtn);
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls.some(([value]) => String(value).startsWith('rgba('))).toBe(true);
});
it('should log error when invalid value is provided', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(<ColorPicker format="rgba" value="invalid-rgba" />);
expect(errorSpy).toHaveBeenCalled();
});
it('should stop click propagation on root container', () => {
const parentClick = vi.fn();
const { container } = render(
<div onClick={parentClick}>
<ColorPicker />
</div>
);
const root = container.querySelector('[data-u-comp="color-picker"]') as HTMLElement;
expect(root).toBeTruthy();
fireEvent.click(root);
expect(parentClick).not.toHaveBeenCalled();
});
it('should emit rgba from presets and confirm/cancel inside dialog', () => {
const onChange = vi.fn();
const { container } = render(<ColorPicker format="rgba" value="rgba(0, 0, 0, 1)" onChange={onChange} />);
const presetButton = container.querySelector('[data-u-comp="color-picker-presets"] button') as HTMLButtonElement;
fireEvent.click(presetButton);
expect(onChange.mock.calls.some(([value]) => String(value).startsWith('rgba('))).toBe(true);
const moreLink = container.querySelector('[data-u-comp="color-picker"] a') as HTMLAnchorElement;
fireEvent.click(moreLink);
const alphaInput = document.querySelector('input[maxlength="4"]') as HTMLInputElement;
fireEvent.change(alphaInput, { target: { value: '0.6' } });
const [cancelBtn, confirmBtn] = Array.from(document.querySelectorAll('footer button')) as HTMLButtonElement[];
fireEvent.click(cancelBtn);
fireEvent.click(moreLink);
fireEvent.click(confirmBtn);
expect(onChange).toHaveBeenCalled();
});
it('should confirm custom color in hex mode', () => {
const onChange = vi.fn();
const { container } = render(<ColorPicker format="hex" value="#00ff00" onChange={onChange} />);
const moreLink = container.querySelector('[data-u-comp="color-picker"] a') as HTMLAnchorElement;
fireEvent.click(moreLink);
const confirmBtn = Array.from(document.querySelectorAll('footer button')).at(-1) as HTMLButtonElement;
fireEvent.click(confirmBtn);
expect(onChange.mock.calls.some(([value]) => String(value).startsWith('#'))).toBe(true);
});
});
@@ -0,0 +1,69 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ColorSpectrum } from '../ColorSpectrum';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe('ColorSpectrum extra', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => {
const gradient = { addColorStop: vi.fn() };
return {
createLinearGradient: vi.fn(() => gradient),
fillRect: vi.fn(),
fillStyle: '',
} as unknown as CanvasRenderingContext2D;
});
});
it('should draw gradients and emit pointer-driven hsv changes', () => {
const onChange = vi.fn();
const onChanged = vi.fn();
const { container } = render(<ColorSpectrum hsv={[120, 20, 80]} onChange={onChange} onChanged={onChanged} />);
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
const wrapper = container.querySelector('[data-u-comp="color-picker-spectrum"]') as HTMLDivElement;
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: 100, configurable: true });
canvas.getBoundingClientRect = vi.fn(() => ({
left: 0,
top: 0,
width: 100,
height: 100,
right: 100,
bottom: 100,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.pointerDown(canvas, { clientX: 50, clientY: 25 });
fireEvent.pointerMove(canvas, { clientX: 80, clientY: 40 });
fireEvent.mouseUp(wrapper);
fireEvent.pointerUp(window);
fireEvent.mouseUp(window);
expect(onChange).toHaveBeenCalled();
expect(onChanged).toHaveBeenCalledWith(120, 20, 80);
});
});
@@ -0,0 +1,57 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HueSlider } from '../HueSlider';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe('HueSlider extra', () => {
it('should stop propagation, handle drag and emit onChanged', () => {
const onChange = vi.fn();
const onChanged = vi.fn();
const { container } = render(<HueSlider hsv={[120, 60, 70]} onChange={onChange} onChanged={onChanged} />);
const slider = container.querySelector('[data-u-comp="color-picker-hue-slider"] > div') as HTMLDivElement;
const thumb = container.querySelector('.univer-size-2') as HTMLDivElement;
Object.defineProperty(thumb, 'clientWidth', { value: 10, configurable: true });
Object.defineProperty((slider.parentElement as HTMLDivElement), 'clientWidth', { value: 100, configurable: true });
slider.getBoundingClientRect = vi.fn(() => ({
left: 0,
top: 0,
width: 100,
height: 8,
right: 100,
bottom: 8,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.pointerMove(window, { clientX: 40 });
fireEvent.pointerDown(slider, { clientX: 40 });
fireEvent.pointerMove(window, { clientX: 70 });
fireEvent.pointerUp(window);
expect(onChange).toHaveBeenCalled();
expect(onChanged).toHaveBeenCalledWith(120, 60, 70);
});
});
@@ -0,0 +1,88 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DatePicker } from '../DatePicker';
import '@testing-library/jest-dom/vitest';
const { calendarPropsHistory } = vi.hoisted(() => ({
calendarPropsHistory: [] as any[],
}));
vi.mock('../../dropdown/Dropdown', () => ({
Dropdown: (props: any) => (
<div data-testid="mock-dropdown" data-open={String(props.open)}>
<button
type="button"
data-testid="mock-dropdown-toggle"
onClick={() => props.onOpenChange?.(!props.open)}
>
toggle
</button>
<div data-testid="mock-dropdown-overlay">{props.overlay}</div>
{props.children}
</div>
),
}));
vi.mock('../../calendar/Calendar', () => ({
Calendar: (props: any) => {
calendarPropsHistory.push(props);
return (
<button
type="button"
data-testid="mock-calendar-select"
onClick={() => props.onValueChange?.(new Date('2026-01-02T00:00:00.000Z'))}
>
select-date
</button>
);
},
}));
afterEach(() => {
cleanup();
calendarPropsHistory.length = 0;
});
describe('DatePicker', () => {
it('should render value, open dropdown and emit value change', () => {
const onValueChange = vi.fn();
const value = new Date('2026-01-01T00:00:00.000Z');
render(
<DatePicker
value={value}
className="custom-date-picker"
onValueChange={onValueChange}
/>
);
expect(screen.getByText('2026-01-01')).toBeInTheDocument();
expect(calendarPropsHistory[0].value).toEqual(value);
fireEvent.click(screen.getByTestId('mock-dropdown-toggle'));
expect(screen.getByTestId('mock-dropdown')).toHaveAttribute('data-open', 'true');
fireEvent.click(screen.getByTestId('mock-calendar-select'));
expect(onValueChange).toHaveBeenCalledWith(new Date('2026-01-02T00:00:00.000Z'));
expect(screen.getByTestId('mock-dropdown')).toHaveAttribute('data-open', 'false');
const dateButton = screen.getByText('2026-01-01').closest('button') as HTMLButtonElement;
expect(dateButton.className).toContain('custom-date-picker');
});
});
@@ -0,0 +1,96 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DateRangePicker } from '../DateRangePicker';
import '@testing-library/jest-dom/vitest';
const { calendarPropsHistory } = vi.hoisted(() => ({
calendarPropsHistory: [] as any[],
}));
vi.mock('../../dropdown/Dropdown', () => ({
Dropdown: (props: any) => (
<div data-testid="mock-dropdown" data-open={String(props.open)}>
<button
type="button"
data-testid="mock-dropdown-toggle"
onClick={() => props.onOpenChange?.(!props.open)}
>
toggle
</button>
<div data-testid="mock-dropdown-overlay">{props.overlay}</div>
{props.children}
</div>
),
}));
vi.mock('../../calendar/Calendar', () => ({
Calendar: (props: any) => {
calendarPropsHistory.push(props);
const testId = props.max ? 'mock-calendar-start' : 'mock-calendar-end';
const nextDate = props.max
? new Date('2026-02-15T00:00:00.000Z')
: new Date('2026-02-03T00:00:00.000Z');
return (
<button
type="button"
data-testid={testId}
onClick={() => props.onValueChange?.(nextDate)}
>
{testId}
</button>
);
},
}));
afterEach(() => {
cleanup();
calendarPropsHistory.length = 0;
});
describe('DateRangePicker', () => {
it('should render values, pass min/max and normalize start/end order', () => {
const onValueChange = vi.fn();
const value = [
new Date('2026-02-10T00:00:00.000Z'),
new Date('2026-02-01T00:00:00.000Z'),
] as [Date, Date];
render(<DateRangePicker value={value} onValueChange={onValueChange} />);
expect(screen.getByText('2026-02-10')).toBeInTheDocument();
expect(screen.getByText('2026-02-01')).toBeInTheDocument();
expect(calendarPropsHistory[0].value).toEqual(value[0]);
expect(calendarPropsHistory[0].max).toEqual(value[1]);
expect(calendarPropsHistory[1].value).toEqual(value[1]);
expect(calendarPropsHistory[1].min).toEqual(value[0]);
fireEvent.click(screen.getByTestId('mock-dropdown-toggle'));
expect(screen.getByTestId('mock-dropdown')).toHaveAttribute('data-open', 'true');
fireEvent.click(screen.getByTestId('mock-calendar-start'));
expect(onValueChange).toHaveBeenCalledWith([
new Date('2026-02-01T00:00:00.000Z'),
new Date('2026-02-15T00:00:00.000Z'),
]);
expect(screen.getByTestId('mock-dropdown')).toHaveAttribute('data-open', 'false');
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { cleanup, render } from '@testing-library/react';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Dialog } from '../Dialog';
import '@testing-library/jest-dom/vitest';
@@ -70,4 +70,50 @@ describe('Dialog', () => {
expect(onOpenChange).not.toHaveBeenCalledWith(false);
}
});
it('should apply width and draggable styles', () => {
const { rerender } = render(
<Dialog open title="drag" width={320} draggable>
content
</Dialog>
);
const content = document.querySelector('[role="dialog"]') as HTMLElement;
expect(content.style.width).toBe('320px');
expect(content.style.transform).toContain('translate(');
const header = document.querySelector('[data-drag-handle="true"]') as HTMLElement;
expect(header).toBeInTheDocument();
fireEvent.mouseDown(header, { clientX: 20, clientY: 20 });
fireEvent.mouseMove(document, { clientX: 40, clientY: 50 });
fireEvent.mouseUp(document);
rerender(
<Dialog open title="drag" width="40rem" draggable>
content
</Dialog>
);
expect((document.querySelector('[role="dialog"]') as HTMLElement).style.width).toBe('40rem');
});
it('should honor keyboard=false and trigger open change from close button', () => {
const onOpenChange = vi.fn();
const onClose = vi.fn();
render(
<Dialog open keyboard={false} onOpenChange={onOpenChange} onClose={onClose}>
content
</Dialog>
);
fireEvent.keyDown(document, { key: 'Escape' });
const closeBtn = (document.querySelector('.univer-sr-only') as HTMLElement | null)?.parentElement as HTMLElement | null;
expect(closeBtn).toBeInTheDocument();
if (!closeBtn) {
throw new Error('Close button should exist');
}
closeBtn.click();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,160 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Dialog } from '../Dialog';
import '@testing-library/jest-dom/vitest';
vi.mock('../DialogPrimitive', async () => {
const React = await import('react');
const Dialog = ({ children, open, onOpenChange, modal }: any) => (
<div data-testid="dialog-provider" data-open={String(open)} data-modal={String(modal)}>
<button type="button" data-testid="provider-close" onClick={() => onOpenChange?.(false)}>
provider-close
</button>
{children}
</div>
);
const DialogContent = React.forwardRef<HTMLDivElement, any>((props, ref) => {
const { children, onEscapeKeyDown, onPointerDownOutside, onClickClose } = props;
return (
<div
ref={ref}
role="dialog"
tabIndex={0}
onKeyDown={(e) => onEscapeKeyDown?.(e)}
onPointerDown={(e) => onPointerDownOutside?.(e)}
>
<button type="button" data-slot="close" onClick={onClickClose}>
close
</button>
{children}
</div>
);
});
const DialogHeader = ({ children, ...props }: any) => <div {...props}>{children}</div>;
const DialogFooter = ({ children }: any) => <div>{children}</div>;
const DialogTitle = ({ children }: any) => <div>{children}</div>;
const DialogDescription = ({ children }: any) => <div>{children}</div>;
return {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
};
});
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe('Dialog logic branches', () => {
it('should ignore provider close when mask is false', () => {
const onOpenChange = vi.fn();
const onClose = vi.fn();
render(
<Dialog open mask={false} onOpenChange={onOpenChange} onClose={onClose}>
content
</Dialog>
);
fireEvent.click(screen.getByTestId('provider-close'));
expect(onOpenChange).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it('should close on escape and pointer outside when enabled', () => {
const onOpenChange = vi.fn();
const onClose = vi.fn();
render(
<Dialog open keyboard maskClosable onOpenChange={onOpenChange} onClose={onClose}>
content
</Dialog>
);
const dialog = screen.getByRole('dialog');
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onClose).toHaveBeenCalled();
onOpenChange.mockClear();
onClose.mockClear();
fireEvent.pointerDown(dialog);
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onClose).toHaveBeenCalled();
});
it('should not close from pointer outside when maskClosable is false', () => {
const onOpenChange = vi.fn();
const onClose = vi.fn();
render(
<Dialog open maskClosable={false} onOpenChange={onOpenChange} onClose={onClose}>
content
</Dialog>
);
fireEvent.pointerDown(screen.getByRole('dialog'));
expect(onOpenChange).not.toHaveBeenCalledWith(false);
expect(onClose).not.toHaveBeenCalled();
});
it('should run draggable bounds and drag lifecycle', () => {
Object.defineProperty(document.documentElement, 'clientWidth', { value: 300, configurable: true });
Object.defineProperty(document.documentElement, 'clientHeight', { value: 200, configurable: true });
render(
<Dialog open draggable title="drag-title">
content
</Dialog>
);
const dialog = screen.getByRole('dialog') as HTMLDivElement;
dialog.getBoundingClientRect = vi.fn(() => ({
width: 200,
height: 100,
top: 0,
left: 0,
right: 200,
bottom: 100,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.mouseMove(document, { clientX: 1, clientY: 1 });
const header = screen.getByText('drag-title').parentElement as HTMLElement;
fireEvent.mouseDown(header, { clientX: 10, clientY: 10 });
expect(document.body.style.userSelect).toBe('none');
fireEvent.mouseMove(document, { clientX: -100, clientY: -100 });
fireEvent.mouseMove(document, { clientX: 500, clientY: 500 });
fireEvent.mouseUp(document);
expect(document.body.style.userSelect).toBe('');
});
});
@@ -0,0 +1,302 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DraggableList } from '../DraggableList';
import '@testing-library/jest-dom/vitest';
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: any) => node,
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
interface IItem {
id: string;
label: string;
}
const BASE_LIST: IItem[] = [
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
{ id: 'c', label: 'C' },
];
function mockRect(element: HTMLElement) {
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
x: 10,
y: 20,
left: 10,
top: 20,
right: 110,
bottom: 56,
width: 100,
height: 36,
toJSON: () => ({}),
} as DOMRect);
}
describe('DraggableList', () => {
it('should reorder list and emit drag callbacks', async () => {
const onListChange = vi.fn();
const onDragStart = vi.fn();
const onDragStop = vi.fn();
const { container } = render(
<DraggableList<IItem>
list={BASE_LIST}
idKey="id"
rowHeight={36}
margin={[4, 6]}
onListChange={onListChange}
onDragStart={onDragStart}
onDragStop={onDragStop}
itemRender={(item) => <div>{item.label}</div>}
/>
);
const listRoot = container.querySelector('.univer-flex-col') as HTMLElement;
expect(listRoot.style.rowGap).toBe('6px');
expect(listRoot.style.paddingLeft).toBe('4px');
expect(listRoot.style.paddingRight).toBe('4px');
const itemA = container.querySelector('[data-draggable-list-item-id="a"]') as HTMLDivElement;
const itemB = container.querySelector('[data-draggable-list-item-id="b"]') as HTMLDivElement;
expect(itemA.style.minHeight).toBe('36px');
mockRect(itemA);
Object.defineProperty(itemA, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
const elementFromPointSpy = vi.spyOn(document, 'elementFromPoint').mockReturnValue(itemB);
fireEvent.pointerDown(itemA, {
pointerId: 1,
clientX: 20,
clientY: 24,
});
await waitFor(() => {
expect(onDragStart).toHaveBeenCalledWith(undefined, { y: 0 });
});
expect(container.querySelector('.univer-pointer-events-none')).toBeInTheDocument();
fireEvent.pointerMove(window, {
pointerId: 1,
clientX: 48,
clientY: 50,
});
fireEvent.pointerUp(window, {
pointerId: 1,
clientX: 48,
clientY: 50,
});
await waitFor(() => {
expect(onDragStop).toHaveBeenCalledWith(undefined, { y: 0 }, { y: 1 });
expect(onListChange).toHaveBeenCalledTimes(1);
});
const updatedList = onListChange.mock.calls[0][0] as IItem[];
expect(updatedList.map((item) => item.id)).toEqual(['b', 'a', 'c']);
elementFromPointSpy.mockRestore();
});
it('should only start drag when pointer down on draggable handle', async () => {
const onListChange = vi.fn();
const onDragStart = vi.fn();
const { container } = render(
<DraggableList<IItem>
list={BASE_LIST.slice(0, 2)}
idKey="id"
draggableHandle=".drag-handle"
onListChange={onListChange}
onDragStart={onDragStart}
itemRender={(item) => (
<div>
<span>{item.label}</span>
<button type="button" className="drag-handle">drag</button>
</div>
)}
/>
);
const itemA = container.querySelector('[data-draggable-list-item-id="a"]') as HTMLDivElement;
mockRect(itemA);
Object.defineProperty(itemA, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
fireEvent.pointerDown(screen.getByText('A'), {
pointerId: 2,
clientX: 20,
clientY: 24,
});
fireEvent.pointerMove(window, {
pointerId: 2,
clientX: 26,
clientY: 30,
});
fireEvent.pointerUp(window, {
pointerId: 2,
clientX: 26,
clientY: 30,
});
expect(onDragStart).not.toHaveBeenCalled();
expect(onListChange).not.toHaveBeenCalled();
const handleButton = itemA.querySelector('.drag-handle') as HTMLButtonElement;
fireEvent.pointerDown(handleButton, {
pointerId: 3,
clientX: 20,
clientY: 24,
});
await waitFor(() => {
expect(onDragStart).toHaveBeenCalledTimes(1);
});
});
it('should ignore unrelated pointer events while dragging', async () => {
const onListChange = vi.fn();
const { container } = render(
<DraggableList<IItem>
list={BASE_LIST.slice(0, 2)}
idKey="id"
onListChange={onListChange}
itemRender={(item) => <div>{item.label}</div>}
/>
);
const itemA = container.querySelector('[data-draggable-list-item-id="a"]') as HTMLDivElement;
const itemB = container.querySelector('[data-draggable-list-item-id="b"]') as HTMLDivElement;
mockRect(itemA);
Object.defineProperty(itemA, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
fireEvent.pointerDown(itemA, {
pointerId: 10,
clientX: 20,
clientY: 24,
});
// pointerId mismatch should be ignored
fireEvent.pointerMove(window, {
pointerId: 999,
clientX: 30,
clientY: 34,
});
// target equals source should be ignored
vi.spyOn(document, 'elementFromPoint').mockReturnValue(itemA);
fireEvent.pointerMove(window, {
pointerId: 10,
clientX: 32,
clientY: 36,
});
// target not in current list should keep previous list
const invalidTarget = document.createElement('div');
invalidTarget.setAttribute('data-draggable-list-item-id', 'missing');
vi.spyOn(document, 'elementFromPoint').mockReturnValue(invalidTarget);
fireEvent.pointerMove(window, {
pointerId: 10,
clientX: 34,
clientY: 38,
});
// A second pointer down during active drag should be ignored.
fireEvent.pointerDown(itemB, {
pointerId: 11,
clientX: 20,
clientY: 24,
});
// pointerup mismatch should not end current dragging
fireEvent.pointerUp(window, {
pointerId: 999,
clientX: 34,
clientY: 38,
});
expect(onListChange).not.toHaveBeenCalled();
fireEvent.pointerUp(window, {
pointerId: 10,
clientX: 34,
clientY: 38,
});
expect(onListChange).toHaveBeenCalledTimes(1);
});
it('should render ghost with itemRender fallback when innerHTML is empty', async () => {
const onListChange = vi.fn();
const itemRender = vi.fn(() => null);
const { container } = render(
<DraggableList<IItem>
list={BASE_LIST.slice(0, 1)}
idKey="id"
onListChange={onListChange}
itemRender={itemRender}
/>
);
const itemA = container.querySelector('[data-draggable-list-item-id="a"]') as HTMLDivElement;
mockRect(itemA);
Object.defineProperty(itemA, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
fireEvent.pointerDown(itemA, {
pointerId: 20,
clientX: 20,
clientY: 24,
});
await waitFor(() => {
expect(itemRender.mock.calls.length).toBeGreaterThanOrEqual(3);
});
fireEvent.pointerUp(window, {
pointerId: 20,
clientX: 20,
clientY: 24,
});
expect(onListChange).toHaveBeenCalledTimes(1);
});
});
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DropdownMenu } from '../DropdownMenu';
import '@testing-library/jest-dom/vitest';
@@ -109,4 +109,53 @@ describe('DropdownMenu', () => {
);
expect(container).toMatchSnapshot();
});
it('should invoke onSelect callbacks for item/checkbox/radio', () => {
const onItemSelect = vi.fn();
const onCheckboxSelect = vi.fn();
const onRadioSelect = vi.fn();
const items = [
{ type: 'item' as const, children: 'Run', onSelect: onItemSelect },
{ type: 'checkbox' as const, value: 'c1', label: 'Check', checked: false, onSelect: onCheckboxSelect },
{
type: 'radio' as const,
value: 'a',
onSelect: onRadioSelect,
options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }],
},
];
const { getByText } = render(
<DropdownMenu open items={items}>
<button type="button">Trigger</button>
</DropdownMenu>
);
fireEvent.click(getByText('Run'));
fireEvent.click(getByText('Check'));
fireEvent.click(getByText('B'));
expect(onItemSelect).toHaveBeenCalled();
expect(onCheckboxSelect).toHaveBeenCalledWith('c1');
expect(onRadioSelect).toHaveBeenCalledWith('b');
});
it('should throw when radio option misses value', () => {
const badItems = [
{
type: 'radio' as const,
value: 'a',
options: [{ label: 'Missing Value' }],
},
];
expect(() =>
render(
<DropdownMenu open items={badItems}>
<button type="button">Trigger</button>
</DropdownMenu>
)
).toThrow('[DropdownMenu]: `value` is required');
});
});
@@ -0,0 +1,69 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPrimitive,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../DropdownMenuPrimitive';
import '@testing-library/jest-dom/vitest';
describe('DropdownMenuPrimitive', () => {
it('should render primitive wrappers and variants', () => {
const { container } = render(
<DropdownMenuPrimitive open>
<DropdownMenuTrigger>Trigger</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel inset>Label</DropdownMenuLabel>
<DropdownMenuItem inset variant="destructive">Item</DropdownMenuItem>
<DropdownMenuCheckboxItem checked hideIndicator>
Check
</DropdownMenuCheckboxItem>
<DropdownMenuRadioGroup value="a">
<DropdownMenuRadioItem value="a" hideIndicator>
Radio A
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuShortcut>Cmd+K</DropdownMenuShortcut>
<DropdownMenuSub>
<DropdownMenuSubTrigger inset>More</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Sub Item</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenuPrimitive>
);
expect(container).toBeInTheDocument();
});
});
@@ -0,0 +1,76 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { FormDualColumnLayout, FormLayout } from '../FormLayout';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('FormLayout', () => {
it('should toggle content when collapsable', () => {
const { container } = render(
<FormLayout
label="Section"
desc="Description"
error="Invalid value"
collapsable
defaultCollapsed
>
<input data-u-comp="input" />
</FormLayout>
);
expect(screen.queryByText('Description')).not.toBeInTheDocument();
expect(screen.queryByText('Invalid value')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Section'));
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Invalid value')).toBeInTheDocument();
expect(container.querySelector('[data-u-comp="input"]')).toBeInTheDocument();
expect(container.innerHTML).toContain('univer-border-red-500');
});
it('should keep content visible when not collapsable', () => {
render(
<FormLayout label="Always Visible" defaultCollapsed>
<div>Child Content</div>
</FormLayout>
);
fireEvent.click(screen.getByText('Always Visible'));
expect(screen.getByText('Child Content')).toBeInTheDocument();
});
it('should render dual column layout', () => {
const { container } = render(
<FormDualColumnLayout>
<div>Left</div>
<div>Right</div>
</FormDualColumnLayout>
);
expect(screen.getByText('Left')).toBeInTheDocument();
expect(screen.getByText('Right')).toBeInTheDocument();
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.className).toContain('univer-flex');
expect(wrapper.className).toContain('univer-justify-between');
});
});
@@ -15,7 +15,7 @@
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Gallery } from '../Gallery';
import '@testing-library/jest-dom/vitest';
@@ -30,6 +30,9 @@ afterEach(() => {
});
describe('Gallery', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('does not render when open is false', () => {
render(<Gallery images={images} open={false} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
@@ -91,14 +94,49 @@ describe('Gallery', () => {
it('can switch images using the pager', () => {
render(<Gallery images={images} open={true} />);
// You may need to adjust this selector depending on your Pager implementation
// For example, if there is a button with aria-label="Next page"
const nextButton = screen.queryByLabelText(/next/i) || screen.queryByTestId('pager-right-arrow');
if (nextButton) {
fireEvent.click(nextButton);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', images[1]);
expect(img).toHaveAttribute('alt', 'Image 2 of 3');
const nextButton = document.querySelector('[data-u-comp="pager-right-arrow"]') as HTMLButtonElement;
fireEvent.click(nextButton);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', images[1]);
expect(img).toHaveAttribute('alt', 'Image 2 of 3');
});
it('should zoom with wheel event and keep value in range', () => {
render(<Gallery images={images} open={true} />);
const img = screen.getByRole('img');
const getScale = () => Number.parseFloat((img.style.transform.match(/scale\(([^)]+)\)/)?.[1] ?? '1'));
fireEvent.wheel(window, { deltaY: -300 });
expect(img.style.transform).toContain('scale(');
const zoomInBtn = screen.getByRole('button', { name: /zoom in/i });
const zoomOutBtn = screen.getByRole('button', { name: /zoom out/i });
for (let i = 0; i < 8; i++) {
fireEvent.click(zoomInBtn);
}
const atUpperBound = getScale();
fireEvent.click(zoomInBtn);
expect(getScale()).toBe(atUpperBound);
expect(getScale()).toBeLessThanOrEqual(2);
for (let i = 0; i < 12; i++) {
fireEvent.click(zoomOutBtn);
}
const atLowerBound = getScale();
fireEvent.click(zoomOutBtn);
expect(getScale()).toBe(atLowerBound);
expect(getScale()).toBeGreaterThanOrEqual(0.5);
});
it('should schedule close animation timer when closing', () => {
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const { rerender } = render(<Gallery images={images} open={true} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
rerender(<Gallery images={images} open={false} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(timeoutSpy).toHaveBeenCalled();
expect(timeoutSpy.mock.calls.some(([, delay]) => delay === 150)).toBe(true);
});
});
@@ -159,4 +159,78 @@ describe('GradientColorPicker', () => {
expect(calledValue.stops.length).toBe(3);
expect(calledValue.stops.some((s) => s.offset === 50)).toBe(true);
});
it('should ignore null offset change from offset input', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const inputs = container.querySelectorAll('input');
const offsetInput = inputs[0] as HTMLInputElement;
fireEvent.change(offsetInput, { target: { value: '' } });
expect(onChange).not.toHaveBeenCalled();
});
it('should update stop color via nested color picker presets', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const presetButton = container.querySelector('[data-u-comp="color-picker-presets"] button') as HTMLButtonElement;
expect(presetButton).toBeTruthy();
fireEvent.click(presetButton);
expect(onChange).toHaveBeenCalled();
const next = onChange.mock.calls[0][0] as IGradientValue;
expect(next.stops[0].color).toMatch(/^#/);
});
it('should support radial/angular/diamond and fallback preview background', () => {
const { container, rerender } = render(<GradientColorPicker value={{ ...defaultValue, type: 'radial' }} />);
const preview = container.querySelector('.univer-h-32') as HTMLDivElement;
expect(preview.style.background).toContain('radial-gradient');
rerender(<GradientColorPicker value={{ ...defaultValue, type: 'angular' }} />);
expect((container.querySelector('.univer-h-32') as HTMLDivElement).style.background).toContain('conic-gradient');
rerender(<GradientColorPicker value={{ ...defaultValue, type: 'diamond' }} />);
expect((container.querySelector('.univer-h-32') as HTMLDivElement).style.background).toContain('radial-gradient');
rerender(<GradientColorPicker value={{ ...defaultValue, type: 'unexpected' as unknown as IGradientValue['type'] }} />);
expect((container.querySelector('.univer-h-32') as HTMLDivElement).style.background).toContain('linear-gradient');
});
it('should drag a stop and emit new offset', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const bar = container.querySelector('.univer-cursor-crosshair') as HTMLDivElement;
const stop = container.querySelector('.univer-absolute.univer-rounded-full.univer-border-2') as HTMLDivElement;
bar.getBoundingClientRect = vi.fn(() => ({
left: 0,
width: 100,
top: 0,
height: 10,
bottom: 10,
right: 100,
x: 0,
y: 0,
toJSON: () => {},
}));
fireEvent.pointerDown(stop, { clientX: 0 });
fireEvent.pointerMove(window, { clientX: 60 });
fireEvent.pointerUp(window);
expect(onChange).toHaveBeenCalled();
const values = onChange.mock.calls.map((call) => call[0] as IGradientValue);
expect(values.some((v) => v.stops[0].offset === 60)).toBe(true);
});
it('should not remove stop when there are only two stops', () => {
const onChange = vi.fn();
const { container } = render(<GradientColorPicker value={defaultValue} onChange={onChange} />);
const deleteButton = container.querySelector('.univer-border-red-500') as HTMLButtonElement;
expect(deleteButton).toBeDisabled();
fireEvent.click(deleteButton);
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,85 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HoverCard } from '../HoverCard';
import '@testing-library/jest-dom/vitest';
vi.mock('../HoverCardPrimitive', async () => {
const React = await import('react');
return {
HoverCardPrimitive: ({ open, onOpenChange, children }: any) => (
<div
data-testid="hover-root"
data-open={String(open)}
onMouseEnter={() => onOpenChange?.(true)}
onMouseLeave={() => onOpenChange?.(false)}
>
{children}
</div>
),
HoverCardTrigger: ({ children }: any) => <div data-testid="hover-trigger">{children}</div>,
HoverCardPortal: ({ children }: any) => <div>{children}</div>,
HoverCardContent: React.forwardRef<HTMLDivElement, any>(({ children }, ref) => (
<div ref={ref} data-testid="hover-content">{children}</div>
)),
};
});
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe('HoverCard logic branches', () => {
it('should update uncontrolled open state and call onOpenChange', () => {
const onOpenChange = vi.fn();
render(
<HoverCard overlay={<div>Overlay</div>} onOpenChange={onOpenChange}>
<button type="button">Trigger</button>
</HoverCard>
);
const root = screen.getByTestId('hover-root');
expect(root.getAttribute('data-open')).toBe('false');
fireEvent.mouseEnter(root);
expect(onOpenChange).toHaveBeenCalledWith(true);
expect(root.getAttribute('data-open')).toBe('true');
fireEvent.mouseLeave(root);
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(root.getAttribute('data-open')).toBe('false');
});
it('should block open changes when disabled', () => {
const onOpenChange = vi.fn();
render(
<HoverCard overlay={<div>Overlay</div>} disabled onOpenChange={onOpenChange}>
<button type="button">Trigger</button>
</HoverCard>
);
const root = screen.getByTestId('hover-root');
fireEvent.mouseEnter(root);
fireEvent.mouseLeave(root);
expect(onOpenChange).not.toHaveBeenCalled();
expect(root.getAttribute('data-open')).toBe('false');
});
});
@@ -111,4 +111,192 @@ describe('InputNumber', () => {
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(onChange).toHaveBeenCalledWith(0);
});
it('should respect min/max and disable control buttons at bounds', () => {
onChange.mockClear();
const { container } = render(<InputNumber value={1} min={1} max={2} onChange={onChange} />);
const [incBtn, decBtn] = Array.from(container.querySelectorAll('[role="button"]')) as HTMLElement[];
expect(decBtn).toHaveAttribute('aria-disabled', 'true');
expect(incBtn).toHaveAttribute('aria-disabled', 'false');
fireEvent.click(decBtn);
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(incBtn);
expect(onChange).toHaveBeenCalledWith(2);
});
it('should support formatter/parser/precision and press enter callback', () => {
const onPressEnter = vi.fn();
const { container } = render(
<InputNumber
defaultValue={1.236}
precision={2}
parser={(v) => (v ?? '').replace('$', '')}
formatter={(v) => `$${v}`}
onPressEnter={onPressEnter}
/>
);
const input = container.querySelector('input') as HTMLInputElement;
expect(input.value).toBe('$1.24');
fireEvent.change(input, { target: { value: '$2.345' } });
fireEvent.blur(input);
expect(input.value).toBe('$2.35');
fireEvent.keyDown(input, { key: 'Enter' });
expect(onPressEnter).toHaveBeenCalled();
});
it('should hide controls when controls is false and keep input disabled', () => {
const { container } = render(<InputNumber defaultValue={3} controls={false} disabled />);
expect(container.querySelector('[role="button"]')).toBeNull();
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeDisabled();
});
it('should support ref callback and object ref', () => {
const callbackRef = vi.fn();
const objectRef = { current: null as HTMLInputElement | null };
const { unmount } = render(<InputNumber defaultValue={1} ref={callbackRef} />);
expect(callbackRef).toHaveBeenCalled();
unmount();
render(<InputNumber defaultValue={2} ref={objectRef} />);
expect(objectRef.current).not.toBeNull();
});
it('should handle parser fallback and scientific notation', () => {
const onLocalChange = vi.fn();
const { container } = render(
<InputNumber
defaultValue={1}
parser={() => null as unknown as string}
onChange={onLocalChange}
/>
);
const input = container.querySelector('input') as HTMLInputElement;
fireEvent.change(input, { target: { value: '123' } });
expect(onLocalChange).toHaveBeenCalledWith(null);
onLocalChange.mockClear();
fireEvent.change(input, { target: { value: '1e3' } });
expect(onLocalChange).toHaveBeenCalledWith(null);
});
it('should parse scientific notation without precision loss branch errors', () => {
const onLocalChange = vi.fn();
const { container } = render(<InputNumber defaultValue={1} onChange={onLocalChange} />);
const input = container.querySelector('input') as HTMLInputElement;
fireEvent.change(input, { target: { value: '1e3' } });
expect(onLocalChange).toHaveBeenCalledWith(13);
});
it('should step from null value using min baseline and keep boundary', () => {
const onLocalChange = vi.fn();
const { container } = render(<InputNumber allowEmpty min={2} max={3} value={null} onChange={onLocalChange} />);
const [incBtn] = Array.from(container.querySelectorAll('[role="button"]')) as HTMLElement[];
fireEvent.click(incBtn);
expect(onLocalChange).toHaveBeenCalledWith(3);
fireEvent.click(incBtn);
expect(onLocalChange).toHaveBeenCalledTimes(1);
});
it('should handle NaN and clamp by min/max while typing', () => {
const onLocalChange = vi.fn();
const { container } = render(<InputNumber defaultValue={5} min={0} max={10} onChange={onLocalChange} />);
const input = container.querySelector('input') as HTMLInputElement;
fireEvent.change(input, { target: { value: '-' } });
expect(onLocalChange).toHaveBeenCalledWith(null);
fireEvent.change(input, { target: { value: '9999' } });
expect(onLocalChange).toHaveBeenCalledWith(10);
fireEvent.change(input, { target: { value: '-9999' } });
expect(onLocalChange).toHaveBeenCalledWith(0);
});
it('should restore last valid value on blur when current value is invalid', () => {
const onLocalChange = vi.fn();
const { container } = render(<InputNumber defaultValue={6} onChange={onLocalChange} />);
const input = container.querySelector('input') as HTMLInputElement;
fireEvent.change(input, { target: { value: '-' } });
fireEvent.blur(input);
expect(onLocalChange).toHaveBeenCalledWith(6);
expect(input.value).toBe('6');
});
it('should clamp out-of-range value on blur', () => {
const onLocalChange = vi.fn();
const { container } = render(<InputNumber defaultValue={1} min={0} max={10} onChange={onLocalChange} />);
const input = container.querySelector('input') as HTMLInputElement;
fireEvent.change(input, { target: { value: '100' } });
fireEvent.blur(input);
expect(onLocalChange).toHaveBeenCalledWith(10);
expect(input.value).toBe('10');
fireEvent.change(input, { target: { value: '-100' } });
fireEvent.blur(input);
expect(onLocalChange).toHaveBeenCalledWith(0);
expect(input.value).toBe('0');
});
it('should parse very large values in scientific-notation branches', () => {
const onWithPrecision = vi.fn();
const onWithoutPrecision = vi.fn();
const { container: c1 } = render(<InputNumber precision={2} defaultValue={1} onChange={onWithPrecision} />);
const { container: c2 } = render(<InputNumber defaultValue={1} onChange={onWithoutPrecision} />);
const huge = '10000000000000000000000';
fireEvent.change(c1.querySelector('input')!, { target: { value: huge } });
fireEvent.change(c2.querySelector('input')!, { target: { value: huge } });
expect(onWithPrecision).toHaveBeenCalled();
expect(onWithoutPrecision).toHaveBeenCalled();
});
it('should use last valid value as baseline when stepping from empty and support decrement click', () => {
const onLocalChange = vi.fn();
const { container } = render(<InputNumber allowEmpty defaultValue={2} onChange={onLocalChange} min={0} />);
const input = container.querySelector('input') as HTMLInputElement;
const [incBtn, decBtn] = Array.from(container.querySelectorAll('[role="button"]')) as HTMLElement[];
fireEvent.change(input, { target: { value: '' } });
fireEvent.click(incBtn);
expect(onLocalChange).toHaveBeenCalledWith(3);
fireEvent.click(decBtn);
expect(onLocalChange).toHaveBeenCalledWith(2);
});
it('should prevent interactions when disabled and prevent default on control mousedown', () => {
const onLocalChange = vi.fn();
const onKeyDown = vi.fn();
const { container } = render(<InputNumber defaultValue={2} disabled onChange={onLocalChange} onKeyDown={onKeyDown} />);
const input = container.querySelector('input') as HTMLInputElement;
const [incBtn, decBtn] = Array.from(container.querySelectorAll('[role="button"]')) as HTMLElement[];
const incMouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
const decMouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
expect(incBtn.dispatchEvent(incMouseDown)).toBe(false);
expect(decBtn.dispatchEvent(decMouseDown)).toBe(false);
fireEvent.keyDown(input, { key: 'ArrowUp' });
fireEvent.click(incBtn);
fireEvent.click(decBtn);
expect(onKeyDown).not.toHaveBeenCalled();
expect(onLocalChange).not.toHaveBeenCalled();
});
});
@@ -15,10 +15,13 @@
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Input } from '../Input';
afterEach(cleanup);
afterEach(() => {
vi.unstubAllGlobals();
});
describe('Input', () => {
afterEach(cleanup);
@@ -70,4 +73,66 @@ describe('Input', () => {
expect(inputElement.value).toBe('Test');
});
it('should clear value without bubbling click event', () => {
const onChange = vi.fn();
const parentClick = vi.fn();
const { container } = render(
<div onClick={parentClick}>
<Input allowClear value="abc" onChange={onChange} />
</div>
);
const clearButton = container.querySelector('button[type="button"]') as HTMLButtonElement;
fireEvent.click(clearButton);
expect(onChange).toHaveBeenCalledWith('');
expect(parentClick).not.toHaveBeenCalled();
});
it('should observe slot mutations and apply slot paddingRight', () => {
const observe = vi.fn();
const disconnect = vi.fn();
class MockMutationObserver {
private _callback: MutationCallback;
constructor(callback: MutationCallback) {
this._callback = callback;
}
observe(...args: unknown[]) {
observe(...args);
this._callback([], {} as MutationObserver);
}
disconnect() {
disconnect();
}
}
vi.stubGlobal('MutationObserver', MockMutationObserver);
const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth');
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
get() {
return 20;
},
});
const { container } = render(<Input value="x" slot={<span>slot</span>} />);
const input = container.querySelector('input') as HTMLInputElement;
expect(observe).toHaveBeenCalled();
expect(input.style.paddingRight).toBe('28px');
if (originalOffsetWidth) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth);
}
});
it('should set default allowClear padding when no slot exists', () => {
const { container } = render(<Input allowClear value="x" />);
const input = container.querySelector('input') as HTMLInputElement;
expect(input.style.paddingRight).toBe('26px');
});
});
@@ -0,0 +1,133 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { Menu, MenuItem, TinyMenuGroup } from '../Menu';
import '@testing-library/jest-dom/vitest';
const { menuPropsSpy } = vi.hoisted(() => ({
menuPropsSpy: vi.fn(),
}));
vi.mock('rc-menu', () => ({
default: (props: any) => {
menuPropsSpy(props);
return (
<div data-testid="rc-menu" data-prefix={props.prefixCls} className={props.className}>
{props.children}
</div>
);
},
MenuItem: (props: any) => <div data-testid="rc-menu-item">{props.children}</div>,
MenuItemGroup: (props: any) => <div data-testid="rc-menu-group">{props.children}</div>,
SubMenu: (props: any) => <div data-testid="rc-sub-menu">{props.children}</div>,
}));
vi.mock('../../tooltip/Tooltip', () => ({
Tooltip: (props: any) => (
<div data-testid="mock-tooltip" data-title={props.title}>
{props.children}
</div>
),
}));
afterEach(() => {
cleanup();
menuPropsSpy.mockClear();
});
describe('Menu', () => {
it('should render rc-menu with mount container', () => {
const mountContainer = document.createElement('div');
render(
<ConfigProvider mountContainer={mountContainer}>
<Menu className="inner-class" wrapperClass="wrapper-class">
<MenuItem key="menu-item">Menu Item</MenuItem>
</Menu>
</ConfigProvider>
);
const rcMenu = screen.getByTestId('rc-menu');
expect(rcMenu).toHaveClass('wrapper-class');
expect(rcMenu).toHaveAttribute('data-prefix');
expect(rcMenu.getAttribute('data-prefix')).toContain('univer-menu');
const props = menuPropsSpy.mock.calls.at(-1)?.[0];
expect(props.getPopupContainer()).toBe(mountContainer);
});
it('should render nothing without mount container', () => {
render(
<ConfigProvider mountContainer={null}>
<Menu className="inner-class" wrapperClass="wrapper-class" />
</ConfigProvider>
);
expect(screen.queryByTestId('rc-menu')).not.toBeInTheDocument();
});
it('should render tiny menu group and support click/tooltip', () => {
const onClickA = vi.fn();
const onClickB = vi.fn();
function IconA(props: { className?: string }) {
return <span className={props.className}>IconA</span>;
}
function IconB(props: { className?: string }) {
return <span className={props.className}>IconB</span>;
}
const { container } = render(
<TinyMenuGroup
items={[
{
key: 'a',
className: 'custom-a',
Icon: IconA,
active: true,
tooltip: 'tip-a',
onClick: onClickA,
},
{
key: 'b',
className: 'custom-b',
Icon: IconB,
onClick: onClickB,
},
]}
/>
);
expect(screen.getByTestId('mock-tooltip')).toHaveAttribute('data-title', 'tip-a');
const iconA = screen.getByText('IconA');
const iconAWrapper = iconA.parentElement as HTMLElement;
expect(iconAWrapper.className).toContain('univer-bg-gray-50');
fireEvent.click(iconAWrapper);
expect(onClickA).toHaveBeenCalledTimes(1);
const iconB = screen.getByText('IconB');
fireEvent.click(iconB.parentElement as HTMLElement);
expect(onClickB).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('.custom-a, .custom-b').length).toBe(2);
});
});
@@ -0,0 +1,102 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { ConfigProvider } from '../../config-provider/ConfigProvider';
import { Popup } from '../Popup';
import '@testing-library/jest-dom/vitest';
afterEach(() => {
cleanup();
});
describe('Popup', () => {
it('should clamp popup offset and support resize/contextmenu', async () => {
const mountContainer = document.createElement('div');
document.body.appendChild(mountContainer);
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 100,
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 100,
});
render(
<ConfigProvider mountContainer={mountContainer}>
<Popup visible offset={[90, 90]} placementY="above" overflowVisible>
<div>popup-content</div>
</Popup>
</ConfigProvider>
);
const popup = mountContainer.querySelector('section.univer-popup') as HTMLElement;
expect(popup).toBeInTheDocument();
Object.defineProperty(popup, 'clientWidth', {
configurable: true,
value: 60,
});
Object.defineProperty(popup, 'clientHeight', {
configurable: true,
value: 40,
});
fireEvent(window, new Event('resize'));
await waitFor(() => {
expect(popup.style.left).toBe('40px');
expect(popup.style.top).toBe('52px');
});
expect(popup.style.overflow).toBe('visible');
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
popup.dispatchEvent(contextMenuEvent);
expect(contextMenuEvent.defaultPrevented).toBe(true);
mountContainer.remove();
});
it('should move popup off-screen when hidden', async () => {
const mountContainer = document.createElement('div');
document.body.appendChild(mountContainer);
render(
<ConfigProvider mountContainer={mountContainer}>
<Popup visible={false} offset={[10, 20]}>
<div>popup-content</div>
</Popup>
</ConfigProvider>
);
const popup = mountContainer.querySelector('section.univer-popup') as HTMLElement;
expect(popup).toBeInTheDocument();
await waitFor(() => {
expect(popup.style.left).toBe('-9997px');
expect(popup.style.top).toBe('-9997px');
});
mountContainer.remove();
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { cleanup, render } from '@testing-library/react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MultipleSelect } from '../MultipleSelect';
import { Select } from '../Select';
@@ -111,4 +111,26 @@ describe('MultipleSelect', () => {
}
}
});
it('should remove badge value when close icon is clicked', () => {
const handleChange = vi.fn();
render(<MultipleSelect value={['1', '2']} options={options} onChange={handleChange} />);
const closeButtons = screen.getAllByLabelText('Close badge');
fireEvent.click(closeButtons[0]);
expect(handleChange).toHaveBeenCalledWith(['2']);
});
it('should support borderless and disabled visual classes', () => {
const { container, rerender } = render(<MultipleSelect value={['1']} options={options} onChange={() => {}} />);
const trigger = container.querySelector('[data-u-comp="multiple-select"]') as HTMLDivElement;
expect(trigger).toHaveClass('univer-cursor-pointer', { exact: false });
rerender(<MultipleSelect value={['1']} options={options} onChange={() => {}} borderless />);
expect((container.querySelector('[data-u-comp="multiple-select"]') as HTMLDivElement).className).toContain('univer-border-transparent');
rerender(<MultipleSelect value={['1']} options={options} onChange={() => {}} disabled />);
expect((container.querySelector('[data-u-comp="multiple-select"]') as HTMLDivElement).className).toContain('univer-cursor-not-allowed');
});
});
@@ -0,0 +1,76 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MultipleSelect } from '../MultipleSelect';
import '@testing-library/jest-dom/vitest';
vi.mock('../../dropdown-menu/DropdownMenu', () => {
return {
DropdownMenu: ({ children, items = [], onOpenChange }: any) => (
<div>
<button type="button" data-testid="open-menu" onClick={() => onOpenChange?.(true)}>
open
</button>
<button type="button" data-testid="close-menu" onClick={() => onOpenChange?.(false)}>
close
</button>
{items.map((item: any) => (
<button
key={item.value}
type="button"
data-testid={`item-${item.value}`}
onClick={() => item.onSelect?.(item.value)}
>
{String(item.value)}
</button>
))}
{children}
</div>
),
};
});
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
describe('MultipleSelect logic branches', () => {
const options = [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
];
it('should handle open state and checkbox item select branches', () => {
const onChange = vi.fn();
const { container } = render(<MultipleSelect value={['1']} options={options} onChange={onChange} />);
fireEvent.click(screen.getByTestId('open-menu'));
expect((container.querySelector('[data-u-comp="multiple-select"]') as HTMLDivElement).className).toContain('univer-ring-2');
fireEvent.click(screen.getByTestId('item-1'));
expect(onChange).toHaveBeenCalledWith([]);
fireEvent.click(screen.getByTestId('item-2'));
expect(onChange).toHaveBeenCalledWith(['1', '2']);
fireEvent.click(screen.getByTestId('close-menu'));
expect((container.querySelector('[data-u-comp="multiple-select"]') as HTMLDivElement).className).toContain('univer-cursor-pointer');
});
});
@@ -15,13 +15,38 @@
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Textarea } from '../Textarea';
import '@testing-library/jest-dom/vitest';
afterEach(cleanup);
describe('Textarea', () => {
let observeMock: ReturnType<typeof vi.fn>;
let unobserveMock: ReturnType<typeof vi.fn>;
let disconnectMock: ReturnType<typeof vi.fn>;
let triggerResize: (entries: { target: { getBoundingClientRect: () => { width: number; height: number } } }[]) => void;
beforeEach(() => {
observeMock = vi.fn();
unobserveMock = vi.fn();
disconnectMock = vi.fn();
// @ts-ignore
window.ResizeObserver = class ResizeObserver {
constructor(callback: any) {
triggerResize = callback;
}
observe = observeMock;
unobserve = unobserveMock;
disconnect = disconnectMock;
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('should support controlled value', () => {
const { rerender } = render(<Textarea value="foo" onValueChange={() => {}} />);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
@@ -30,14 +55,74 @@ describe('Textarea', () => {
expect(textarea.value).toBe('bar');
});
it('calls onResize when size changes', () => {
it('should call onResize when size changes', () => {
const onResize = vi.fn();
render(<Textarea onResize={onResize} />);
const textarea = document.querySelector('textarea[data-u-comp="textarea"]')!;
// mock ResizeObserver
const event = new Event('resize');
textarea.dispatchEvent(event);
expect(typeof onResize).toBe('function');
expect(observeMock).toHaveBeenCalled();
// Trigger resize with valid dimensions
triggerResize([{
target: {
getBoundingClientRect: () => ({ width: 100, height: 100 }),
},
}]);
expect(onResize).toHaveBeenCalledWith(100, 100);
// Trigger resize with different dimensions
triggerResize([{
target: {
getBoundingClientRect: () => ({ width: 200, height: 200 }),
},
}]);
expect(onResize).toHaveBeenCalledWith(200, 200);
});
it('should not call onResize when size is 0', () => {
const onResize = vi.fn();
render(<Textarea onResize={onResize} />);
// Trigger resize with 0 dimensions
triggerResize([{
target: {
getBoundingClientRect: () => ({ width: 0, height: 0 }),
},
}]);
expect(onResize).not.toHaveBeenCalled();
});
it('should not call onResize when size does not change', () => {
const onResize = vi.fn();
render(<Textarea onResize={onResize} />);
// First call
triggerResize([{
target: {
getBoundingClientRect: () => ({ width: 100, height: 100 }),
},
}]);
expect(onResize).toHaveBeenCalledTimes(1);
// Second call with same dimensions
triggerResize([{
target: {
getBoundingClientRect: () => ({ width: 100, height: 100 }),
},
}]);
expect(onResize).toHaveBeenCalledTimes(1);
});
it('should cleanup observer on unmount', () => {
const onResize = vi.fn();
const { unmount } = render(<Textarea onResize={onResize} />);
unmount();
expect(unobserveMock).toHaveBeenCalled();
expect(disconnectMock).toHaveBeenCalled();
});
it('should forward ref', () => {
@@ -64,6 +149,7 @@ describe('Textarea', () => {
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveAttribute('rows', '5');
});
it('renders with default props', () => {
render(<Textarea />);
const textarea = screen.getByRole('textbox');
@@ -0,0 +1,51 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render } from '@testing-library/react';
import dayjs from 'dayjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TimeInput } from '../TimeInput';
import '@testing-library/jest-dom/vitest';
describe('TimeInput', () => {
afterEach(cleanup);
it('should render with current value and custom class', () => {
const value = new Date('2024-01-01T01:02:03.000Z');
const { container, getByDisplayValue } = render(<TimeInput value={value} className="custom-time" />);
const expected = dayjs(value).format('HH:mm:ss');
expect(container.querySelector('[data-u-comp="time-input"]')).toBeInTheDocument();
expect(container.querySelector('.custom-time')).toBeInTheDocument();
expect(getByDisplayValue(expected)).toBeInTheDocument();
});
it('should emit changed date when time input changes', () => {
const onValueChange = vi.fn();
const value = new Date('2024-01-01T00:00:00.000Z');
const { container } = render(<TimeInput value={value} onValueChange={onValueChange} />);
const input = container.querySelector('input[type="time"]') as HTMLInputElement;
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: '12:34:56' } });
expect(onValueChange).toHaveBeenCalledTimes(1);
const changed = onValueChange.mock.calls[0][0] as Date;
expect(changed.getHours()).toBe(12);
expect(changed.getMinutes()).toBe(34);
expect(changed.getSeconds()).toBe(56);
});
});
@@ -0,0 +1,215 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Tooltip } from '../Tooltip';
import '@testing-library/jest-dom/vitest';
function createRect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
top,
left,
width,
height,
right: left + width,
bottom: top + height,
toJSON() {
return {};
},
} as DOMRect;
}
describe('Tooltip', () => {
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function getRect(this: HTMLElement) {
if (this.getAttribute('role') === 'tooltip') {
return createRect(0, 0, 120, 40);
}
if (this.tagName === 'SPAN' || this.tagName === 'BUTTON') {
return createRect(100, 100, 80, 20);
}
return createRect(0, 0, 0, 0);
});
});
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
it('should show and hide in uncontrolled mode', async () => {
render(
<Tooltip title="Tip content">
Trigger
</Tooltip>
);
const trigger = screen.getByText('Trigger');
fireEvent.mouseEnter(trigger);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Tip content');
fireEvent.mouseLeave(trigger);
await waitFor(() => {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
it('should notify visibility changes in controlled mode', () => {
const onVisibleChange = vi.fn();
render(
<Tooltip title="Controlled" visible={false} onVisibleChange={onVisibleChange}>
Trigger
</Tooltip>
);
const trigger = screen.getByText('Trigger');
fireEvent.mouseEnter(trigger);
fireEvent.mouseLeave(trigger);
expect(onVisibleChange).toHaveBeenCalledWith(true);
expect(onVisibleChange).toHaveBeenCalledWith(false);
});
it('should support non-asChild trigger and focus/blur events', async () => {
render(
<Tooltip title="From button" asChild={false}>
Trigger button
</Tooltip>
);
const button = screen.getByRole('button', { name: 'Trigger button' });
fireEvent.focus(button);
expect(await screen.findByRole('tooltip')).toHaveTextContent('From button');
fireEvent.blur(button);
await waitFor(() => {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
it('should only show when overflowing if showIfEllipsis is true', async () => {
render(
<Tooltip title="Ellipsis tip" showIfEllipsis>
Ellipsis target
</Tooltip>
);
const trigger = screen.getByText('Ellipsis target');
Object.defineProperty(trigger, 'clientWidth', { value: 100, configurable: true });
Object.defineProperty(trigger, 'scrollWidth', { value: 100, configurable: true });
fireEvent.mouseEnter(trigger);
await waitFor(() => {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
Object.defineProperty(trigger, 'scrollWidth', { value: 140, configurable: true });
fireEvent.mouseEnter(trigger);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
});
it('should compute styles for top/left/right placements and update on resize/scroll', async () => {
const placements = ['top', 'left', 'right'] as const;
for (const placement of placements) {
const { unmount } = render(
<Tooltip title={`tip-${placement}`} placement={placement}>
Trigger
{' '}
{placement}
</Tooltip>
);
const trigger = screen.getByText(`Trigger ${placement}`);
fireEvent.mouseEnter(trigger);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
fireEvent.resize(window);
fireEvent.scroll(window);
expect(tooltip.style.top).not.toBe('');
expect(tooltip.style.left).not.toBe('');
unmount();
}
});
it('should run fallback position clamping when no placement fully fits', async () => {
Object.defineProperty(window, 'innerWidth', { value: 100, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: 100, configurable: true });
render(
<Tooltip title="fallback-tip" placement="top">
fallback-trigger
</Tooltip>
);
fireEvent.mouseEnter(screen.getByText('fallback-trigger'));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip.style.top).not.toBe('');
expect(tooltip.style.left).not.toBe('');
});
it('should handle mouse enter/leave on tooltip portal node', async () => {
render(
<Tooltip title="portal-tip">
portal-trigger
</Tooltip>
);
const trigger = screen.getByText('portal-trigger');
fireEvent.mouseEnter(trigger);
const tooltip = await screen.findByRole('tooltip');
fireEvent.mouseEnter(tooltip);
fireEvent.mouseLeave(tooltip);
await waitFor(() => {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
it('should execute left-placement recompute branch on scroll', async () => {
vi.restoreAllMocks();
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function getRect(this: HTMLElement) {
if (this.getAttribute('role') === 'tooltip') {
return createRect(0, 0, 80, 30);
}
if (this.tagName === 'SPAN' || this.tagName === 'BUTTON') {
return createRect(300, 120, 60, 20);
}
return createRect(0, 0, 0, 0);
});
Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
render(
<Tooltip title="left-tip" placement="left">
left-trigger
</Tooltip>
);
fireEvent.mouseEnter(screen.getByText('left-trigger'));
const tooltip = await screen.findByRole('tooltip');
fireEvent.scroll(window);
expect(tooltip.style.left).not.toBe('');
});
});
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { Tree } from '../Tree';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Tree, TreeSelectionMode } from '../Tree';
import { findNodeFromPath, findNodePathFromTree, findSubTreeFromPath, isIntermediated, mergeTreeSelected } from '../util';
afterEach(cleanup);
@@ -99,4 +99,59 @@ describe('Tree', () => {
expect(container);
});
it('should call onChange for checkbox and onExpend for node title click', () => {
const onChange = vi.fn();
const onExpend = vi.fn();
const { getByText, container } = render(
<Tree
data={data}
onChange={onChange}
onExpend={onExpend}
defaultExpandAll
/>
);
const nodeTitle = getByText('node 0');
fireEvent.click(nodeTitle);
expect(onExpend).toHaveBeenCalledWith('0');
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
fireEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ key: '0' }));
});
it('should avoid onExpend for parent in ONLY_LEAF_NODE mode and allow leaf', () => {
const onExpend = vi.fn();
const { getByText } = render(
<Tree
data={data}
selectionMode={TreeSelectionMode.ONLY_LEAF_NODE}
onExpend={onExpend}
defaultExpandAll
/>
);
fireEvent.click(getByText('node 0'));
fireEvent.click(getByText('node 0'));
expect(onExpend).not.toHaveBeenCalledWith('0');
fireEvent.click(getByText('node 0-0'));
expect(onExpend).toHaveBeenCalledWith('0-0');
});
it('should derive selected node set from valueGroup and cached finder', () => {
const cache = new Map<string, string[]>();
const { container } = render(
<Tree
data={data}
valueGroup={['0-0']}
defaultCache={cache}
defaultExpandAll
/>
);
const checkboxes = Array.from(container.querySelectorAll('input[type="checkbox"]')) as HTMLInputElement[];
expect(checkboxes.some((checkbox) => checkbox.checked)).toBe(true);
});
});
@@ -0,0 +1,89 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import {
createCacheWithFindNodePathFromTree,
filterLeafNode,
isIntermediated,
mergeTreeSelected,
} from '../util';
const treeData = [
{
key: 'root',
title: 'root',
children: [
{
key: 'a',
title: 'a',
children: [
{ key: 'a-1', title: 'a-1' },
{ key: 'a-2', title: 'a-2' },
],
},
{
key: 'b',
title: 'b',
children: [
{ key: 'b-1', title: 'b-1' },
],
},
],
},
];
describe('tree util extra', () => {
it('should cache paths and support reset with new tree', () => {
const find = createCacheWithFindNodePathFromTree(treeData);
expect(find.findNodePathFromTreeWithCache('a-2')).toEqual(['root', 'a', 'a-2']);
expect(find.findNodePathFromTreeWithCache('a')).toEqual(['root', 'a']);
const newTree = [
{
key: 'new-root',
title: 'new-root',
children: [{ key: 'new-leaf', title: 'new-leaf' }],
},
];
find.reset(newTree);
expect(find.findNodePathFromTreeWithCache('new-leaf')).toEqual(['new-root', 'new-leaf']);
});
it('should remove current branch and clear parent when deselecting', () => {
const selected = ['root', 'a', 'a-1', 'a-2'];
const result = mergeTreeSelected(treeData, selected, ['root', 'a']);
expect(result).toEqual([]);
});
it('should keep parent selected when sibling branch still selected', () => {
const selected = ['root', 'a', 'a-1', 'a-2', 'b', 'b-1'];
const result = mergeTreeSelected(treeData, selected, ['root', 'a']);
expect(result.sort()).toEqual(['b', 'b-1', 'root'].sort());
});
it('should detect intermediated state and leaf filtering', () => {
const notAllChecked = isIntermediated(new Set(['a-1']), treeData[0]);
const allChecked = isIntermediated(new Set(['a-1', 'a-2', 'b-1']), treeData[0]);
expect(notAllChecked).toBe(true);
expect(allChecked).toBe(false);
const onlyLeafNodes = filterLeafNode(treeData, ['root', 'a', 'a-2', 'missing', 'b-1']);
expect(onlyLeafNodes.map((item) => item.key).sort()).toEqual(['a-2', 'b-1']);
});
});
@@ -0,0 +1,60 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { VirtualList } from '../VirtualList';
import '@testing-library/jest-dom/vitest';
describe('VirtualList', () => {
afterEach(cleanup);
const data = Array.from({ length: 20 }, (_, i) => ({ id: `id-${i}`, label: `Item ${i}` }));
it('should render all items when height/itemHeight are not provided', () => {
const { queryByText } = render(
<VirtualList data={data} itemKey="id">
{(item) => <span>{item.label}</span>}
</VirtualList>
);
expect(queryByText('Item 0')).toBeInTheDocument();
expect(queryByText('Item 19')).toBeInTheDocument();
});
it('should render virtualized items and react to scroll', () => {
const { container } = render(
<VirtualList
data={data}
itemKey={(item) => item.id}
height={40}
itemHeight={10}
overscan={1}
>
{(item) => <span>{item.label}</span>}
</VirtualList>
);
const scroller = container.firstElementChild as HTMLDivElement;
expect(scroller).toBeInTheDocument();
expect(scroller.textContent).toContain('Item 0');
scroller.scrollTop = 80;
fireEvent.scroll(scroller);
expect(scroller.textContent).toContain('Item 10');
});
});
@@ -0,0 +1,62 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it, vi } from 'vitest';
import { render, unmount } from '../react-dom';
const { mockRender, mockUnmount, mockCreateRoot } = vi.hoisted(() => {
const hoistedRender = vi.fn();
const hoistedUnmount = vi.fn();
const hoistedCreateRoot = vi.fn(() => ({
render: hoistedRender,
unmount: hoistedUnmount,
}));
return {
mockRender: hoistedRender,
mockUnmount: hoistedUnmount,
mockCreateRoot: hoistedCreateRoot,
};
});
vi.mock('react-dom/client', () => ({
createRoot: mockCreateRoot,
}));
describe('helper/react-dom', () => {
it('should reuse root per container and unmount correctly', () => {
const containerA = document.createElement('div');
const containerB = document.createElement('div');
render(<div>A1</div>, containerA);
render(<div>A2</div>, containerA);
render(<div>B1</div>, containerB);
expect(mockCreateRoot).toHaveBeenCalledTimes(2);
expect(mockCreateRoot).toHaveBeenNthCalledWith(1, containerA);
expect(mockCreateRoot).toHaveBeenNthCalledWith(2, containerB);
expect(mockRender).toHaveBeenCalledTimes(3);
unmount(containerA);
expect(mockUnmount).toHaveBeenCalledTimes(1);
unmount(containerA);
expect(mockUnmount).toHaveBeenCalledTimes(1);
unmount(containerB);
expect(mockUnmount).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,82 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type ResizeObserverTestCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
describe('helper/resize-observer', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('should use a shared ResizeObserver and dispatch callbacks', async () => {
const observe = vi.fn();
const unobserve = vi.fn();
let ctorCalls = 0;
const observerCallbackRef: { current?: ResizeObserverTestCallback } = {};
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
ctorCalls += 1;
observerCallbackRef.current = callback;
}
observe = observe;
unobserve = unobserve;
}
vi.stubGlobal('ResizeObserver', MockResizeObserver as unknown as typeof ResizeObserver);
const { resizeObserverCtor } = await import('../resize-observer');
const cb1 = vi.fn();
const cb2 = vi.fn();
const targetA = document.createElement('div');
const targetB = document.createElement('div');
const observerA = resizeObserverCtor(cb1);
const observerB = resizeObserverCtor(cb2);
observerA.observe(targetA);
observerB.observe(targetB);
expect(ctorCalls).toBe(1);
expect(observe).toHaveBeenCalledWith(targetA, undefined);
expect(observe).toHaveBeenCalledWith(targetB, undefined);
const entries = [{ target: targetA }] as unknown as ResizeObserverEntry[];
const observerCallback = observerCallbackRef.current;
if (!observerCallback) {
throw new Error('ResizeObserver callback should be initialized');
}
observerCallback(entries, {} as ResizeObserver);
expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(1);
observerA.unobserve(targetA);
observerCallback(entries, {} as ResizeObserver);
expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(2);
expect(unobserve).toHaveBeenCalledWith(targetA);
});
});
@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { IAccessor, IDrawingSearch } from '@univerjs/core';
import { ICommandService } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { describe, expect, it, vi } from 'vitest';
import { EditDocDrawingOperation } from '../edit-doc-drawing.operation';
import { SidebarDocDrawingOperation } from '../open-drawing-panel.operation';
describe('EditDocDrawingOperation', () => {
it('returns false when no drawing is provided', () => {
const drawingManagerService = {
focusDrawing: vi.fn(),
};
const commandService = {
executeCommand: vi.fn(),
};
const accessor = {
get(token: unknown) {
if (token === IDrawingManagerService) {
return drawingManagerService;
}
if (token === ICommandService) {
return commandService;
}
throw new Error('Unknown dependency');
},
} as IAccessor;
expect(EditDocDrawingOperation.handler(accessor, null as unknown as IDrawingSearch)).toBe(false);
expect(drawingManagerService.focusDrawing).not.toHaveBeenCalled();
expect(commandService.executeCommand).not.toHaveBeenCalled();
});
it('focuses the drawing and opens the sidebar when a drawing is provided', () => {
const drawingManagerService = {
focusDrawing: vi.fn(),
};
const commandService = {
executeCommand: vi.fn(),
};
const accessor = {
get(token: unknown) {
if (token === IDrawingManagerService) {
return drawingManagerService;
}
if (token === ICommandService) {
return commandService;
}
throw new Error('Unknown dependency');
},
} as IAccessor;
const params: IDrawingSearch = {
unitId: 'unit-1',
subUnitId: 'doc-1',
drawingId: 'drawing-1',
};
expect(EditDocDrawingOperation.handler(accessor, params)).toBe(true);
expect(drawingManagerService.focusDrawing).toHaveBeenCalledWith([params]);
expect(commandService.executeCommand).toHaveBeenCalledWith(SidebarDocDrawingOperation.id, { value: 'open' });
});
});
@@ -0,0 +1,169 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { DocumentDataModel, ICustomRange, IDocumentData } from '@univerjs/core';
import {
CustomRangeType,
IResourceManagerService,
LocaleType,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DOC_HYPER_LINK_PLUGIN, DocHyperLinkResourceController } from '../resource.controller';
function createDocData(): IDocumentData {
return {
id: 'doc-1',
locale: LocaleType.EN_US,
title: 'Doc',
body: {
dataStream: 'Body\r\n',
customRanges: [
{
startIndex: 0,
endIndex: 4,
rangeId: 'body-link',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://body.old',
},
},
{
startIndex: 0,
endIndex: 4,
rangeId: 'ignored-range',
rangeType: CustomRangeType.COMMENT,
},
],
},
headers: {
'header-1': {
headerId: 'header-1',
body: {
dataStream: 'Header\r\n',
customRanges: [{
startIndex: 0,
endIndex: 6,
rangeId: 'header-link',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://header.old',
},
}],
},
},
},
footers: {
'footer-1': {
footerId: 'footer-1',
body: {
dataStream: 'Footer\r\n',
customRanges: [{
startIndex: 0,
endIndex: 6,
rangeId: 'footer-link',
rangeType: CustomRangeType.HYPERLINK,
}],
},
},
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getRange(model: DocumentDataModel, rangeId: string, segment: 'body' | 'header' | 'footer' = 'body'): ICustomRange | undefined {
const doc = segment === 'header'
? model.headerModelMap.get('header-1')
: segment === 'footer'
? model.footerModelMap.get('footer-1')
: model;
return doc?.getBody()?.customRanges?.find((range) => range.rangeId === rangeId);
}
describe('DocHyperLinkResourceController', () => {
let univer: Univer;
let resourceManagerService: IResourceManagerService;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([DocHyperLinkResourceController]);
injector.get(DocHyperLinkResourceController);
resourceManagerService = injector.get(IResourceManagerService);
});
afterEach(() => {
univer.dispose();
});
it('should serialize hyperlink resources from headers, footers and body only', () => {
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData());
const resource = resourceManagerService.getResourcesByType('doc-1', UniverInstanceType.UNIVER_DOC)
.find((item) => item.name === DOC_HYPER_LINK_PLUGIN);
expect(resource).toBeDefined();
expect(JSON.parse(resource!.data)).toEqual({
links: [
{ id: 'header-link', payload: 'https://header.old' },
{ id: 'footer-link', payload: '' },
{ id: 'body-link', payload: 'https://body.old' },
],
});
});
it('should load hyperlink resources back into header, footer and body ranges', () => {
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData());
resourceManagerService.loadResources(doc.getUnitId(), [{
name: DOC_HYPER_LINK_PLUGIN,
data: JSON.stringify({
links: [
{ id: 'header-link', payload: 'https://header.new' },
{ id: 'footer-link', payload: 'https://footer.new' },
{ id: 'body-link', payload: 'https://body.new' },
{ id: 'missing-link', payload: 'https://missing.new' },
],
}),
}]);
expect(getRange(doc, 'header-link', 'header')?.properties).toEqual({ url: 'https://header.new' });
expect(getRange(doc, 'footer-link', 'footer')?.properties).toEqual({ url: 'https://footer.new' });
expect(getRange(doc, 'body-link')?.properties).toEqual({ url: 'https://body.new' });
expect(getRange(doc, 'ignored-range')?.properties).toBeUndefined();
});
it('should return an empty resource payload when the document does not exist', () => {
const resource = resourceManagerService.getResourcesByType('missing-doc', UniverInstanceType.UNIVER_DOC)
.find((item) => item.name === DOC_HYPER_LINK_PLUGIN);
expect(resource).toBeDefined();
expect(resource?.data).toBe(JSON.stringify({ links: [] }));
});
});
@@ -14,35 +14,100 @@
* limitations under the License.
*/
import type { Injector } from '@univerjs/core';
import type { FUniver } from '@univerjs/core/facade';
import { ICommandService } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import type { DocumentDataModel, ICommandService, IDocumentData } from '@univerjs/core';
import { InsertCommand } from '@univerjs/docs-ui';
import { beforeEach, describe, expect, it } from 'vitest';
import { createTestBed } from './create-test-bed';
import '@univerjs/docs-ui/facade';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FDocument } from '../f-document';
describe('Test FDocument', () => {
let get: Injector['get'];
let commandService: ICommandService;
let univerAPI: FUniver;
let commandService: Pick<ICommandService, 'executeCommand'>;
let resourceManagerService: { getResourcesByType: ReturnType<typeof vi.fn> };
let univerInstanceService: { focusUnit: ReturnType<typeof vi.fn> };
let renderManagerService: { getRenderById: ReturnType<typeof vi.fn> };
let documentDataModel: Pick<DocumentDataModel, 'getUnitId' | 'getSnapshot'>;
let document: FDocument;
beforeEach(() => {
const testBed = createTestBed();
get = testBed.get;
univerAPI = testBed.univerAPI;
commandService = get(ICommandService);
commandService.registerCommand(InsertCommand);
commandService.registerCommand(RichTextEditingMutation);
commandService = {
executeCommand: vi.fn().mockResolvedValue(true),
};
resourceManagerService = {
getResourcesByType: vi.fn(() => []),
};
univerInstanceService = {
focusUnit: vi.fn(),
};
renderManagerService = {
getRenderById: vi.fn(),
};
documentDataModel = {
getUnitId: () => 'test',
getSnapshot: () => ({
id: 'test',
title: 'Test Document',
documentStyle: {},
body: {
dataStream: 'Hello,\r\n',
},
}),
};
document = new FDocument(
documentDataModel as DocumentDataModel,
{} as never,
univerInstanceService as never,
commandService as ICommandService,
resourceManagerService as never,
renderManagerService as never
);
});
it('Document appendText', async () => {
const activeDoc = univerAPI.getActiveDocument()!;
expect(await activeDoc.appendText('Univer')).toBeTruthy();
it('appends text by executing the insert command at the tail of the body', async () => {
await expect(document.appendText('Univer')).resolves.toBe(true);
const dataStream = activeDoc.getSnapshot().body!.dataStream;
expect(dataStream.substring(0, dataStream.length - 2)).toEqual('Hello,Univer');
expect(commandService.executeCommand).toHaveBeenCalledWith(InsertCommand.id, {
unitId: 'test',
body: {
dataStream: 'Univer',
},
range: {
startOffset: 6,
endOffset: 6,
collapsed: true,
segmentId: '',
},
segmentId: '',
});
});
it('throws when appending text to a document without a body', () => {
const emptyDocument = new FDocument(
{
getUnitId: () => 'test',
getSnapshot: () => ({ id: 'test' } as IDocumentData),
} as DocumentDataModel,
{} as never,
univerInstanceService as never,
commandService as ICommandService,
resourceManagerService as never,
renderManagerService as never
);
expect(() => emptyDocument.appendText('Univer')).toThrowError('The document body is empty');
});
it('includes current document resources in snapshots', () => {
resourceManagerService.getResourcesByType.mockReturnValue([
{
name: 'test-resource',
data: '{"value":1}',
},
]);
expect(document.getSnapshot().resources).toEqual([
{
name: 'test-resource',
data: '{"value":1}',
},
]);
});
});
@@ -0,0 +1,114 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { IDrawingManagerService } from '@univerjs/drawing';
import type { BaseObject, Image } from '@univerjs/engine-render';
import { DrawingTypeEnum } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getUpdateParams } from '../get-update-params';
describe('getUpdateParams', () => {
it('maps drawing objects back to drawing params and keeps missing objects as null', () => {
const shapeObject = {
oKey: 'shape-1',
left: 10,
top: 20,
width: 30,
height: 40,
angle: 15,
} as BaseObject;
const imageObject = {
oKey: 'image-1',
left: 1,
top: 2,
width: 3,
height: 4,
angle: 5,
srcRect: {
left: 6,
top: 7,
width: 8,
height: 9,
},
} as unknown as Image;
const drawingManagerService = {
getDrawingOKey: (oKey: string) => {
if (oKey === 'shape-1') {
return {
unitId: 'unit-1',
subUnitId: 'sub-1',
drawingId: 'drawing-shape',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
};
}
if (oKey === 'image-1') {
return {
unitId: 'unit-1',
subUnitId: 'sub-1',
drawingId: 'drawing-image',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
};
}
return null;
},
} as IDrawingManagerService;
const result = getUpdateParams(new Map([
['shape-1', shapeObject],
['missing', { oKey: 'missing' } as BaseObject],
['image-1', imageObject],
]), drawingManagerService);
expect(result).toEqual([
{
unitId: 'unit-1',
subUnitId: 'sub-1',
drawingId: 'drawing-shape',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
transform: {
left: 10,
top: 20,
width: 30,
height: 40,
angle: 15,
},
},
null,
{
unitId: 'unit-1',
subUnitId: 'sub-1',
drawingId: 'drawing-image',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
left: 1,
top: 2,
width: 3,
height: 4,
angle: 5,
},
srcRect: {
left: 6,
top: 7,
width: 8,
height: 9,
},
},
]);
});
});
@@ -0,0 +1,246 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { IDrawingParam, IDrawingSearch } from '@univerjs/core';
import { BooleanNumber, DrawingTypeEnum } from '@univerjs/core';
import { beforeEach, describe, expect, it } from 'vitest';
import { UnitDrawingService } from '../drawing-manager-impl.service';
const unitId = 'unit';
const subUnitId = 'subUnit';
function createDrawing(drawingId: string, overrides: Partial<IDrawingParam> = {}): IDrawingParam {
return {
unitId,
subUnitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
...overrides,
};
}
function createSearch(drawingId: string): IDrawingSearch {
return {
unitId,
subUnitId,
drawingId,
};
}
describe('UnitDrawingService', () => {
let service: UnitDrawingService<IDrawingParam>;
beforeEach(() => {
service = new UnitDrawingService<IDrawingParam>();
});
it('should register, initialize and remove drawing data for a unit', () => {
const added: IDrawingSearch[][] = [];
const removed: IDrawingSearch[][] = [];
service.add$.subscribe((params) => added.push(params));
service.remove$.subscribe((params) => removed.push(params));
service.registerDrawingData(unitId, {
[subUnitId]: {
data: {
a: createDrawing('a'),
b: createDrawing('b'),
},
order: ['a', 'b'],
},
});
service.initializeNotification(unitId);
expect(added).toHaveLength(1);
expect(added[0].map((item) => item.drawingId)).toEqual(['a', 'b']);
expect(service.getDrawingData(unitId, subUnitId)).toMatchObject({
a: createDrawing('a'),
b: createDrawing('b'),
});
service.removeDrawingDataForUnit(unitId);
expect(removed).toEqual([[createSearch('a'), createSearch('b')]]);
expect(service.getDrawingDataForUnit(unitId)).toEqual({});
});
it('should build and apply add, update and remove operations', () => {
const drawingA = createDrawing('a', { allowTransform: true });
const drawingB = createDrawing('b');
const addOp = service.getBatchAddOp([drawingA, drawingB]);
service.applyJson1(unitId, subUnitId, addOp.redo);
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['b', 'a']);
expect(service.getDrawingByParam(createSearch('a'))).toEqual(drawingA);
expect(service.getDrawingOKey(`${unitId}#-#${subUnitId}#-#a`)).toEqual(drawingA);
const updatedDrawingA = createDrawing('a', {
allowTransform: false,
groupId: 'group-1',
});
const updateOp = service.getBatchUpdateOp([updatedDrawingA]);
service.applyJson1(unitId, subUnitId, updateOp.redo);
expect(service.getDrawingByParam(createSearch('a'))).toMatchObject(updatedDrawingA);
expect(service.getOldDrawingByParam(createSearch('a'))).toMatchObject(drawingA);
const removeOp = service.getBatchRemoveOp([createSearch('b')]);
service.applyJson1(unitId, subUnitId, removeOp.redo);
expect(service.getDrawingByParam(createSearch('b'))).toBeUndefined();
expect(service.getOldDrawingByParam(createSearch('b'))).toMatchObject(drawingB);
});
it('should manage focus, refresh and visibility notifications', () => {
const focused: IDrawingParam[][] = [];
const refreshed: IDrawingParam[][] = [];
const visibleUpdates: Array<Array<{ drawingId: string; visible: boolean }>> = [];
const drawing = createDrawing('a');
const addOp = service.getBatchAddOp([drawing]);
service.applyJson1(unitId, subUnitId, addOp.redo);
service.focus$.subscribe((params) => focused.push(params));
service.refreshTransform$.subscribe((params) => refreshed.push(params));
service.visible$.subscribe((params) => visibleUpdates.push(params.map(({ drawingId, visible }) => ({ drawingId, visible }))));
service.focusDrawing([createSearch('a'), createSearch('missing')]);
expect(focused[0].map((item) => item.drawingId)).toEqual(['a']);
expect(service.getFocusDrawings().map((item) => item.drawingId)).toEqual(['a']);
const transformed = createDrawing('a', {
transform: { left: 1, top: 2 } as NonNullable<IDrawingParam['transform']>,
transforms: [{ left: 3, top: 4 } as NonNullable<IDrawingParam['transform']>] as NonNullable<IDrawingParam['transforms']>,
isMultiTransform: BooleanNumber.TRUE,
});
service.refreshTransform([transformed]);
expect(refreshed).toEqual([[transformed]]);
expect(service.getDrawingByParam(createSearch('a'))).toMatchObject({
transform: { left: 1, top: 2 },
transforms: [{ left: 3, top: 4 }],
isMultiTransform: BooleanNumber.TRUE,
});
service.visibleNotification([{ ...createSearch('a'), visible: false }]);
expect(visibleUpdates).toEqual([[{ drawingId: 'a', visible: false }]]);
service.focusDrawing(null);
expect(focused.at(-1)).toEqual([]);
expect(service.getFocusDrawings()).toEqual([]);
});
it('should update drawing order in all directions', () => {
service.registerDrawingData(unitId, {
[subUnitId]: {
data: {
a: createDrawing('a'),
b: createDrawing('b'),
c: createDrawing('c'),
},
order: ['a', 'b', 'c'],
},
});
service.applyJson1(unitId, subUnitId, service.getForwardDrawingsOp({ unitId, subUnitId, drawingIds: ['a'] }).redo);
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['b', 'a', 'c']);
service.applyJson1(unitId, subUnitId, service.getBackwardDrawingOp({ unitId, subUnitId, drawingIds: ['a'] }).redo);
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['a', 'b', 'c']);
service.applyJson1(unitId, subUnitId, service.getFrontDrawingsOp({ unitId, subUnitId, drawingIds: ['a'] }).redo);
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['b', 'c', 'a']);
service.applyJson1(unitId, subUnitId, service.getBackDrawingsOp({ unitId, subUnitId, drawingIds: ['a'] }).redo);
expect(service.getDrawingOrder(unitId, subUnitId)).toEqual(['a', 'b', 'c']);
});
it('should group, ungroup and expose feature notifications and flags', () => {
const addOp = service.getBatchAddOp([
createDrawing('child-1'),
createDrawing('child-2'),
]);
service.applyJson1(unitId, subUnitId, addOp.redo);
const groupNotifications: IDrawingSearch[][] = [];
const pluginUpdates: IDrawingParam[][] = [];
const pluginAdds: IDrawingParam[][] = [];
const pluginRemoves: IDrawingSearch[][] = [];
const pluginOrderUpdates: string[][] = [];
const pluginGroupUpdates: string[][] = [];
const pluginUngroupUpdates: string[][] = [];
service.update$.subscribe((params) => groupNotifications.push(params));
service.featurePluginUpdate$.subscribe((params) => pluginUpdates.push(params));
service.featurePluginAdd$.subscribe((params) => pluginAdds.push(params));
service.featurePluginRemove$.subscribe((params) => pluginRemoves.push(params));
service.featurePluginOrderUpdate$.subscribe((params) => pluginOrderUpdates.push(params.drawingIds));
service.featurePluginGroupUpdate$.subscribe((params) => pluginGroupUpdates.push(params.map((item) => item.parent.drawingId)));
service.featurePluginUngroupUpdate$.subscribe((params) => pluginUngroupUpdates.push(params.map((item) => item.parent.drawingId)));
const groupParent = createDrawing('group', { drawingType: DrawingTypeEnum.DRAWING_GROUP });
const groupedChildren = [
createDrawing('child-1', { groupId: 'group' }),
createDrawing('child-2', { groupId: 'group' }),
];
const groupParams = [{
parent: groupParent,
children: groupedChildren,
}];
service.applyJson1(unitId, subUnitId, service.getGroupDrawingOp(groupParams).redo);
expect(service.getDrawingByParam(createSearch('group'))).toMatchObject(groupParent);
expect(service.getDrawingsByGroup(createSearch('group')).map((item) => item.drawingId).sort()).toEqual(['child-1', 'child-2']);
const ungroupParams = [{
parent: groupParent,
children: [createDrawing('child-1'), createDrawing('child-2')],
}];
service.applyJson1(unitId, subUnitId, service.getUngroupDrawingOp(ungroupParams).redo);
expect(service.getDrawingByParam(createSearch('group'))).toBeUndefined();
service.updateNotification([createSearch('child-1')]);
service.featurePluginUpdateNotification([createDrawing('child-1')]);
service.featurePluginAddNotification([createDrawing('child-2')]);
service.featurePluginRemoveNotification([createSearch('child-2')]);
service.featurePluginOrderUpdateNotification({ unitId, subUnitId, drawingIds: ['child-1'], arrangeType: 0 });
service.featurePluginGroupUpdateNotification(groupParams);
service.featurePluginUngroupUpdateNotification(ungroupParams);
expect(groupNotifications).toEqual([[createSearch('child-1')]]);
expect(pluginUpdates[0].map((item) => item.drawingId)).toEqual(['child-1']);
expect(pluginAdds[0].map((item) => item.drawingId)).toEqual(['child-2']);
expect(pluginRemoves).toEqual([[createSearch('child-2')]]);
expect(pluginOrderUpdates).toEqual([['child-1']]);
expect(pluginGroupUpdates).toEqual([['group']]);
expect(pluginUngroupUpdates).toEqual([['group']]);
expect(service.getDrawingVisible()).toBe(true);
expect(service.getDrawingEditable()).toBe(true);
service.setDrawingVisible(false);
service.setDrawingEditable(false);
expect(service.getDrawingVisible()).toBe(false);
expect(service.getDrawingEditable()).toBe(false);
service.dispose();
expect(service.drawingManagerData).toEqual({});
});
});
@@ -0,0 +1,135 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { ImageSourceType, ImageUploadStatusType } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DRAWING_IMAGE_ALLOW_SIZE } from '../../basics/config';
import { ImageIoService } from '../image-io-impl.service';
type MockLoadEvent = ProgressEvent<FileReader> & {
target: {
result: string | null;
};
};
class MockImage {
src = '';
}
class SuccessFileReader {
onload: null | ((event: MockLoadEvent) => void) = null;
readAsDataURL(_file: File) {
queueMicrotask(() => {
this.onload?.({
target: {
result: 'data:image/png;base64,Zm9v',
},
} as MockLoadEvent);
});
}
}
class EmptyFileReader {
onload: null | ((event: MockLoadEvent) => void) = null;
readAsDataURL(_file: File) {
queueMicrotask(() => {
this.onload?.({
target: {
result: null,
},
} as MockLoadEvent);
});
}
}
describe('ImageIoService', () => {
let service: ImageIoService;
beforeEach(() => {
service = new ImageIoService();
vi.stubGlobal('Image', MockImage);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('should emit wait count changes and manage image source cache', () => {
const counts: number[] = [];
const cachedImage = new MockImage() as unknown as HTMLImageElement;
service.change$.subscribe((count) => counts.push(count));
service.setWaitCount(2);
service.addImageSourceCache('https://example.com/image.png', ImageSourceType.URL, cachedImage);
expect(service.getImageSourceCache('https://example.com/image.png', ImageSourceType.URL)).toBe(cachedImage);
expect(service.getImageSourceCache('data:image/png;base64,Zm9v', ImageSourceType.BASE64)).toMatchObject({
src: 'data:image/png;base64,Zm9v',
});
expect(counts).toEqual([2]);
});
it('should ignore invalid cache insertions and resolve image ids directly', async () => {
service.addImageSourceCache('data:image/png;base64,Zm9v', ImageSourceType.BASE64, new MockImage() as unknown as HTMLImageElement);
service.addImageSourceCache('https://example.com/image.png', ImageSourceType.URL, null);
expect(service.getImageSourceCache('https://example.com/image.png', ImageSourceType.URL)).toBeUndefined();
await expect(service.getImage('image-id')).resolves.toBe('image-id');
});
it('should reject unsupported image types and oversized files', async () => {
const counts: number[] = [];
service.change$.subscribe((count) => counts.push(count));
service.setWaitCount(1);
await expect(service.saveImage(new File(['abc'], 'a.txt', { type: 'text/plain' }))).rejects.toThrow(ImageUploadStatusType.ERROR_IMAGE_TYPE);
service.setWaitCount(1);
await expect(service.saveImage(new File(['abc'], 'a.png', { type: 'image/png' }))).resolves.toMatchObject({
source: expect.any(String),
});
service.setWaitCount(1);
await expect(
service.saveImage(new File([new Uint8Array(DRAWING_IMAGE_ALLOW_SIZE + 1)], 'big.png', { type: 'image/png' }))
).rejects.toThrow(ImageUploadStatusType.ERROR_EXCEED_SIZE);
expect(counts).toContain(0);
});
it('should reject when file reader returns empty result', async () => {
vi.stubGlobal('FileReader', EmptyFileReader);
service.setWaitCount(1);
await expect(service.saveImage(new File(['abc'], 'empty.png', { type: 'image/png' }))).rejects.toThrow(ImageUploadStatusType.ERROR_IMAGE);
});
it('should save valid images as base64 payload', async () => {
vi.stubGlobal('FileReader', SuccessFileReader);
service.setWaitCount(1);
await expect(service.saveImage(new File(['abc'], 'ok.png', { type: 'image/png' }))).resolves.toMatchObject({
imageId: expect.any(String),
imageSourceType: ImageSourceType.BASE64,
source: 'data:image/png;base64,Zm9v',
base64Cache: 'data:image/png;base64,Zm9v',
status: ImageUploadStatusType.SUCCUSS,
});
});
});
@@ -0,0 +1,82 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { URLImageService } from '../url-image.service';
describe('URLImageService', () => {
let service: URLImageService;
beforeEach(() => {
service = new URLImageService();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('should use downloader when registered and reset after disposal', async () => {
const disposable = service.registerURLImageDownloader(async (url) => `base64:${url}`);
await expect(service.getImage('https://example.com/image.png')).resolves.toBe('base64:https://example.com/image.png');
disposable.dispose();
await expect(service.getImage('https://example.com/image.png')).resolves.toBe('https://example.com/image.png');
});
it('should fall back to original url when custom downloader fails', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
service.registerURLImageDownloader(async () => {
throw new Error('downloader failed');
});
await expect(service.getImage('https://example.com/image.png')).resolves.toBe('https://example.com/image.png');
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it('should download blob through converted base64 when custom downloader succeeds', async () => {
const blob = new Blob(['image']);
const fetchMock = vi.fn(async () => ({
blob: async () => blob,
}));
vi.stubGlobal('fetch', fetchMock);
service.registerURLImageDownloader(async () => 'data:image/png;base64,Zm9v');
await expect(service.downloadImage('https://example.com/image.png')).resolves.toBe(blob);
expect(fetchMock).toHaveBeenCalledWith('data:image/png;base64,Zm9v');
});
it('should fall back to the original url when custom blob download fails', async () => {
const blob = new Blob(['image']);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const fetchMock = vi.fn(async () => ({
blob: async () => blob,
}));
vi.stubGlobal('fetch', fetchMock);
service.registerURLImageDownloader(async () => {
throw new Error('downloader failed');
});
await expect(service.downloadImage('https://example.com/image.png')).resolves.toBe(blob);
expect(fetchMock).toHaveBeenCalledWith('https://example.com/image.png');
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,72 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { afterEach, describe, expect, it, vi } from 'vitest';
import { getDrawingShapeKeyByDrawingSearch } from '../get-image-shape-key';
import { getImageSize } from '../get-image-size';
class MockImage {
width = 320;
height = 180;
onload: null | (() => void) = null;
onerror: null | ((error?: unknown) => void) = null;
private _src = '';
get src() {
return this._src;
}
set src(value: string) {
this._src = value;
queueMicrotask(() => {
if (value === 'bad-image') {
this.onerror?.(new Error('load failed'));
return;
}
this.onload?.();
});
}
}
describe('drawing utils', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('should generate a drawing shape key with or without index', () => {
expect(getDrawingShapeKeyByDrawingSearch({ unitId: 'u', subUnitId: 's', drawingId: 'd' })).toBe('u#-#s#-#d');
expect(getDrawingShapeKeyByDrawingSearch({ unitId: 'u', subUnitId: 's', drawingId: 'd' }, 2)).toBe('u#-#s#-#d#-#2');
});
it('should resolve image size from the loaded image element', async () => {
vi.stubGlobal('Image', MockImage);
await expect(getImageSize('good-image')).resolves.toMatchObject({
width: 320,
height: 180,
image: expect.objectContaining({ src: 'good-image' }),
});
});
it('should reject when image loading fails', async () => {
vi.stubGlobal('Image', MockImage);
await expect(getImageSize('bad-image')).rejects.toEqual(new Error('load failed'));
});
});
@@ -0,0 +1,386 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { ICommandInfo } from '@univerjs/core';
import { ObjectMatrix } from '@univerjs/core';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SetArrayFormulaDataMutation } from '../../commands/mutations/set-array-formula-data.mutation';
import {
SetCellFormulaDependencyCalculationMutation,
SetCellFormulaDependencyCalculationResultMutation,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationResultMutation,
SetFormulaCalculationStartMutation,
SetFormulaCalculationStopMutation,
SetFormulaDependencyCalculationMutation,
SetFormulaDependencyCalculationResultMutation,
SetFormulaStringBatchCalculationMutation,
SetFormulaStringBatchCalculationResultMutation,
SetQueryFormulaDependencyAllMutation,
SetQueryFormulaDependencyAllResultMutation,
SetQueryFormulaDependencyMutation,
SetQueryFormulaDependencyResultMutation,
} from '../../commands/mutations/set-formula-calculation.mutation';
import { SetImageFormulaDataMutation } from '../../commands/mutations/set-image-formula-data.mutation';
import { FormulaExecutedStateType, FormulaExecuteStageType } from '../../services/runtime.service';
import { CalculateController } from '../calculate.controller';
interface ICommandServiceMock {
executeCommand: ReturnType<typeof vi.fn>;
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => { dispose: () => void };
emit: (id: string, params?: unknown) => void;
}
function createCommandServiceMock(): ICommandServiceMock {
const callbacks = new Set<(commandInfo: ICommandInfo) => void>();
return {
executeCommand: vi.fn(async () => true),
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => {
callbacks.add(callback);
return {
dispose: () => callbacks.delete(callback),
};
},
emit: (id: string, params?: unknown) => {
callbacks.forEach((callback) => callback({ id, params } as ICommandInfo));
},
};
}
describe('CalculateController', () => {
it('should dispatch execute and stop events from commands', async () => {
const commandService = createCommandServiceMock();
const executionCompleteListener$ = new Subject<any>();
const executionInProgressListener$ = new Subject<any>();
const calculateFormulaService = {
executionCompleteListener$,
executionInProgressListener$,
stopFormulaExecution: vi.fn(),
execute: vi.fn(),
executeFormulas: vi.fn(async () => ({ u: {} })),
getAllDependencyJson: vi.fn(async () => [{ treeId: 1 }]),
getCellDependencyJson: vi.fn(async () => ({ treeId: 2 })),
getInRangeFormulas: vi.fn(async () => [{ treeId: 3 }]),
getRangeDependents: vi.fn(async () => [{ treeId: 4 }]),
getDependentsAndInRangeFormulas: vi.fn(async () => ({ dependents: [], inRanges: [] })),
};
const formulaDataModel = {
getFormulaData: vi.fn(() => ({ unit: {} })),
getArrayFormulaCellData: vi.fn(() => ({ unit: {} })),
getArrayFormulaRange: vi.fn(() => ({ unit: {} })),
setArrayFormulaRange: vi.fn(),
setArrayFormulaCellData: vi.fn(),
clearPreviousArrayFormulaCellData: vi.fn(),
mergeArrayFormulaCellData: vi.fn(),
mergeArrayFormulaRange: vi.fn(),
};
// eslint-disable-next-line no-new
new CalculateController(
commandService as never,
calculateFormulaService as never,
formulaDataModel as never
);
commandService.emit(SetFormulaCalculationStopMutation.id, {});
expect(calculateFormulaService.stopFormulaExecution).toHaveBeenCalledTimes(1);
commandService.emit(SetFormulaCalculationStartMutation.id, {
forceCalculation: true,
dirtyRanges: [],
});
expect(calculateFormulaService.execute).toHaveBeenCalledWith(
expect.objectContaining({
formulaData: { unit: {} },
arrayFormulaCellData: { unit: {} },
arrayFormulaRange: { unit: {} },
forceCalculate: true,
})
);
commandService.emit(SetArrayFormulaDataMutation.id, {
arrayFormulaRange: { u: { s: {} } },
arrayFormulaCellData: { u: { s: {} } },
});
expect(formulaDataModel.setArrayFormulaRange).toHaveBeenCalled();
expect(formulaDataModel.setArrayFormulaCellData).toHaveBeenCalled();
});
it('should handle formula query commands and emit local result mutations', async () => {
const commandService = createCommandServiceMock();
const executionCompleteListener$ = new Subject<any>();
const executionInProgressListener$ = new Subject<any>();
const calculateFormulaService = {
executionCompleteListener$,
executionInProgressListener$,
stopFormulaExecution: vi.fn(),
execute: vi.fn(),
executeFormulas: vi.fn(async () => ({ unit: { sheet: {} } })),
getAllDependencyJson: vi.fn(async () => [{ treeId: 11 }]),
getCellDependencyJson: vi.fn(async () => ({ treeId: 12 })),
getInRangeFormulas: vi.fn(async () => [{ treeId: 13 }]),
getRangeDependents: vi.fn(async () => [{ treeId: 14 }]),
getDependentsAndInRangeFormulas: vi.fn(async () => ({ dependents: [{ treeId: 15 }], inRanges: [{ treeId: 16 }] })),
};
const formulaDataModel = {
getFormulaData: vi.fn(() => ({})),
getArrayFormulaCellData: vi.fn(() => ({})),
getArrayFormulaRange: vi.fn(() => ({})),
setArrayFormulaRange: vi.fn(),
setArrayFormulaCellData: vi.fn(),
clearPreviousArrayFormulaCellData: vi.fn(),
mergeArrayFormulaCellData: vi.fn(),
mergeArrayFormulaRange: vi.fn(),
};
// eslint-disable-next-line no-new
new CalculateController(
commandService as never,
calculateFormulaService as never,
formulaDataModel as never
);
commandService.emit(SetFormulaStringBatchCalculationMutation.id, {
formulas: {
unit: {
sheet: {
1: {
1: ['=A1'],
},
},
},
},
});
await Promise.resolve();
expect(calculateFormulaService.executeFormulas).toHaveBeenCalled();
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetFormulaStringBatchCalculationResultMutation.id,
{ result: { unit: { sheet: {} } } },
{ onlyLocal: true }
);
commandService.emit(SetFormulaDependencyCalculationMutation.id, {});
await Promise.resolve();
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetFormulaDependencyCalculationResultMutation.id,
{ result: [{ treeId: 11 }] },
{ onlyLocal: true }
);
commandService.emit(SetCellFormulaDependencyCalculationMutation.id, {
unitId: 'u',
sheetId: 's',
row: 1,
column: 2,
});
await Promise.resolve();
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetCellFormulaDependencyCalculationResultMutation.id,
{ result: { treeId: 12 } },
{ onlyLocal: true }
);
commandService.emit(SetQueryFormulaDependencyMutation.id, {
unitRanges: [],
isInRange: false,
});
await Promise.resolve();
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetQueryFormulaDependencyResultMutation.id,
{ result: [{ treeId: 14 }] },
{ onlyLocal: true }
);
commandService.emit(SetQueryFormulaDependencyMutation.id, {
unitRanges: [],
isInRange: true,
});
await Promise.resolve();
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetQueryFormulaDependencyResultMutation.id,
{ result: [{ treeId: 13 }] },
{ onlyLocal: true }
);
commandService.emit(SetQueryFormulaDependencyAllMutation.id, {
unitRanges: [],
});
await Promise.resolve();
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetQueryFormulaDependencyAllResultMutation.id,
{ result: { dependents: [{ treeId: 15 }], inRanges: [{ treeId: 16 }] } },
{ onlyLocal: true }
);
});
it('should emit calculation progress and apply successful runtime result', async () => {
const commandService = createCommandServiceMock();
const executionCompleteListener$ = new Subject<any>();
const executionInProgressListener$ = new Subject<any>();
const calculateFormulaService = {
executionCompleteListener$,
executionInProgressListener$,
stopFormulaExecution: vi.fn(),
execute: vi.fn(),
executeFormulas: vi.fn(async () => ({})),
getAllDependencyJson: vi.fn(async () => []),
getCellDependencyJson: vi.fn(async () => undefined),
getInRangeFormulas: vi.fn(async () => []),
getRangeDependents: vi.fn(async () => []),
getDependentsAndInRangeFormulas: vi.fn(async () => ({ dependents: [], inRanges: [] })),
};
const formulaDataModel = {
getFormulaData: vi.fn(() => ({})),
getArrayFormulaCellData: vi.fn(() => ({})),
getArrayFormulaRange: vi.fn(() => ({})),
setArrayFormulaRange: vi.fn(),
setArrayFormulaCellData: vi.fn(),
clearPreviousArrayFormulaCellData: vi.fn(),
mergeArrayFormulaCellData: vi.fn(),
mergeArrayFormulaRange: vi.fn(),
};
// eslint-disable-next-line no-new
new CalculateController(
commandService as never,
calculateFormulaService as never,
formulaDataModel as never
);
executionInProgressListener$.next({
totalFormulasToCalculate: 3,
completedFormulasCount: 1,
totalArrayFormulasToCalculate: 0,
completedArrayFormulasCount: 0,
formulaCycleIndex: 0,
stage: FormulaExecuteStageType.CURRENTLY_CALCULATING,
});
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetFormulaCalculationNotificationMutation.id,
expect.objectContaining({
stageInfo: expect.objectContaining({
stage: FormulaExecuteStageType.CURRENTLY_CALCULATING,
}),
}),
{ onlyLocal: true }
);
executionCompleteListener$.next({
functionsExecutedState: FormulaExecutedStateType.SUCCESS,
unitData: {
unit: {
sheet: new ObjectMatrix({
0: {
0: { v: 123 },
},
}),
},
},
unitOtherData: {},
arrayFormulaRange: { unit: { sheet: {} } },
arrayFormulaCellData: { unit: { sheet: {} } },
clearArrayFormulaCellData: {},
arrayFormulaEmbedded: { unit: { sheet: {} } },
imageFormulaData: [
{ unitId: 'unit', sheetId: 'sheet', row: 1, column: 1, imageId: 'img-1' },
],
runtimeFeatureRange: {},
runtimeFeatureCellData: {},
dependencyTreeModelData: [{ treeId: 100 }],
});
await Promise.resolve();
expect(formulaDataModel.clearPreviousArrayFormulaCellData).toHaveBeenCalled();
expect(formulaDataModel.mergeArrayFormulaCellData).toHaveBeenCalled();
expect(formulaDataModel.mergeArrayFormulaRange).toHaveBeenCalled();
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetArrayFormulaDataMutation.id,
expect.anything(),
{ onlyLocal: true }
);
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetImageFormulaDataMutation.id,
expect.objectContaining({
imageFormulaData: expect.any(Array),
}),
{ onlyLocal: true }
);
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetFormulaDependencyCalculationResultMutation.id,
{ result: [{ treeId: 100 }] },
{ onlyLocal: true }
);
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetFormulaCalculationResultMutation.id,
expect.anything(),
{ onlyLocal: true }
);
});
it('should only apply tree result when no formula executed', async () => {
const commandService = createCommandServiceMock();
const executionCompleteListener$ = new Subject<any>();
const executionInProgressListener$ = new Subject<any>();
const calculateFormulaService = {
executionCompleteListener$,
executionInProgressListener$,
stopFormulaExecution: vi.fn(),
execute: vi.fn(),
executeFormulas: vi.fn(async () => ({})),
getAllDependencyJson: vi.fn(async () => []),
getCellDependencyJson: vi.fn(async () => undefined),
getInRangeFormulas: vi.fn(async () => []),
getRangeDependents: vi.fn(async () => []),
getDependentsAndInRangeFormulas: vi.fn(async () => ({ dependents: [], inRanges: [] })),
};
const formulaDataModel = {
getFormulaData: vi.fn(() => ({})),
getArrayFormulaCellData: vi.fn(() => ({})),
getArrayFormulaRange: vi.fn(() => ({})),
setArrayFormulaRange: vi.fn(),
setArrayFormulaCellData: vi.fn(),
clearPreviousArrayFormulaCellData: vi.fn(),
mergeArrayFormulaCellData: vi.fn(),
mergeArrayFormulaRange: vi.fn(),
};
// eslint-disable-next-line no-new
new CalculateController(
commandService as never,
calculateFormulaService as never,
formulaDataModel as never
);
executionCompleteListener$.next({
functionsExecutedState: FormulaExecutedStateType.NOT_EXECUTED,
dependencyTreeModelData: [{ treeId: 201 }],
});
await Promise.resolve();
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetFormulaDependencyCalculationResultMutation.id,
{ result: [{ treeId: 201 }] },
{ onlyLocal: true }
);
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetFormulaCalculationNotificationMutation.id,
{ functionsExecutedState: FormulaExecutedStateType.NOT_EXECUTED },
{ onlyLocal: true }
);
});
});
@@ -0,0 +1,178 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { ICommandInfo } from '@univerjs/core';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SetDefinedNameMutation } from '../../commands/mutations/set-defined-name.mutation';
import { RemoveFeatureCalculationMutation, SetFeatureCalculationMutation } from '../../commands/mutations/set-feature-calculation.mutation';
import { SetFormulaDataMutation } from '../../commands/mutations/set-formula-data.mutation';
import { RemoveOtherFormulaMutation, SetOtherFormulaMutation } from '../../commands/mutations/set-other-formula.mutation';
import { SetDependencyController } from '../set-dependency.controller';
interface ICommandServiceMock {
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => { dispose: () => void };
emit: (id: string, params?: unknown) => void;
}
function createCommandServiceMock(): ICommandServiceMock {
const callbacks = new Set<(commandInfo: ICommandInfo) => void>();
return {
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => {
callbacks.add(callback);
return {
dispose: () => callbacks.delete(callback),
};
},
emit: (id: string, params?: unknown) => {
callbacks.forEach((callback) => callback({ id, params } as ICommandInfo));
},
};
}
describe('SetDependencyController', () => {
it('should react to feature manager changes and command mutations', () => {
const commandService = createCommandServiceMock();
const onChanged$ = new Subject<{ unitId: string; subUnitId: string; featureIds: string[] }>();
const dependencyManagerService = {
removeFeatureFormulaDependency: vi.fn(),
removeOtherFormulaDependency: vi.fn(),
clearFormulaDependency: vi.fn(),
removeFormulaDependency: vi.fn(),
removeFormulaDependencyByDefinedName: vi.fn(),
};
const featureCalculationManagerService = {
onChanged$,
};
// eslint-disable-next-line no-new
new SetDependencyController(
commandService as never,
dependencyManagerService as never,
featureCalculationManagerService as never
);
onChanged$.next({ unitId: 'u', subUnitId: 's', featureIds: ['f1', 'f2'] });
expect(dependencyManagerService.removeFeatureFormulaDependency).toHaveBeenCalledWith('u', 's', ['f1', 'f2']);
commandService.emit(RemoveFeatureCalculationMutation.id, { unitId: 'u', subUnitId: 's', featureIds: ['f3'] });
expect(dependencyManagerService.removeFeatureFormulaDependency).toHaveBeenCalledWith('u', 's', ['f3']);
commandService.emit(SetFeatureCalculationMutation.id, {
featureId: 'f4',
calculationParam: {
unitId: 'u',
subUnitId: 's',
},
});
expect(dependencyManagerService.removeFeatureFormulaDependency).toHaveBeenCalledWith('u', 's', ['f4']);
commandService.emit(RemoveOtherFormulaMutation.id, {
unitId: 'u',
subUnitId: 's',
formulaIdList: ['fo1'],
});
expect(dependencyManagerService.removeOtherFormulaDependency).toHaveBeenCalledWith('u', 's', ['fo1']);
commandService.emit(SetOtherFormulaMutation.id, {
unitId: 'u',
subUnitId: 's',
formulaMap: {
a: { f: '=A1', ranges: [] },
b: { f: '=B1', ranges: [] },
},
});
expect(dependencyManagerService.removeOtherFormulaDependency).toHaveBeenCalledWith('u', 's', ['a', 'b']);
commandService.emit(SetDefinedNameMutation.id, {
unitId: 'u',
name: 'MY_DEFINED_NAME',
});
expect(dependencyManagerService.removeFormulaDependencyByDefinedName).toHaveBeenCalledWith('u', 'MY_DEFINED_NAME');
});
it('should clear or remove formula dependencies according to formulaData payload', () => {
const commandService = createCommandServiceMock();
const dependencyManagerService = {
removeFeatureFormulaDependency: vi.fn(),
removeOtherFormulaDependency: vi.fn(),
clearFormulaDependency: vi.fn(),
removeFormulaDependency: vi.fn(),
removeFormulaDependencyByDefinedName: vi.fn(),
};
const featureCalculationManagerService = {
onChanged$: new Subject<{ unitId: string; subUnitId: string; featureIds: string[] }>(),
};
// eslint-disable-next-line no-new
new SetDependencyController(
commandService as never,
dependencyManagerService as never,
featureCalculationManagerService as never
);
commandService.emit(SetFormulaDataMutation.id, {
formulaData: {
u1: null,
u2: {
s1: null,
s2: {
1: {
2: { f: '=A1' },
},
},
},
},
});
expect(dependencyManagerService.clearFormulaDependency).toHaveBeenCalledWith('u1');
expect(dependencyManagerService.clearFormulaDependency).toHaveBeenCalledWith('u2', 's1');
expect(dependencyManagerService.removeFormulaDependency).toHaveBeenCalledWith('u2', 's2', 1, 2);
});
it('should ignore null mutation params', () => {
const commandService = createCommandServiceMock();
const dependencyManagerService = {
removeFeatureFormulaDependency: vi.fn(),
removeOtherFormulaDependency: vi.fn(),
clearFormulaDependency: vi.fn(),
removeFormulaDependency: vi.fn(),
removeFormulaDependencyByDefinedName: vi.fn(),
};
const featureCalculationManagerService = {
onChanged$: new Subject<{ unitId: string; subUnitId: string; featureIds: string[] }>(),
};
// eslint-disable-next-line no-new
new SetDependencyController(
commandService as never,
dependencyManagerService as never,
featureCalculationManagerService as never
);
commandService.emit(RemoveFeatureCalculationMutation.id, null);
commandService.emit(SetFeatureCalculationMutation.id, null);
commandService.emit(RemoveOtherFormulaMutation.id, null);
commandService.emit(SetOtherFormulaMutation.id, null);
commandService.emit(SetDefinedNameMutation.id, null);
expect(dependencyManagerService.removeFeatureFormulaDependency).not.toHaveBeenCalled();
expect(dependencyManagerService.removeOtherFormulaDependency).not.toHaveBeenCalled();
expect(dependencyManagerService.removeFormulaDependencyByDefinedName).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,111 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { ICommandInfo } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { RemoveOtherFormulaMutation, SetOtherFormulaMutation } from '../../commands/mutations/set-other-formula.mutation';
import { SetOtherFormulaController } from '../set-other-formula.controller';
interface ICommandServiceMock {
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => { dispose: () => void };
emit: (id: string, params?: unknown) => void;
}
function createCommandServiceMock(): ICommandServiceMock {
const callbacks = new Set<(commandInfo: ICommandInfo) => void>();
return {
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => {
callbacks.add(callback);
return {
dispose: () => callbacks.delete(callback),
};
},
emit: (id: string, params?: unknown) => {
callbacks.forEach((callback) => callback({ id, params } as ICommandInfo));
},
};
}
describe('SetOtherFormulaController', () => {
it('should register and remove other-formula configs when commands are executed', () => {
const commandService = createCommandServiceMock();
const otherFormulaManagerService = {
batchRegister: vi.fn(),
batchRemove: vi.fn(),
};
// eslint-disable-next-line no-new
new SetOtherFormulaController(
commandService as never,
otherFormulaManagerService as never,
{} as never
);
const formulaMap = {
f1: { f: '=A1' },
f2: { f: '=B2' },
};
commandService.emit(SetOtherFormulaMutation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
formulaMap,
});
expect(otherFormulaManagerService.batchRegister).toHaveBeenCalledWith({
'unit-1': {
'sheet-1': formulaMap,
},
});
commandService.emit(RemoveOtherFormulaMutation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
formulaIdList: ['f1', 'f2'],
});
expect(otherFormulaManagerService.batchRemove).toHaveBeenCalledWith({
'unit-1': {
'sheet-1': {
f1: true,
f2: true,
},
},
});
});
it('should ignore null params and unrelated commands', () => {
const commandService = createCommandServiceMock();
const otherFormulaManagerService = {
batchRegister: vi.fn(),
batchRemove: vi.fn(),
};
// eslint-disable-next-line no-new
new SetOtherFormulaController(
commandService as never,
otherFormulaManagerService as never,
{} as never
);
commandService.emit(SetOtherFormulaMutation.id, null);
commandService.emit(RemoveOtherFormulaMutation.id, null);
commandService.emit('unknown.command', {});
expect(otherFormulaManagerService.batchRegister).not.toHaveBeenCalled();
expect(otherFormulaManagerService.batchRemove).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,103 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { ICommandInfo } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { RemoveDefinedNameMutation, SetDefinedNameMutation } from '../../commands/mutations/set-defined-name.mutation';
import { SetSuperTableOptionMutation } from '../../commands/mutations/set-super-table.mutation';
import { SetSuperTableController } from '../set-super-table.controller';
interface ICommandServiceMock {
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => { dispose: () => void };
emit: (id: string, params?: unknown) => void;
}
function createCommandServiceMock(): ICommandServiceMock {
const callbacks = new Set<(commandInfo: ICommandInfo) => void>();
return {
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => {
callbacks.add(callback);
return {
dispose: () => callbacks.delete(callback),
};
},
emit: (id: string, params?: unknown) => {
callbacks.forEach((callback) => callback({ id, params } as ICommandInfo));
},
};
}
describe('SetSuperTableController', () => {
it('should dispatch table register/remove/option commands', () => {
const commandService = createCommandServiceMock();
const superTableService = {
registerTable: vi.fn(),
remove: vi.fn(),
registerTableOptionMap: vi.fn(),
};
// eslint-disable-next-line no-new
new SetSuperTableController(commandService as never, superTableService as never);
const reference = {
tableId: 'table-id',
range: { startRow: 0, endRow: 10, startColumn: 0, endColumn: 2 },
};
commandService.emit(SetDefinedNameMutation.id, {
unitId: 'unit-1',
tableName: 'Table1',
reference,
});
expect(superTableService.registerTable).toHaveBeenCalledWith('unit-1', 'Table1', reference);
commandService.emit(RemoveDefinedNameMutation.id, {
unitId: 'unit-1',
tableName: 'Table1',
});
expect(superTableService.remove).toHaveBeenCalledWith('unit-1', 'Table1');
const tableOption = {
Table1: { totalRow: true },
};
commandService.emit(SetSuperTableOptionMutation.id, {
tableOption,
tableOptionType: 'normal',
});
expect(superTableService.registerTableOptionMap).toHaveBeenCalledWith(tableOption, 'normal');
});
it('should ignore null params and unrelated commands', () => {
const commandService = createCommandServiceMock();
const superTableService = {
registerTable: vi.fn(),
remove: vi.fn(),
registerTableOptionMap: vi.fn(),
};
// eslint-disable-next-line no-new
new SetSuperTableController(commandService as never, superTableService as never);
commandService.emit(SetDefinedNameMutation.id, null);
commandService.emit(RemoveDefinedNameMutation.id, null);
commandService.emit(SetSuperTableOptionMutation.id, null);
commandService.emit('unknown.command');
expect(superTableService.registerTable).not.toHaveBeenCalled();
expect(superTableService.remove).not.toHaveBeenCalled();
expect(superTableService.registerTableOptionMap).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,230 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { IUnitExcludedCell } from '../../../basics/common';
import { ObjectMatrix } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import {
FormulaDependencyTree,
FormulaDependencyTreeModel,
FormulaDependencyTreeType,
FormulaDependencyTreeVirtual,
} from '../dependency-tree';
function createTree(treeId: number) {
const tree = new FormulaDependencyTree(treeId);
tree.unitId = 'unit-1';
tree.subUnitId = 'sheet-1';
tree.formula = '=A1+B1';
tree.row = 2;
tree.column = 3;
tree.rowCount = 20;
tree.columnCount = 10;
tree.rangeList = [
{
unitId: 'unit-1',
sheetId: 'sheet-1',
range: {
startRow: 1,
startColumn: 1,
endRow: 2,
endColumn: 2,
},
},
];
return tree;
}
describe('FormulaDependencyTree', () => {
it('should track tree state and parent child relations', () => {
const parent = createTree(1);
const child = createTree(2);
parent.setAdded();
expect(parent.isAdded()).toBe(true);
parent.resetState();
expect(parent.isAdded()).toBe(false);
parent.setSkip();
expect(parent.isSkip()).toBe(true);
parent.pushChildren(child);
expect(parent.hasChildren(2)).toBe(true);
expect(child.parents.has(1)).toBe(true);
});
it('should handle basic range and dependency checks', () => {
const tree = createTree(10);
expect(tree.inRangeData({ startRow: 2, startColumn: 3, endRow: 2, endColumn: 3 })).toBe(true);
expect(tree.inRangeData({ startRow: 5, startColumn: 5, endRow: 6, endColumn: 6 })).toBe(false);
expect(tree.dependencySheetName()).toBe(false);
expect(tree.dependencySheetName({ 'unit-1': { 'sheet-1': 'Main' } })).toBe(true);
tree.pushRangeList([
{
unitId: 'unit-1',
sheetId: 'sheet-1',
range: { startRow: 8, startColumn: 8, endRow: 8, endColumn: 8 },
},
]);
expect(tree.rangeList.length).toBe(2);
expect(tree.shouldBePushRangeList()).toBe(false);
});
it('should use feature ranges when tree is feature formula', () => {
const tree = createTree(12);
tree.featureId = 'feature-1';
tree.featureDirtyRanges = [
{
unitId: 'u',
sheetId: 's',
range: { startRow: 9, startColumn: 9, endRow: 10, endColumn: 10 },
},
];
tree.type = FormulaDependencyTreeType.FEATURE_FORMULA;
tree.rangeList = [];
expect(tree.shouldBePushRangeList()).toBe(false);
expect(tree.toRTreeItem()).toEqual(tree.featureDirtyRanges);
});
it('should detect excluded range with finite and NaN boundaries', () => {
const tree = createTree(13);
tree.rangeList = [
{
unitId: 'unit-1',
sheetId: 'sheet-1',
range: { startRow: Number.NaN, startColumn: Number.NaN, endRow: Number.NaN, endColumn: Number.NaN },
},
];
const excluded: IUnitExcludedCell = {
'unit-1': {
'sheet-1': new ObjectMatrix<boolean>({
0: { 0: true },
}),
},
};
expect(tree.isExcludeRange(excluded)).toBe(true);
});
it('should clear internals on dispose', () => {
const tree = createTree(14);
tree.featureDirtyRanges = [
{
unitId: 'u',
sheetId: 's',
range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 },
},
];
tree.getDirtyData = (() => ({
runtimeCellData: {},
dirtyRanges: {},
})) as never;
tree.dispose();
expect(tree.featureDirtyRanges).toEqual([]);
expect(tree.rangeList).toEqual([]);
expect(tree.getDirtyData).toBeNull();
});
});
describe('FormulaDependencyTreeVirtual', () => {
it('should expose defaults when no ref tree exists', () => {
const tree = new FormulaDependencyTreeVirtual();
expect(tree.row).toBe(-1);
expect(tree.column).toBe(-1);
expect(tree.rowCount).toBe(0);
expect(tree.columnCount).toBe(0);
expect(tree.unitId).toBe('');
expect(tree.subUnitId).toBe('');
expect(tree.formula).toBe('');
expect(tree.formulaId).toBe('');
expect(tree.rangeList).toEqual([]);
expect(tree.toRTreeItem()[0]?.range.startRow).toBe(-1);
});
it('should proxy properties and offset ranges from ref tree', () => {
const refTree = createTree(30);
refTree.formulaId = 'formula-id-1';
const virtualTree = new FormulaDependencyTreeVirtual();
virtualTree.treeId = 31;
virtualTree.refTree = refTree;
virtualTree.refOffsetX = 2;
virtualTree.refOffsetY = 3;
expect(virtualTree.row).toBe(5);
expect(virtualTree.column).toBe(5);
expect(virtualTree.rowCount).toBe(refTree.rowCount);
expect(virtualTree.columnCount).toBe(refTree.columnCount);
expect(virtualTree.unitId).toBe('unit-1');
expect(virtualTree.subUnitId).toBe('sheet-1');
expect(virtualTree.formula).toBe('=A1+B1');
expect(virtualTree.formulaId).toBe('formula-id-1');
expect(virtualTree.nodeData.refOffsetX).toBe(2);
expect(virtualTree.nodeData.refOffsetY).toBe(3);
expect(virtualTree.rangeList[0]?.range).toEqual({
startRow: 4,
startColumn: 3,
endRow: 5,
endColumn: 4,
});
expect(virtualTree.inRangeData({ startRow: 5, startColumn: 5, endRow: 6, endColumn: 6 })).toBe(true);
expect(virtualTree.dependencySheetName({ 'unit-1': { 'sheet-1': 'Main' } })).toBe(true);
virtualTree.dispose();
expect(virtualTree.refTree).toBeNull();
});
});
describe('FormulaDependencyTreeModel', () => {
it('should serialize normal and virtual trees', () => {
const tree = createTree(100);
tree.formulaId = 'fid';
tree.featureId = 'feature-1';
tree.type = FormulaDependencyTreeType.NORMAL_FORMULA;
const childTree = createTree(101);
const model = new FormulaDependencyTreeModel(tree);
const childModel = new FormulaDependencyTreeModel(childTree);
model.addChild(childModel);
childModel.addParent(model);
const json = model.toJson();
expect(json.treeId).toBe(100);
expect(json.children).toEqual([101]);
expect(json.parents).toEqual([]);
expect(json.formulaId).toBe('fid');
expect(json.featureId).toBe('feature-1');
const fullJson = childModel.toFullJson();
expect(fullJson.parents[0]?.treeId).toBe(100);
const virtual = new FormulaDependencyTreeVirtual();
virtual.treeId = 200;
virtual.refTree = tree;
const virtualModel = new FormulaDependencyTreeModel(virtual);
expect(virtualModel.refTreeId).toBe(100);
expect(virtualModel.formula).toBe('');
});
});
@@ -0,0 +1,205 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it, vi } from 'vitest';
import { NumberValueObject } from '../../value-object/primitive-object';
import { MultiAreaReferenceObject } from '../multi-area-reference-object';
function createAreaStub(config?: {
rowCount?: number;
columnCount?: number;
exceed?: boolean;
unitId?: string;
sheetId?: string;
range?: { startRow: number; startColumn: number; endRow: number; endColumn: number };
rangeData?: { startRow: number; startColumn: number; endRow: number; endColumn: number };
iteratorValue?: number;
}) {
const rowCount = config?.rowCount ?? 1;
const columnCount = config?.columnCount ?? 1;
const range = config?.range ?? { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 };
const rangeData = config?.rangeData ?? range;
const iteratorValue = config?.iteratorValue ?? 1;
return {
dispose: vi.fn(),
isError: () => false,
getRowCount: () => rowCount,
getColumnCount: () => columnCount,
isExceedRange: () => !!config?.exceed,
setRefOffset: vi.fn(),
getUnitId: () => config?.unitId ?? 'unit-1',
getSheetId: () => config?.sheetId ?? 'sheet-1',
getActiveSheetRowCount: () => 100,
getActiveSheetColumnCount: () => 26,
iterator: (callback: (v: any, row: number, col: number) => any) => callback(NumberValueObject.create(iteratorValue), 0, 0),
getFirstCell: () => NumberValueObject.create(iteratorValue),
getRangePosition: () => range,
getRangeData: () => rangeData,
};
}
function createErrorAreaStub() {
return {
dispose: vi.fn(),
isError: () => true,
};
}
describe('MultiAreaReferenceObject', () => {
it('should manage areas and multi-area flags', () => {
const areaA = createAreaStub({ rowCount: 2, columnCount: 3 });
const areaB = createAreaStub({ rowCount: 4, columnCount: 5 });
const multi = new MultiAreaReferenceObject('token', [[areaA as never]]);
multi.addArea(areaB as never);
multi.addArea([areaA as never, areaB as never]);
expect(multi.isMultiArea()).toBe(true);
expect(multi.isRange()).toBe(false);
expect(multi.isCell()).toBe(false);
expect(multi.isRow()).toBe(false);
expect(multi.isColumn()).toBe(false);
expect(multi.getAreas().length).toBe(3);
expect(multi.getRowCount()).toBe(2 + 4 + 2 + 4);
expect(multi.getColumnCount()).toBe(3 + 5 + 3 + 5);
});
it('should ignore error areas in count, range and sheet inference', () => {
const area = createAreaStub({
unitId: 'unit-A',
sheetId: 'sheet-A',
range: { startRow: 2, startColumn: 3, endRow: 4, endColumn: 5 },
});
const errorArea = createErrorAreaStub();
const multi = new MultiAreaReferenceObject('token', [[errorArea as never, area as never]]);
expect(multi.getUnitId()).toBe('unit-A');
expect(multi.getSheetId()).toBe('sheet-A');
expect(multi.getActiveSheetRowCount()).toBe(100);
expect(multi.getActiveSheetColumnCount()).toBe(26);
expect(multi.isExceedRange()).toBe(false);
expect(multi.getRangePosition()).toEqual({ startRow: 2, startColumn: 3, endRow: 4, endColumn: 5 });
});
it('should propagate offset and iterate in row-major order with stop signal', () => {
const area1 = createAreaStub({ iteratorValue: 1 });
const area2 = createAreaStub({ iteratorValue: 2 });
const multi = new MultiAreaReferenceObject('token', [[area1 as never, area2 as never]]);
multi.setRefOffset(2, 3);
expect(area1.setRefOffset).toHaveBeenCalledWith(2, 3);
expect(area2.setRefOffset).toHaveBeenCalledWith(2, 3);
const values: number[] = [];
multi.iterator((v) => {
values.push(v?.getValue() as number);
return values.length < 1;
});
expect(values).toEqual([1]);
});
it('should convert multi-area to array object and unit range', () => {
const area1 = createAreaStub({
range: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 },
rangeData: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 },
unitId: 'u1',
sheetId: 's1',
iteratorValue: 9,
});
const area2 = createAreaStub({
range: { startRow: 5, startColumn: 6, endRow: 7, endColumn: 8 },
rangeData: { startRow: 5, startColumn: 6, endRow: 7, endColumn: 8 },
unitId: 'u1',
sheetId: 's1',
iteratorValue: 5,
});
const multi = new MultiAreaReferenceObject('token', [[area1 as never, area2 as never]]);
const array = multi.toArrayValueObject();
expect(array.getRowCount()).toBe(1);
expect(array.getColumnCount()).toBe(2);
// Current implementation always normalizes to NullValueObject after first-cell extraction.
expect(array.get(0, 0)?.isNull()).toBe(true);
expect(array.get(0, 1)?.isNull()).toBe(true);
expect(multi.getRangePosition()).toEqual({
startRow: 0,
startColumn: 0,
endRow: 7,
endColumn: 8,
});
expect(multi.getRangeData()).toEqual({
startRow: 0,
startColumn: 0,
endRow: 7,
endColumn: 8,
});
expect(multi.toUnitRange()).toEqual({
unitId: 'u1',
sheetId: 's1',
range: {
startRow: 0,
startColumn: 0,
endRow: 7,
endColumn: 8,
},
});
expect(multi.getFirstCell().getValue()).toBe(9);
});
it('should fallback to parent behavior when all areas are invalid', () => {
const invalidArea = createAreaStub({
range: {
startRow: Number.POSITIVE_INFINITY,
startColumn: Number.POSITIVE_INFINITY,
endRow: Number.NEGATIVE_INFINITY,
endColumn: Number.NEGATIVE_INFINITY,
},
rangeData: {
startRow: Number.POSITIVE_INFINITY,
startColumn: Number.POSITIVE_INFINITY,
endRow: Number.NEGATIVE_INFINITY,
endColumn: Number.NEGATIVE_INFINITY,
},
});
const multi = new MultiAreaReferenceObject('token', [[invalidArea as never]]);
expect(multi.getRangePosition()).toEqual({
startRow: -1,
startColumn: -1,
endRow: -1,
endColumn: -1,
});
expect(multi.getRangeData()).toEqual({
startRow: -1,
startColumn: -1,
endRow: -1,
endColumn: -1,
});
});
it('should dispose all areas', () => {
const area1 = createAreaStub();
const area2 = createAreaStub();
const multi = new MultiAreaReferenceObject('token', [[area1 as never, area2 as never]]);
multi.dispose();
expect(area1.dispose).toHaveBeenCalledTimes(1);
expect(area2.dispose).toHaveBeenCalledTimes(1);
expect(multi.getAreas()).toEqual([]);
});
});
@@ -0,0 +1,67 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { handleRegExp } from '../regexp-check';
describe('handleRegExp', () => {
it('should reject lookbehind patterns', () => {
const result = handleRegExp('(?<=a)b', false);
expect(result.isError).toBe(true);
expect(result.regExp).toBeNull();
});
it('should reject malformed patterns', () => {
const result = handleRegExp('abc\\', false);
expect(result.isError).toBe(true);
expect(result.regExp).toBeNull();
});
it('should reject unsafe regex with nested catastrophic repetition', () => {
const result = handleRegExp('(a+)+$', false);
expect(result.isError).toBe(true);
expect(result.regExp).toBeNull();
});
it('should build global unicode regexp when requested', () => {
const result = handleRegExp('a+', true);
expect(result.isError).toBe(false);
expect(result.regExp).not.toBeNull();
expect(result.regExp?.flags).toContain('g');
expect(result.regExp?.flags).toContain('u');
expect('baaac'.match(result.regExp!)?.[0]).toBe('aaa');
});
it('should support complex escaped patterns and back references', () => {
const result = handleRegExp('(\\w+)\\s+\\1', false);
expect(result.isError).toBe(false);
expect(result.regExp?.test('hello hello')).toBe(true);
expect(result.regExp?.test('hello world')).toBe(false);
});
it('should accept patterns that include lookbehind-like literals in a char set', () => {
const result = handleRegExp('[(?<=a)]', false);
expect(result.isError).toBe(false);
expect(result.regExp).not.toBeNull();
expect(result.regExp?.test('(')).toBe(true);
});
});
@@ -0,0 +1,65 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it, vi } from 'vitest';
import { CubeValueObject } from '../cube-value-object';
import { NumberValueObject } from '../primitive-object';
function createArrayStub(values: {
sum: number;
max: number;
count: number;
countA: number;
countBlank: number;
}) {
return {
dispose: vi.fn(),
sum: () => NumberValueObject.create(values.sum),
max: () => NumberValueObject.create(values.max),
count: () => NumberValueObject.create(values.count),
countA: () => NumberValueObject.create(values.countA),
countBlank: () => NumberValueObject.create(values.countBlank),
};
}
describe('CubeValueObject', () => {
it('should aggregate sum/max/min/count metrics', () => {
const a1 = createArrayStub({ sum: 5, max: 8, count: 2, countA: 3, countBlank: 1 });
const a2 = createArrayStub({ sum: 7, max: 4, count: 5, countA: 6, countBlank: 2 });
const cube = CubeValueObject.create([a1 as never, a2 as never]);
expect(cube.isCube()).toBe(true);
expect(cube.getCubeCount()).toBe(2);
expect(cube.getCubeValues().length).toBe(2);
expect(cube.sum().getValue()).toBe(0);
expect(cube.max().getValue()).toBe(4);
expect(cube.min().getValue()).toBe(4);
expect(cube.count().getValue()).toBe(0);
expect(cube.countA().getValue()).toBe(0);
expect(cube.countBlank().getValue()).toBe(0);
});
it('should dispose all array members', () => {
const a1 = createArrayStub({ sum: 1, max: 1, count: 1, countA: 1, countBlank: 1 });
const a2 = createArrayStub({ sum: 2, max: 2, count: 2, countA: 2, countBlank: 2 });
const cube = CubeValueObject.create([a1 as never, a2 as never]);
cube.dispose();
expect(a1.dispose).toHaveBeenCalledTimes(1);
expect(a2.dispose).toHaveBeenCalledTimes(1);
expect(cube.getCubeValues()).toEqual([]);
});
});
@@ -0,0 +1,335 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { ICommandInfo } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
SetCellFormulaDependencyCalculationMutation,
SetCellFormulaDependencyCalculationResultMutation,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationStartMutation,
SetFormulaCalculationStopMutation,
SetFormulaDependencyCalculationMutation,
SetFormulaDependencyCalculationResultMutation,
SetFormulaStringBatchCalculationMutation,
SetFormulaStringBatchCalculationResultMutation,
SetQueryFormulaDependencyAllMutation,
SetQueryFormulaDependencyAllResultMutation,
SetQueryFormulaDependencyMutation,
SetQueryFormulaDependencyResultMutation,
SetTriggerFormulaCalculationStartMutation,
} from '../../commands/mutations/set-formula-calculation.mutation';
import { ENGINE_FORMULA_CYCLE_REFERENCE_COUNT, ENGINE_FORMULA_RETURN_DEPENDENCY_TREE } from '../../controllers/config.schema';
import { FFormula } from '../f-formula';
interface ICommandServiceMock {
executeCommand: ReturnType<typeof vi.fn>;
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => { dispose: () => void };
emit: (id: string, params?: unknown) => void;
}
function createCommandServiceMock(): ICommandServiceMock {
const callbacks = new Set<(commandInfo: ICommandInfo) => void>();
return {
executeCommand: vi.fn(async () => true),
onCommandExecuted: (callback: (commandInfo: ICommandInfo) => void) => {
callbacks.add(callback);
return {
dispose: () => callbacks.delete(callback),
};
},
emit: (id: string, params?: unknown) => {
callbacks.forEach((callback) => callback({ id, params } as ICommandInfo));
},
};
}
function createFFormula(computingStatusService?: { computingStatus: boolean; computingStatus$: BehaviorSubject<boolean> }) {
const commandService = createCommandServiceMock();
const configService = {
setConfig: vi.fn(),
};
const lexerTreeBuilder = {
moveFormulaRefOffset: vi.fn(() => '=B2'),
sequenceNodesBuilder: vi.fn(() => ['SUM', 'A1']),
getFormulaExprTree: vi.fn(() => ({ value: 'SUM(A1)', children: [], startIndex: 0 })),
};
const functionService = {
hasExecutor: vi.fn(() => true),
};
const definedNamesService = {
getValueByName: vi.fn(() => undefined),
};
const superTableService = {
getTable: vi.fn(() => undefined),
};
const injector = {
get: vi.fn(() => computingStatusService),
};
const formula = new FFormula(
commandService as never,
injector as never,
lexerTreeBuilder as never,
configService as never,
functionService as never,
definedNamesService as never,
superTableService as never
);
return {
formula,
commandService,
configService,
lexerTreeBuilder,
functionService,
definedNamesService,
superTableService,
};
}
describe('FFormula', () => {
afterEach(() => {
vi.useRealTimers();
});
it('should delegate lexer tree methods', () => {
const { formula, lexerTreeBuilder } = createFFormula();
expect(formula.moveFormulaRefOffset('=A1', 1, 1)).toBe('=B2');
expect(lexerTreeBuilder.moveFormulaRefOffset).toHaveBeenCalledWith('=A1', 1, 1, undefined);
expect(formula.sequenceNodesBuilder('=SUM(A1)')).toEqual(['SUM', 'A1']);
lexerTreeBuilder.sequenceNodesBuilder.mockReturnValueOnce(undefined as never);
expect(formula.sequenceNodesBuilder('=X')).toEqual([]);
});
it('should execute start/stop commands and listen to command events', () => {
const { formula, commandService } = createFFormula();
const onStart = vi.fn();
const onEnd = vi.fn();
const onProgress = vi.fn();
formula.executeCalculation();
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetTriggerFormulaCalculationStartMutation.id,
{ commands: [], forceCalculation: true },
{ onlyLocal: true }
);
formula.stopCalculation();
expect(commandService.executeCommand).toHaveBeenCalledWith(SetFormulaCalculationStopMutation.id, {});
formula.calculationStart(onStart);
formula.calculationEnd(onEnd);
formula.calculationProcessing(onProgress);
commandService.emit(SetFormulaCalculationStartMutation.id, { forceCalculation: true });
commandService.emit(SetFormulaCalculationNotificationMutation.id, { functionsExecutedState: 1 });
commandService.emit(SetFormulaCalculationNotificationMutation.id, {
stageInfo: {
totalFormulasToCalculate: 1,
completedFormulasCount: 1,
totalArrayFormulasToCalculate: 0,
completedArrayFormulasCount: 0,
formulaCycleIndex: 0,
stage: 4,
},
});
expect(onStart).toHaveBeenCalledWith(true);
expect(onEnd).toHaveBeenCalledWith(1);
expect(onProgress).toHaveBeenCalledTimes(1);
});
it('should wait computing complete by status stream or timeout', async () => {
const status$ = new BehaviorSubject(false);
const computingStatusService = {
computingStatus: false,
computingStatus$: status$,
};
const { formula } = createFFormula(computingStatusService);
const pending = formula.whenComputingCompleteAsync(200);
status$.next(true);
await expect(pending).resolves.toBe(true);
const timeoutService = {
computingStatus: false,
computingStatus$: new BehaviorSubject(false),
};
const { formula: timeoutFormula } = createFFormula(timeoutService);
const timeoutPromise = timeoutFormula.whenComputingCompleteAsync(1);
await expect(timeoutPromise).resolves.toBe(false);
const readyService = {
computingStatus: true,
computingStatus$: new BehaviorSubject(true),
};
const { formula: readyFormula } = createFFormula(readyService);
await expect(readyFormula.whenComputingCompleteAsync()).resolves.toBe(true);
});
it('should resolve and timeout on onCalculationEnd()', async () => {
const { formula, commandService } = createFFormula();
const pending = formula.onCalculationEnd();
commandService.emit(SetFormulaCalculationNotificationMutation.id, { functionsExecutedState: 1 });
await expect(pending).resolves.toBeUndefined();
vi.useFakeTimers();
const timeoutPromise = expect(formula.onCalculationEnd()).rejects.toThrow('Calculation end timeout');
await vi.advanceTimersByTimeAsync(30_001);
await timeoutPromise;
});
it('should set formula configs', () => {
const { formula, configService } = createFFormula();
formula.setMaxIteration(12);
formula.setFormulaReturnDependencyTree(true);
expect(configService.setConfig).toHaveBeenCalledWith(ENGINE_FORMULA_CYCLE_REFERENCE_COUNT, 12);
expect(configService.setConfig).toHaveBeenCalledWith(ENGINE_FORMULA_RETURN_DEPENDENCY_TREE, true);
});
it('should execute formulas and resolve results', async () => {
const { formula, commandService } = createFFormula();
const pending = formula.executeFormulas({ unit: { sheet: {} } }, 100);
commandService.emit(SetFormulaStringBatchCalculationResultMutation.id, {
result: { unit: { sheet: { 0: { 0: [{ value: 1, formula: '=1' }] } } } },
});
await expect(pending).resolves.toEqual({ unit: { sheet: { 0: { 0: [{ value: 1, formula: '=1' }] } } } });
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetFormulaStringBatchCalculationMutation.id,
{ formulas: { unit: { sheet: {} } } },
{ onlyLocal: true }
);
});
it('should reject executeFormulas for empty result or timeout', async () => {
const { formula, commandService } = createFFormula();
const emptyResult = formula.executeFormulas({ unit: { sheet: {} } }, 100);
commandService.emit(SetFormulaStringBatchCalculationResultMutation.id, { result: null });
await expect(emptyResult).rejects.toThrow('Formula batch calculation returned no result');
vi.useFakeTimers();
const timeoutResult = expect(formula.executeFormulas({ unit: { sheet: {} } }, 1)).rejects.toThrow('Formula batch calculation timeout');
await vi.advanceTimersByTimeAsync(2);
await timeoutResult;
});
it('should query all dependency trees', async () => {
const { formula, commandService } = createFFormula();
const pending = formula.getAllDependencyTrees(100);
commandService.emit(SetFormulaDependencyCalculationResultMutation.id, { result: [{ treeId: 1 }] });
await expect(pending).resolves.toEqual([{ treeId: 1 }]);
const emptyPending = formula.getAllDependencyTrees(100);
commandService.emit(SetFormulaDependencyCalculationResultMutation.id, { result: null });
await expect(emptyPending).resolves.toEqual([]);
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetFormulaDependencyCalculationMutation.id,
undefined,
{ onlyLocal: true }
);
});
it('should query cell dependency tree', async () => {
const { formula, commandService } = createFFormula();
const pending = formula.getCellDependencyTree(
{ unitId: 'u', sheetId: 's', row: 1, column: 2 },
100
);
commandService.emit(SetCellFormulaDependencyCalculationResultMutation.id, { result: { treeId: 11 } });
await expect(pending).resolves.toEqual({ treeId: 11 });
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetCellFormulaDependencyCalculationMutation.id,
{ unitId: 'u', sheetId: 's', row: 1, column: 2 },
{ onlyLocal: true }
);
});
it('should query range dependents and in-range formulas', async () => {
const { formula, commandService } = createFFormula();
const ranges = [{ unitId: 'u', sheetId: 's', range: { startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 } }];
const dependents = formula.getRangeDependents(ranges, 100);
commandService.emit(SetQueryFormulaDependencyResultMutation.id, { result: [{ treeId: 2 }] });
await expect(dependents).resolves.toEqual([{ treeId: 2 }]);
const inRanges = formula.getInRangeFormulas(ranges, 100);
commandService.emit(SetQueryFormulaDependencyResultMutation.id, { result: null });
await expect(inRanges).resolves.toEqual([]);
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetQueryFormulaDependencyMutation.id,
{ unitRanges: ranges },
{ onlyLocal: true }
);
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetQueryFormulaDependencyMutation.id,
{ unitRanges: ranges, isInRange: true },
{ onlyLocal: true }
);
});
it('should query dependents and in-range formulas in one call', async () => {
const { formula, commandService } = createFFormula();
const ranges = [{ unitId: 'u', sheetId: 's', range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 } }];
const pending = formula.getRangeDependentsAndInRangeFormulas(ranges, 100);
commandService.emit(SetQueryFormulaDependencyAllResultMutation.id, {
result: {
dependents: [{ treeId: 3 }],
inRanges: [{ treeId: 4 }],
},
});
await expect(pending).resolves.toEqual({
dependents: [{ treeId: 3 }],
inRanges: [{ treeId: 4 }],
});
const emptyPending = formula.getRangeDependentsAndInRangeFormulas(ranges, 100);
commandService.emit(SetQueryFormulaDependencyAllResultMutation.id, { result: null });
await expect(emptyPending).resolves.toEqual({ dependents: [], inRanges: [] });
expect(commandService.executeCommand).toHaveBeenCalledWith(
SetQueryFormulaDependencyAllMutation.id,
{ unitRanges: ranges },
{ onlyLocal: true }
);
});
it('should delegate getFormulaExpressTree with bound providers', () => {
const { formula, lexerTreeBuilder, functionService, definedNamesService, superTableService } = createFFormula();
const tree = formula.getFormulaExpressTree('=SUM(A1)', 'unit-1');
expect(tree).toEqual({ value: 'SUM(A1)', children: [], startIndex: 0 });
expect(lexerTreeBuilder.getFormulaExprTree).toHaveBeenCalledWith(
'=SUM(A1)',
'unit-1',
expect.any(Function),
expect.any(Function),
expect.any(Function)
);
});
});
@@ -0,0 +1,125 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { ErrorType } from '../../../../basics/error-type';
import { ArrayValueObject, transformToValueObject } from '../../../../engine/value-object/array-value-object';
import { StringValueObject } from '../../../../engine/value-object/primitive-object';
import { FUNCTION_NAMES_LOOKUP } from '../../function-names';
import { ImageFunction } from '../index';
describe('ImageFunction', () => {
const fn = new ImageFunction(FUNCTION_NAMES_LOOKUP.IMAGE);
it('should return #VALUE! when source is not string', () => {
const source = ArrayValueObject.createByArray([[1]]);
const result = fn.calculate(source);
expect(result.isError()).toBe(true);
expect(result.getValue()).toBe(ErrorType.VALUE);
});
it('should build image metadata for valid scalar params', () => {
const result = fn.calculate(
StringValueObject.create('https://image'),
StringValueObject.create('alt'),
StringValueObject.create('3'),
StringValueObject.create('100'),
StringValueObject.create('200')
) as StringValueObject;
expect(result.isString()).toBe(true);
expect(result.isImage()).toBe(true);
expect(result.getImageInfo()).toEqual({
source: 'https://image',
altText: 'alt',
sizing: 3,
height: 100,
width: 200,
});
});
it('should validate sizing and size constraints', () => {
const source = StringValueObject.create('https://image');
const invalidSizing = fn.calculate(source, undefined, StringValueObject.create('9'));
expect(invalidSizing.getValue()).toBe(ErrorType.VALUE);
const invalidBySizingRule = fn.calculate(
source,
undefined,
StringValueObject.create('1'),
StringValueObject.create('10')
);
expect(invalidBySizingRule.getValue()).toBe(ErrorType.VALUE);
const invalidHeightWidth = fn.calculate(
source,
undefined,
StringValueObject.create('3'),
StringValueObject.create('0'),
StringValueObject.create('0')
);
expect(invalidHeightWidth.getValue()).toBe(ErrorType.VALUE);
});
it('should propagate argument errors in array mode and calculate each cell', () => {
const source = ArrayValueObject.create({
calculateValueList: transformToValueObject([
['https://a', 'https://b'],
['https://c', 'https://d'],
]),
rowCount: 2,
columnCount: 2,
unitId: '',
sheetId: '',
row: 0,
column: 0,
});
const alt = ArrayValueObject.create({
calculateValueList: transformToValueObject([
['ok', ErrorType.NAME],
[true, false],
]),
rowCount: 2,
columnCount: 2,
unitId: '',
sheetId: '',
row: 0,
column: 0,
});
const sizing = ArrayValueObject.createByArray([[3]]);
const height = ArrayValueObject.createByArray([[20]]);
const width = ArrayValueObject.createByArray([[30]]);
const result = fn.calculate(source, alt, sizing, height, width) as ArrayValueObject;
expect(result.getRowCount()).toBe(2);
expect(result.getColumnCount()).toBe(2);
const first = result.get(0, 0) as StringValueObject;
expect(first.isImage()).toBe(true);
expect(first.getImageInfo()).toEqual({
source: 'https://a',
altText: 'ok',
sizing: 3,
height: 20,
width: 30,
});
const second = result.get(0, 1);
expect(second?.isError()).toBe(true);
expect(second?.getValue()).toBe(ErrorType.NAME);
});
});
+2 -2
View File
@@ -87,8 +87,8 @@ export { type ISetImageFormulaDataMutationParams, SetImageFormulaDataMutation }
export { type IRemoveOtherFormulaMutationParams, type ISetOtherFormulaMutationParams, RemoveOtherFormulaMutation, SetOtherFormulaMutation } from './commands/mutations/set-other-formula.mutation';
export { RemoveSuperTableMutation, SetSuperTableMutation, SetSuperTableOptionMutation } from './commands/mutations/set-super-table.mutation';
export type { ISetSuperTableMutationParam, ISetSuperTableMutationSearchParam } from './commands/mutations/set-super-table.mutation';
export { CalculateController } from './controller/calculate.controller';
export { ENGINE_FORMULA_CYCLE_REFERENCE_COUNT, ENGINE_FORMULA_PLUGIN_CONFIG_KEY, ENGINE_FORMULA_RETURN_DEPENDENCY_TREE, type IUniverEngineFormulaConfig } from './controller/config.schema';
export { CalculateController } from './controllers/calculate.controller';
export { ENGINE_FORMULA_CYCLE_REFERENCE_COUNT, ENGINE_FORMULA_PLUGIN_CONFIG_KEY, ENGINE_FORMULA_RETURN_DEPENDENCY_TREE, type IUniverEngineFormulaConfig } from './controllers/config.schema';
export { Lexer } from './engine/analysis/lexer';
export { LexerNode } from './engine/analysis/lexer-node';
export { LexerTreeBuilder } from './engine/analysis/lexer-tree-builder';
@@ -15,7 +15,7 @@
*/
import type { ICellData, Injector, IWorkbookData, Nullable, Univer } from '@univerjs/core';
import { IUniverInstanceService, LocaleType, ObjectMatrix } from '@univerjs/core';
import { LocaleType, ObjectMatrix, RANGE_TYPE } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FormulaDataModel, initSheetFormulaData } from '../formula-data.model';
import { createCommandTestBed } from './create-command-test-bed';
@@ -58,17 +58,40 @@ const TEST_WORKBOOK_DATA_DEMO: IWorkbookData = {
styles: {},
};
const TEST_WORKBOOK_DATA_EXTRA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
cellData: {
0: {
0: { f: '=A1' },
},
1: {
0: { f: '=A2' },
},
2: {
0: { f: '=A3', v: 1 },
},
3: {
0: { f: '=A4' },
},
},
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
describe('Test formula data model', () => {
describe('formulaDataModel function', () => {
let univer: Univer;
let get: Injector['get'];
let formulaDataModel: FormulaDataModel;
let getValues: (
startRow: number,
startColumn: number,
endRow: number,
endColumn: number
) => Array<Array<Nullable<ICellData>>> | undefined;
beforeEach(() => {
const testBed = createCommandTestBed(TEST_WORKBOOK_DATA_DEMO);
@@ -76,18 +99,6 @@ describe('Test formula data model', () => {
get = testBed.get;
formulaDataModel = get(FormulaDataModel);
getValues = (
startRow: number,
startColumn: number,
endRow: number,
endColumn: number
): Array<Array<Nullable<ICellData>>> | undefined =>
get(IUniverInstanceService)
.getUniverSheetInstance('test')
?.getSheetBySheetId('sheet1')
?.getRange(startRow, startColumn, endRow, endColumn)
.getValues();
});
afterEach(() => {
@@ -331,6 +342,197 @@ describe('Test formula data model', () => {
expect(formulaString).toBe(result[i][0]);
}
});
it('should return null when formula string source cell cannot be found', () => {
expect(formulaDataModel.getFormulaStringByCell(0, 0, 'missing-sheet', 'test')).toBeNull();
expect(formulaDataModel.getFormulaStringByCell(0, 0, 'sheet1', 'missing-unit')).toBeNull();
});
});
describe('extra formula data branches', () => {
beforeEach(() => {
univer.dispose();
const testBed = createCommandTestBed(TEST_WORKBOOK_DATA_EXTRA);
univer = testBed.univer;
get = testBed.get;
formulaDataModel = get(FormulaDataModel);
});
it('should clear previous array formula cell data and remove range', () => {
formulaDataModel.setArrayFormulaRange({
test: {
sheet1: {
0: {
0: {
startRow: 0,
startColumn: 0,
endRow: 1,
endColumn: 1,
},
},
},
},
});
formulaDataModel.setArrayFormulaCellData({
test: {
sheet1: {
0: {
0: { v: 1 },
},
1: {
1: { v: 2 },
},
},
},
});
const clearMatrix = new ObjectMatrix<Nullable<ICellData>>({
0: {
0: { v: null },
},
});
formulaDataModel.clearPreviousArrayFormulaCellData({
test: {
sheet1: clearMatrix,
},
});
expect(formulaDataModel.getArrayFormulaCellData().test?.sheet1).toEqual({
0: {
0: null,
1: null,
},
1: {
0: null,
1: null,
},
});
});
it('should merge array formula range and cell data', () => {
formulaDataModel.mergeArrayFormulaRange({
test: {
sheet1: {
2: {
2: {
startRow: 2,
startColumn: 2,
endRow: 3,
endColumn: 3,
},
},
},
},
});
const runtimeCellData = new ObjectMatrix<Nullable<ICellData>>({
2: {
2: { v: 10 },
},
3: {
3: { v: 11 },
},
});
formulaDataModel.mergeArrayFormulaCellData({
test: {
sheet1: runtimeCellData,
},
});
expect(formulaDataModel.getArrayFormulaRange().test?.sheet1?.[2]?.[2]).toEqual({
startRow: 2,
startColumn: 2,
endRow: 3,
endColumn: 3,
});
expect(formulaDataModel.getArrayFormulaCellData().test?.sheet1?.[3]?.[3]?.v).toBe(11);
});
it('should merge and update image formula data', () => {
const imageMatrix = new ObjectMatrix({
1: {
1: {
source: 'https://img',
altText: 'demo',
sizing: 1,
height: 10,
width: 20,
},
},
});
formulaDataModel.mergeUnitImageFormulaData({
test: {
sheet1: imageMatrix,
},
});
expect(formulaDataModel.getUnitImageFormulaData().test?.sheet1.getValue(1, 1)?.source).toBe('https://img');
formulaDataModel.updateImageFormulaData('test', 'sheet1', {
1: {
1: { v: null },
},
});
expect(formulaDataModel.getUnitImageFormulaData().test?.sheet1.getValue(1, 1)).toBeUndefined();
});
it('should delete array formula range by anchor cell', () => {
formulaDataModel.setArrayFormulaRange({
test: {
sheet1: {
5: {
6: {
startRow: 5,
startColumn: 6,
endRow: 6,
endColumn: 7,
},
},
},
},
});
formulaDataModel.deleteArrayFormulaRange('test', 'sheet1', 5, 6);
expect(formulaDataModel.getArrayFormulaRange().test?.sheet1).toEqual({});
});
it('should calculate dirty ranges for empty formula cells by continuous rows', () => {
const dirtyRanges = formulaDataModel.getFormulaDirtyRanges();
expect(dirtyRanges).toEqual([
{
unitId: 'test',
sheetId: 'sheet1',
range: {
rangeType: RANGE_TYPE.NORMAL,
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 0,
},
},
{
unitId: 'test',
sheetId: 'sheet1',
range: {
rangeType: RANGE_TYPE.NORMAL,
startRow: 3,
endRow: 3,
startColumn: 0,
endColumn: 0,
},
},
]);
});
it('should expose calculate data and per-sheet formula data', () => {
const calculateData = formulaDataModel.getCalculateData();
expect(calculateData.allUnitData.test?.sheet1.rowCount).toBeGreaterThan(0);
expect(calculateData.unitSheetNameMap.test?.Sheet1).toBe('sheet1');
const sheetFormulaData = formulaDataModel.getSheetFormulaData('test', 'sheet1');
expect(sheetFormulaData?.[0]?.[0]?.f).toBe('=A1');
});
});
});
+9 -9
View File
@@ -15,17 +15,17 @@
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverEngineFormulaConfig } from './controller/config.schema';
import type { IUniverEngineFormulaConfig } from './controllers/config.schema';
import { IConfigService, Inject, Injector, merge, Plugin, touchDependencies } from '@univerjs/core';
import pkg from '../package.json';
import { CalculateController } from './controller/calculate.controller';
import { ComputingStatusReporterController } from './controller/computing-status.controller';
import { defaultPluginConfig, ENGINE_FORMULA_PLUGIN_CONFIG_KEY } from './controller/config.schema';
import { FormulaController } from './controller/formula.controller';
import { SetDependencyController } from './controller/set-dependency.controller';
import { SetFeatureCalculationController } from './controller/set-feature-calculation.controller';
import { SetOtherFormulaController } from './controller/set-other-formula.controller';
// import { SetSuperTableController } from './controller/set-super-table.controller';
import { CalculateController } from './controllers/calculate.controller';
import { ComputingStatusReporterController } from './controllers/computing-status.controller';
import { defaultPluginConfig, ENGINE_FORMULA_PLUGIN_CONFIG_KEY } from './controllers/config.schema';
import { FormulaController } from './controllers/formula.controller';
import { SetDependencyController } from './controllers/set-dependency.controller';
import { SetFeatureCalculationController } from './controllers/set-feature-calculation.controller';
import { SetOtherFormulaController } from './controllers/set-other-formula.controller';
// import { SetSuperTableController } from './controllers/set-super-table.controller';
import { Lexer } from './engine/analysis/lexer';
import { LexerTreeBuilder } from './engine/analysis/lexer-tree-builder';
import { AstTreeBuilder } from './engine/analysis/parser';
@@ -0,0 +1,518 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { beforeEach, describe, expect, it, vi } from 'vitest';
import { ErrorType } from '../../basics/error-type';
import { ENGINE_FORMULA_PLUGIN_CONFIG_KEY } from '../../controllers/config.schema';
import { FORMULA_REF_TO_ARRAY_CACHE } from '../../engine/reference-object/base-reference-object';
import { CalculateFormulaService } from '../calculate-formula.service';
import { FormulaExecuteStageType } from '../runtime.service';
function createService() {
const configService = {
getConfig: vi.fn(() => ({ intervalCount: 9999 })),
};
const lexer = {
treeBuilder: vi.fn(),
};
const currentConfigService = {
load: vi.fn(),
loadDataLite: vi.fn(),
loadDirtyRangesAndExcludedCell: vi.fn(),
getRuntimeState: vi.fn(),
getUnitData: vi.fn(() => ({
unit: {
sheet: {
rowCount: 20,
columnCount: 10,
},
},
})),
getDirtyData: vi.fn(() => ({})),
};
const runtimeService = {
setFormulaExecuteStage: vi.fn(),
getRuntimeState: vi.fn(() => ({ stage: FormulaExecuteStageType.IDLE })),
reset: vi.fn(),
setFormulaCycleIndex: vi.fn(),
isCycleDependency: vi.fn(() => false),
setRuntimeFeatureCellData: vi.fn(),
setRuntimeFeatureRange: vi.fn(),
stopExecution: vi.fn(),
getAllRuntimeData: vi.fn(() => ({
unitData: {},
unitOtherData: {},
arrayFormulaRange: {},
arrayFormulaEmbedded: {},
functionsExecutedState: 0,
arrayFormulaCellData: {},
clearArrayFormulaCellData: {},
imageFormulaData: [],
runtimeFeatureRange: {},
runtimeFeatureCellData: {},
dependencyTreeModelData: [],
})),
setTotalArrayFormulasToCalculate: vi.fn(),
setTotalFormulasToCalculate: vi.fn(),
setCompletedArrayFormulasCount: vi.fn(),
setCompletedFormulasCount: vi.fn(),
isStopExecution: vi.fn(() => false),
setCurrent: vi.fn(),
setRuntimeData: vi.fn(),
setRuntimeOtherData: vi.fn(),
markedAsSuccessfullyExecuted: vi.fn(),
markedAsNoFunctionsExecuted: vi.fn(),
markedAsStopFunctionsExecuted: vi.fn(),
};
const formulaDependencyGenerator = {
generate: vi.fn(async () => []),
getAllDependencyJson: vi.fn(async () => [{ treeId: 1 }]),
getCellDependencyJson: vi.fn(async () => ({ treeId: 2 })),
getRangeDependents: vi.fn(async () => [{ treeId: 3 }]),
getInRangeFormulas: vi.fn(async () => [{ treeId: 4 }]),
getRangeDependentsAndInRangeFormulas: vi.fn(async () => ({ dependents: [], inRanges: [] })),
};
const interpreter = {
checkAsyncNode: vi.fn(() => false),
executeAsync: vi.fn(async () => ({ async: true })),
execute: vi.fn(() => ({ value: true })),
};
const astTreeBuilder = {
parse: vi.fn(),
};
const service = new CalculateFormulaService(
configService as never,
lexer as never,
currentConfigService as never,
runtimeService as never,
formulaDependencyGenerator as never,
interpreter as never,
astTreeBuilder as never
);
// Make execution deterministic in tests.
(service as any)._executeLock = {
acquire: vi.fn(async (_key: string, callback: () => Promise<void>) => {
await callback();
}),
};
return {
service,
mocks: {
configService,
lexer,
currentConfigService,
runtimeService,
formulaDependencyGenerator,
interpreter,
astTreeBuilder,
},
};
}
describe('CalculateFormulaService', () => {
beforeEach(() => {
FORMULA_REF_TO_ARRAY_CACHE.clear();
});
it('should forward stop and feature runtime setters', () => {
const { service, mocks } = createService();
service.stopFormulaExecution();
expect(mocks.runtimeService.stopExecution).toHaveBeenCalledTimes(1);
service.setRuntimeFeatureCellData('feature-1', { unit: {} } as never);
expect(mocks.runtimeService.setRuntimeFeatureCellData).toHaveBeenCalledWith('feature-1', { unit: {} });
service.setRuntimeFeatureRange('feature-1', { unit: {} } as never);
expect(mocks.runtimeService.setRuntimeFeatureRange).toHaveBeenCalledWith('feature-1', { unit: {} });
});
it('should execute by cycle and notify progress/completion', async () => {
const { service, mocks } = createService();
const executeStepSpy = vi.spyOn(service as any, '_executeStep')
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
mocks.runtimeService.isCycleDependency
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
const progress: any[] = [];
const complete: any[] = [];
const completedPromise = new Promise<void>((resolve) => {
service.executionCompleteListener$.subscribe(() => resolve());
});
service.executionInProgressListener$.subscribe((state) => progress.push(state));
service.executionCompleteListener$.subscribe((data) => complete.push(data));
await service.execute({
formulaData: {},
arrayFormulaCellData: {},
arrayFormulaRange: {},
forceCalculate: true,
dirtyRanges: [],
dirtyNameMap: {},
dirtyDefinedNameMap: {},
dirtyUnitFeatureMap: {},
dirtyUnitOtherFormulaMap: {},
maxIteration: 3,
} as never);
await completedPromise;
expect(executeStepSpy).toHaveBeenCalledTimes(2);
expect(mocks.currentConfigService.load).toHaveBeenCalledTimes(1);
expect(mocks.runtimeService.setFormulaCycleIndex).toHaveBeenCalledWith(0);
expect(mocks.runtimeService.setFormulaCycleIndex).toHaveBeenCalledWith(1);
expect(mocks.runtimeService.setFormulaExecuteStage).toHaveBeenCalledWith(FormulaExecuteStageType.CALCULATION_COMPLETED);
expect(progress.length).toBeGreaterThanOrEqual(2);
expect(complete.length).toBe(1);
expect(mocks.runtimeService.reset).toHaveBeenCalledTimes(2);
});
it('should merge array dirty ranges and excluded cells', () => {
const { service } = createService();
const result = (service as any)._getArrayFormulaDirtyRangeAndExcludedRange(
{
u1: {
s1: {
0: {
1: {
startRow: 1,
endRow: 2,
startColumn: 3,
endColumn: 4,
},
},
},
},
},
{
featureA: {
u2: {
s2: [
{ startRow: 7, endRow: 7, startColumn: 8, endColumn: 8 },
],
},
},
}
);
expect(result.dirtyRanges).toEqual([
{
unitId: 'u1',
sheetId: 's1',
range: { startRow: 1, endRow: 2, startColumn: 3, endColumn: 4 },
},
{
unitId: 'u2',
sheetId: 's2',
range: { startRow: 7, endRow: 7, startColumn: 8, endColumn: 8 },
},
]);
expect(result.excludedCell.u1?.s1?.getValue(0, 1)).toBe(true);
});
it('should execute extra apply when array dirty ranges exist', async () => {
const { service, mocks } = createService();
const applySpy = vi.spyOn(service as any, '_apply')
.mockResolvedValueOnce({
arrayFormulaRange: {
u1: {
s1: {
0: {
0: {
startRow: 1,
endRow: 1,
startColumn: 1,
endColumn: 1,
},
},
},
},
},
runtimeFeatureRange: {},
})
.mockResolvedValueOnce({
arrayFormulaRange: {},
runtimeFeatureRange: {},
});
await (service as any)._executeStep();
expect(mocks.currentConfigService.loadDirtyRangesAndExcludedCell).toHaveBeenCalledTimes(1);
expect(applySpy).toHaveBeenNthCalledWith(2, true);
});
it('should skip second apply when no dirty range exists', async () => {
const { service } = createService();
const applySpy = vi.spyOn(service as any, '_apply').mockResolvedValueOnce({
arrayFormulaRange: {},
runtimeFeatureRange: {},
});
const result = await (service as any)._executeStep();
expect(result).toBe(true);
expect(applySpy).toHaveBeenCalledTimes(1);
});
it('should return when apply returns null in execute step', async () => {
const { service } = createService();
vi.spyOn(service as any, '_apply').mockResolvedValueOnce(null);
const result = await (service as any)._executeStep();
expect(result).toBeUndefined();
});
it('should apply trees for feature callbacks and formula execution', async () => {
const { service, mocks } = createService();
const resetNode = { resetCalculationState: vi.fn() };
const featureDirtyData = {
runtimeCellData: { u: {} },
dirtyRanges: { u: {} },
};
mocks.formulaDependencyGenerator.generate.mockResolvedValueOnce([
{
row: 1,
column: 2,
rowCount: 20,
columnCount: 10,
subUnitId: 'sheet',
unitId: 'unit',
nodeData: {
node: resetNode,
refOffsetX: 0,
refOffsetY: 0,
},
getDirtyData: null,
featureId: null,
formulaId: null,
refOffsetX: 0,
refOffsetY: 0,
},
{
row: 3,
column: 4,
rowCount: 20,
columnCount: 10,
subUnitId: 'sheet',
unitId: 'unit',
nodeData: {
node: resetNode,
refOffsetX: 0,
refOffsetY: 0,
},
getDirtyData: null,
featureId: null,
formulaId: 'formula-id',
refOffsetX: 1,
refOffsetY: 2,
},
{
row: 5,
column: 6,
rowCount: 20,
columnCount: 10,
subUnitId: 'sheet',
unitId: 'unit',
nodeData: {
node: resetNode,
refOffsetX: 0,
refOffsetY: 0,
},
getDirtyData: () => featureDirtyData,
featureId: 'feature-id',
formulaId: null,
refOffsetX: 0,
refOffsetY: 0,
},
] as never);
await (service as any)._apply(false);
expect(mocks.configService.getConfig).toHaveBeenCalledWith(ENGINE_FORMULA_PLUGIN_CONFIG_KEY);
expect(mocks.runtimeService.setCurrent).toHaveBeenCalled();
expect(mocks.interpreter.execute).toHaveBeenCalled();
expect(mocks.runtimeService.setRuntimeData).toHaveBeenCalledTimes(1);
expect(mocks.runtimeService.setRuntimeOtherData).toHaveBeenCalledWith('formula-id', 1, 2, expect.anything());
expect(mocks.runtimeService.setRuntimeFeatureCellData).toHaveBeenCalledWith('feature-id', featureDirtyData.runtimeCellData);
expect(mocks.runtimeService.setRuntimeFeatureRange).toHaveBeenCalledWith('feature-id', featureDirtyData.dirtyRanges);
expect(mocks.runtimeService.markedAsSuccessfullyExecuted).toHaveBeenCalledTimes(1);
expect(resetNode.resetCalculationState).toHaveBeenCalledTimes(3);
});
it('should use async interpreter branch when node is async', async () => {
const { service, mocks } = createService();
const resetNode = { resetCalculationState: vi.fn() };
mocks.formulaDependencyGenerator.generate.mockResolvedValueOnce([
{
row: 1,
column: 1,
rowCount: 10,
columnCount: 10,
subUnitId: 's',
unitId: 'u',
nodeData: {
node: resetNode,
refOffsetX: 0,
refOffsetY: 0,
},
getDirtyData: null,
featureId: null,
formulaId: null,
refOffsetX: 0,
refOffsetY: 0,
},
] as never);
mocks.interpreter.checkAsyncNode.mockReturnValueOnce(true);
await (service as any)._apply(false);
expect(mocks.interpreter.executeAsync).toHaveBeenCalledTimes(1);
});
it('should stop apply when stop-state is set', async () => {
const { service, mocks } = createService();
mocks.formulaDependencyGenerator.generate.mockResolvedValueOnce([
{
row: 1,
column: 1,
rowCount: 10,
columnCount: 10,
subUnitId: 's',
unitId: 'u',
nodeData: null,
getDirtyData: null,
featureId: null,
formulaId: null,
refOffsetX: 0,
refOffsetY: 0,
},
] as never);
mocks.runtimeService.isStopExecution.mockReturnValueOnce(true);
const completed: any[] = [];
service.executionCompleteListener$.subscribe((item) => completed.push(item));
await (service as any)._apply(false);
expect(mocks.runtimeService.setFormulaExecuteStage).toHaveBeenCalledWith(FormulaExecuteStageType.IDLE);
expect(mocks.runtimeService.markedAsStopFunctionsExecuted).toHaveBeenCalledTimes(1);
expect(completed.length).toBe(1);
});
it('should mark no-functions-executed when tree list is empty', async () => {
const { service, mocks } = createService();
mocks.formulaDependencyGenerator.generate.mockResolvedValueOnce([]);
await (service as any)._apply(false);
expect(mocks.runtimeService.markedAsNoFunctionsExecuted).toHaveBeenCalledTimes(1);
});
it('should execute formulas with null, reference, scalar and array variants', async () => {
const { service, mocks } = createService();
const oneCellArray = {
isReferenceObject: () => false,
isArray: () => true,
getRowCount: () => 1,
getColumnCount: () => 1,
getFirstCell: () => ({ getValue: () => 10 }),
};
const multiArray = {
isReferenceObject: () => false,
isArray: () => true,
getRowCount: () => 2,
getColumnCount: () => 2,
toValue: () => [[1, 2], [3, 4]],
};
const scalar = {
isReferenceObject: () => false,
isArray: () => false,
getValue: () => 99,
};
const referenceObject = {
isReferenceObject: () => true,
toArrayValueObject: () => oneCellArray,
};
vi.spyOn(service, 'calculate' as any)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(referenceObject as never)
.mockResolvedValueOnce(multiArray as never)
.mockResolvedValueOnce(scalar as never);
const result = await service.executeFormulas({
unit: {
sheet: {
1: {
1: ['=A1', '=A2', '=A3', '=A4'],
},
},
},
} as never);
expect(mocks.currentConfigService.loadDataLite).toHaveBeenCalledTimes(1);
expect(mocks.runtimeService.reset).toHaveBeenCalledTimes(1);
expect(mocks.runtimeService.setCurrent).toHaveBeenCalledTimes(1);
expect(result.unit?.sheet?.[1]?.[1]).toEqual([
{ value: null, formula: '=A1' },
{ value: 10, formula: '=A2' },
{ value: [[1, 2], [3, 4]], formula: '=A3' },
{ value: 99, formula: '=A4' },
]);
});
it('should parse and calculate formula through lexer/parser/interpreter', async () => {
const { service, mocks } = createService();
const astNode = { nodeType: 'mock' };
mocks.lexer.treeBuilder.mockReturnValueOnce(ErrorType.NAME);
await expect(service.calculate('=BAD()')).resolves.toBeUndefined();
mocks.lexer.treeBuilder.mockReturnValueOnce({ token: 'ok' });
mocks.astTreeBuilder.parse.mockReturnValueOnce(null);
await expect(service.calculate('=A1')).resolves.toBeUndefined();
mocks.lexer.treeBuilder.mockReturnValueOnce({ token: 'ok2' });
mocks.astTreeBuilder.parse.mockReturnValueOnce(astNode);
mocks.interpreter.checkAsyncNode.mockReturnValueOnce(true);
await service.calculate('=ASYNC()');
expect(mocks.interpreter.executeAsync).toHaveBeenCalledWith({
node: astNode,
refOffsetX: 0,
refOffsetY: 0,
});
mocks.lexer.treeBuilder.mockReturnValueOnce({ token: 'ok3' });
mocks.astTreeBuilder.parse.mockReturnValueOnce(astNode);
mocks.interpreter.checkAsyncNode.mockReturnValueOnce(false);
await service.calculate('=SYNC()');
expect(mocks.interpreter.execute).toHaveBeenCalledWith({
node: astNode,
refOffsetX: 0,
refOffsetY: 0,
});
});
it('should delegate dependency query APIs after loading lite data', async () => {
const { service, mocks } = createService();
await expect(service.getAllDependencyJson()).resolves.toEqual([{ treeId: 1 }]);
await expect(service.getCellDependencyJson('u', 's', 1, 1)).resolves.toEqual({ treeId: 2 });
await expect(service.getRangeDependents([])).resolves.toEqual([{ treeId: 3 }]);
await expect(service.getInRangeFormulas([])).resolves.toEqual([{ treeId: 4 }]);
await expect(service.getDependentsAndInRangeFormulas([])).resolves.toEqual({ dependents: [], inRanges: [] });
expect(mocks.currentConfigService.loadDataLite).toHaveBeenCalledTimes(5);
});
});
@@ -0,0 +1,217 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { ObjectMatrix } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { FormulaCurrentConfigService } from '../current-data.service';
function createService() {
const workbookForCurrentType = {
getUnitId: vi.fn(() => 'unit-current'),
getActiveSheet: vi.fn(() => ({
getSheetId: () => 'sheet-current',
})),
getSnapshot: vi.fn(() => ({
id: 'unit-current',
sheetOrder: ['sheet-current'],
})),
};
const workbookById = new Map<string, unknown>();
const univerInstanceService = {
getCurrentUnitForType: vi.fn(() => workbookForCurrentType),
getUnit: vi.fn((unitId: string) => workbookById.get(unitId)),
};
const localeService = {
getCurrentLocale: vi.fn(() => 'zhCN'),
};
const formulaDataModel = {
getCalculateData: vi.fn(() => ({
allUnitData: {},
unitSheetNameMap: {},
unitStylesData: {},
})),
getFormulaData: vi.fn(() => ({ unitLite: { sheetLite: {} } })),
getArrayFormulaCellData: vi.fn(() => ({})),
getArrayFormulaRange: vi.fn(() => ({ unitLite: { sheetLite: {} } })),
};
const sheetRowFilteredService = {
getRowFiltered: vi.fn((_unitId: string, _sheetId: string, row: number) => row === 2 || row === 4),
};
const service = new FormulaCurrentConfigService(
univerInstanceService as never,
localeService as never,
formulaDataModel as never,
sheetRowFilteredService as never
);
return {
service,
workbookById,
univerInstanceService,
localeService,
formulaDataModel,
};
}
describe('FormulaCurrentConfigService', () => {
it('should load explicit dataset config and merge dirty names to sheet-id map', () => {
const { service } = createService();
const unitData = {
unitA: {
sheetA: {
cellData: new ObjectMatrix({}),
rowCount: 10,
columnCount: 5,
rowData: {},
columnData: {},
},
},
};
service.load({
allUnitData: unitData as never,
unitStylesData: { unitA: {} } as never,
unitSheetNameMap: {
unitA: {
SheetA: 'sheetA',
},
} as never,
formulaData: { unitA: { sheetA: {} } } as never,
arrayFormulaCellData: {},
arrayFormulaRange: {},
forceCalculate: true,
clearDependencyTreeCache: { unitA: { sheetA: 'SheetA' } } as never,
dirtyRanges: [{ unitId: 'unitA', sheetId: 'sheetA', range: { startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 } }],
dirtyNameMap: { unitA: { sheetB: 'SheetB' } } as never,
dirtyDefinedNameMap: { unitA: {} } as never,
dirtyUnitFeatureMap: {} as never,
dirtyUnitOtherFormulaMap: {} as never,
excludedCell: { unitA: { sheetA: { 0: { 0: true } } } } as never,
rowData: { unitA: { sheetB: { 0: { h: 20 } } } } as never,
});
expect(service.isForceCalculate()).toBe(true);
expect(service.getUnitData().unitA.sheetB.rowData).toEqual({ 0: { h: 20 } });
expect(service.getSheetName('unitA', 'sheetA')).toBe('SheetA');
expect(service.getSheetName('unitA', 'sheetB')).toBe('SheetB');
expect(service.getClearDependencyTreeCache()).toEqual({ unitA: { sheetA: 'SheetA' } });
expect(service.getDirtyData()).toEqual(expect.objectContaining({
forceCalculation: true,
dirtyRanges: expect.any(Array),
dirtyNameMap: { unitA: { sheetB: 'SheetB' } },
}));
});
it('should load sheet data from model when allUnitData is omitted and expose workbook info', () => {
const { service, formulaDataModel, localeService } = createService();
formulaDataModel.getCalculateData.mockReturnValue({
allUnitData: {
'unit-current': {
'sheet-current': {
cellData: new ObjectMatrix({}),
rowCount: 9,
columnCount: 4,
rowData: {},
columnData: {},
},
},
},
unitStylesData: { 'unit-current': {} },
unitSheetNameMap: { 'unit-current': { Main: 'sheet-current' } },
});
service.load({
formulaData: {},
arrayFormulaCellData: {},
arrayFormulaRange: {},
forceCalculate: false,
clearDependencyTreeCache: {},
dirtyRanges: [],
dirtyNameMap: {},
dirtyDefinedNameMap: {},
dirtyUnitFeatureMap: {},
dirtyUnitOtherFormulaMap: {},
excludedCell: {},
} as never);
expect(service.getExecuteUnitId()).toBe('unit-current');
expect(service.getExecuteSubUnitId()).toBe('sheet-current');
expect(service.getSheetsInfo()).toEqual({
sheetOrder: ['sheet-current'],
sheetNameMap: { 'sheet-current': 'Main' },
});
expect(service.getLocale()).toBe('zhCN');
expect(localeService.getCurrentLocale).toHaveBeenCalled();
});
it('should resolve sheet size, filtered rows and lightweight data loading', () => {
const { service, workbookById, formulaDataModel } = createService();
workbookById.set('unit-size', {
getSheetBySheetId: (sheetId: string) => {
if (sheetId === 'sheet-size') {
return {
getSnapshot: () => ({ rowCount: 22, columnCount: 8 }),
};
}
return undefined;
},
});
expect(service.getSheetRowColumnCount('unit-size', 'sheet-size')).toEqual({ rowCount: 22, columnCount: 8 });
expect(service.getSheetRowColumnCount('unit-size', 'missing')).toEqual({ rowCount: 0, columnCount: 0 });
expect(service.getFilteredOutRows('unit-size', 'sheet-size', 1, 5)).toEqual([2, 4]);
formulaDataModel.getCalculateData.mockReturnValue({
allUnitData: {},
unitStylesData: {},
unitSheetNameMap: {},
});
formulaDataModel.getFormulaData.mockReturnValue({ unitLite: { sheetLite: { 1: { 1: { f: '=A1' } } } } });
formulaDataModel.getArrayFormulaRange.mockReturnValue({ unitLite: { sheetLite: { key: 'range' } } });
service.loadDataLite({ unitLite: { sheetLite: { 1: { h: 30 } } } } as never);
expect(service.getFormulaData()).toEqual({ unitLite: { sheetLite: { 1: { 1: { f: '=A1' } } } } });
expect(service.getArrayFormulaRange()).toEqual({ unitLite: { sheetLite: { key: 'range' } } });
expect(service.getUnitData().unitLite.sheetLite.rowData).toEqual({ 1: { h: 30 } });
});
it('should update dirty ranges/register data and cleanup all caches on dispose', () => {
const { service } = createService();
service.registerUnitData({ unit: { sheet: { cellData: new ObjectMatrix({}), rowCount: 1, columnCount: 1, rowData: {}, columnData: {} } } } as never);
service.registerFormulaData({ unit: { sheet: { 1: { 1: { f: '=1' } } } } } as never);
service.registerSheetNameMap({ unit: { Sheet: 'sheet' } } as never);
service.loadDirtyRangesAndExcludedCell(
[{ unitId: 'unit', sheetId: 'sheet', range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 } }],
{ unit: { sheet: { 0: { 0: true } } } } as never
);
expect(service.getDirtyRanges()).toHaveLength(1);
expect(service.getExcludedRange()).toEqual({ unit: { sheet: { 0: { 0: true } } } });
expect(service.getDirtyNameMap()).toEqual({});
service.dispose();
expect(service.getUnitData()).toEqual({});
expect(service.getFormulaData()).toEqual({});
expect(service.getSheetNameMap()).toEqual({});
expect(service.getExcludedRange()).toEqual({});
});
});
@@ -0,0 +1,125 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { DefinedNamesService } from '../defined-names.service';
function createDefinedNamesService() {
const worksheet = { id: 'sheet-a' };
const workbook = {
getSheetBySheetName: (sheetName: string) => (sheetName === 'Sheet1' ? worksheet : null),
};
const univerInstanceService = {
getUnit: () => workbook,
};
const service = new DefinedNamesService(univerInstanceService as never);
return {
service,
worksheet,
};
}
describe('DefinedNamesService', () => {
it('should register/query/remove defined names and update cache', () => {
const { service } = createDefinedNamesService();
service.registerDefinedName('unit-1', {
id: 'id-1',
name: 'Total',
formulaOrRefString: 'Sheet1!A1',
});
expect(service.getValueById('unit-1', 'id-1')?.name).toBe('Total');
expect(service.getValueByName('unit-1', 'Total')?.id).toBe('id-1');
expect(service.getValueByName('unit-1', 'total')?.id).toBe('id-1');
expect(service.hasDefinedName('unit-1')).toBe(true);
service.removeDefinedName('unit-1', 'id-1');
expect(service.getValueById('unit-1', 'id-1')).toBeUndefined();
expect(service.hasDefinedName('unit-1')).toBe(false);
});
it('should support batch register and unit cleanup', () => {
const { service } = createDefinedNamesService();
service.registerDefinedNames('unit-2', {
a: {
id: 'a',
name: 'NameA',
formulaOrRefString: 'Sheet1!A1',
},
b: {
id: 'b',
name: 'NameB',
formulaOrRefString: 'Sheet1!B1',
},
});
expect(service.getDefinedNameMap('unit-2')).toBeDefined();
expect(service.getAllDefinedNames()['unit-2']).toBeDefined();
expect(service.getDefinedNameByRefString('unit-2', 'Sheet1!B1')?.id).toBe('b');
service.removeUnitDefinedName('unit-2');
expect(service.getDefinedNameMap('unit-2')).toBeUndefined();
});
it('should emit update/current/focus streams', () => {
const { service } = createDefinedNamesService();
let updateCount = 0;
const ranges: any[] = [];
const focused: any[] = [];
service.update$.subscribe(() => updateCount++);
service.currentRange$.subscribe((range) => ranges.push(range));
service.focusRange$.subscribe((payload) => focused.push(payload));
service.registerDefinedName('unit-3', {
id: 'id-focus',
name: 'F',
formulaOrRefString: 'Sheet1!A1',
});
service.setCurrentRange({
unitId: 'unit-3',
sheetId: 'sheet-1',
range: {
startRow: 1,
endRow: 2,
startColumn: 3,
endColumn: 4,
},
});
service.focusRange('unit-3', 'id-focus');
service.focusRange('unit-3', 'missing-id');
expect(updateCount).toBeGreaterThan(0);
expect(ranges[0]?.unitId).toBe('unit-3');
expect(service.getCurrentRangeForString()).toBe('D2:E3');
expect(focused).toEqual([
{
unitId: 'unit-3',
id: 'id-focus',
name: 'F',
formulaOrRefString: 'Sheet1!A1',
},
]);
});
it('should resolve worksheet by reference string', () => {
const { service, worksheet } = createDefinedNamesService();
expect(service.getWorksheetByRef('unit-1', 'Sheet1!A1')).toBe(worksheet);
expect(service.getWorksheetByRef('unit-1', 'Missing!A1')).toBeNull();
});
});
@@ -0,0 +1,165 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 type { AstRootNode } from '../../engine/ast-node';
import { describe, expect, it } from 'vitest';
import { FormulaDependencyTree } from '../../engine/dependency/dependency-tree';
import { DependencyManagerService } from '../dependency-manager.service';
function createTree(treeId: number, row: number, column: number) {
const tree = new FormulaDependencyTree(treeId);
tree.unitId = 'unit-1';
tree.subUnitId = 'sheet-1';
tree.row = row;
tree.column = column;
tree.rangeList = [
{
unitId: 'unit-1',
sheetId: 'sheet-1',
range: {
startRow: row,
startColumn: column,
endRow: row,
endColumn: column,
},
},
];
return tree;
}
describe('DependencyManagerService', () => {
it('should track formula dependencies and clear by location', () => {
const service = new DependencyManagerService();
const treeA = createTree(1, 0, 0);
const treeB = createTree(2, 1, 1);
service.addFormulaDependency('unit-1', 'sheet-1', 0, 0, treeA);
service.addFormulaDependency('unit-1', 'sheet-1', 1, 1, treeB);
service.addDependencyRTreeCache(treeA);
service.addDependencyRTreeCache(treeB);
expect(service.getFormulaDependency('unit-1', 'sheet-1', 0, 0)).toBe(1);
expect(service.getFormulaDependency('unit-1', 'sheet-1', 1, 1)).toBe(2);
expect(service.getTreeById(1)).toBe(treeA);
expect(service.getTreeById(2)).toBe(treeB);
service.removeFormulaDependency('unit-1', 'sheet-1', 0, 0);
expect(service.getFormulaDependency('unit-1', 'sheet-1', 0, 0)).toBeUndefined();
expect(service.getTreeById(1)).toBeUndefined();
service.clearFormulaDependency('unit-1', 'sheet-1');
expect(service.getTreeById(2)).toBeUndefined();
});
it('should manage other-formula dependency matrices and main-data flag', () => {
const service = new DependencyManagerService();
const tree = createTree(10, 0, 0);
tree.refOffsetX = 3;
tree.refOffsetY = 4;
service.addOtherFormulaDependency('unit-1', 'sheet-1', 'f-1', tree);
service.addOtherFormulaDependencyMainData('f-1');
expect(service.getOtherFormulaDependency('unit-1', 'sheet-1', 'f-1')?.getValue(3, 4)).toBe(10);
expect(service.hasOtherFormulaDataMainData('f-1')).toBe(true);
service.removeOtherFormulaDependency('unit-1', 'sheet-1', ['f-1']);
expect(service.getOtherFormulaDependency('unit-1', 'sheet-1', 'f-1')).toBeUndefined();
expect(service.hasOtherFormulaDataMainData('f-1')).toBe(false);
});
it('should manage feature formula dependencies', () => {
const service = new DependencyManagerService();
const tree = createTree(20, 2, 2);
service.addFeatureFormulaDependency('unit-1', 'sheet-1', 'feature-a', tree);
expect(service.getFeatureFormulaDependency('unit-1', 'sheet-1', 'feature-a')).toBe(20);
service.removeFeatureFormulaDependency('unit-1', 'sheet-1', ['feature-a']);
expect(service.getFeatureFormulaDependency('unit-1', 'sheet-1', 'feature-a')).toBeUndefined();
});
it('should clear dependencies by defined name', () => {
const service = new DependencyManagerService();
const tree = createTree(30, 3, 3);
service.addFormulaDependency('unit-1', 'sheet-1', 3, 3, tree);
service.addDependencyRTreeCache(tree);
const node = {
getDefinedNames: () => ['MY_NAME'],
} as AstRootNode;
service.addFormulaDependencyByDefinedName(tree, node);
service.removeFormulaDependencyByDefinedName('unit-1', 'MY_NAME');
expect(service.getTreeById(30)).toBeUndefined();
});
it('should build dependency graph and reverse dependencies', () => {
const service = new DependencyManagerService();
const dependantTree = createTree(40, 0, 0);
dependantTree.rangeList = [
{
unitId: 'unit-1',
sheetId: 'sheet-1',
range: {
startRow: 0,
startColumn: 1,
endRow: 0,
endColumn: 1,
},
},
];
const sourceTree = createTree(41, 0, 1);
service.addDependencyRTreeCache(dependantTree);
service.addDependencyRTreeCache(sourceTree);
service.buildDependencyTree([dependantTree], [dependantTree]);
expect(dependantTree.children.has(41)).toBe(true);
expect(sourceTree.parents.has(40)).toBe(true);
});
it('should clear parent-child links when tree is removed', () => {
const service = new DependencyManagerService();
const parent = createTree(50, 5, 5);
const child = createTree(51, 5, 6);
parent.pushChildren(child);
service.addDependencyRTreeCache(parent);
service.addDependencyRTreeCache(child);
service.clearDependencyForTree(child);
expect(parent.children.has(51)).toBe(false);
expect(child.rangeList).toEqual([]);
});
it('should support tree id allocation, dirty state update and reset', () => {
const service = new DependencyManagerService();
expect(service.getLastTreeId()).toBe(0);
expect(service.getLastTreeId()).toBe(1);
const tree = createTree(60, 6, 6);
service.addDependencyRTreeCache(tree);
service.updateDependencyTreeDirtyState(60, true);
expect(service.getTreeById(60)?.isDirty).toBe(true);
service.reset();
expect(service.getTreeById(60)).toBeUndefined();
expect(service.getLastTreeId()).toBe(0);
});
});
@@ -0,0 +1,102 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { describe, expect, it } from 'vitest';
import { FunctionType } from '../../basics/function';
import { FORMULA_AST_CACHE } from '../../engine/utils/generate-ast-node';
import { FunctionService } from '../function.service';
describe('FunctionService', () => {
it('should register/query/unregister executors', () => {
const service = new FunctionService();
const sumExecutor = { name: 'SUM' };
const avgExecutor = { name: 'AVERAGE' };
service.registerExecutors(sumExecutor as never, avgExecutor as never);
expect(service.hasExecutor('SUM')).toBe(true);
expect(service.getExecutor('SUM')).toBe(sumExecutor);
expect(service.getExecutors().size).toBe(2);
service.unregisterExecutors('AVERAGE');
expect(service.hasExecutor('AVERAGE')).toBe(false);
});
it('should register descriptions and cleanup by disposable', () => {
const service = new FunctionService();
const sumDescription = {
functionName: 'SUM',
functionType: FunctionType.Math,
description: 'sum',
abstract: 'sum',
functionParameter: [],
};
const avgDescription = {
functionName: 'AVERAGE',
functionType: FunctionType.Statistical,
description: 'avg',
abstract: 'avg',
functionParameter: [],
};
const disposable = service.registerDescriptions(sumDescription as never, avgDescription as never);
expect(service.hasDescription('SUM')).toBe(true);
expect(service.getDescription('AVERAGE')?.description).toBe('avg');
expect(service.getDescriptions().size).toBe(2);
disposable.dispose();
expect(service.hasDescription('SUM')).toBe(false);
expect(service.hasDescription('AVERAGE')).toBe(false);
});
it('should unregister descriptions and clear formula AST cache by function tokens', () => {
const service = new FunctionService();
service.registerDescriptions({
functionName: 'SUM',
functionType: FunctionType.Math,
description: 'sum',
abstract: 'sum',
functionParameter: [],
} as never);
expect(service.hasDescription('SUM')).toBe(true);
service.unregisterDescriptions('SUM');
expect(service.hasDescription('SUM')).toBe(false);
FORMULA_AST_CACHE.clear();
FORMULA_AST_CACHE.set('unit_sheet_SUM_expr', {} as never);
FORMULA_AST_CACHE.set('unit_sheet_TEXT_expr', {} as never);
service.deleteFormulaAstCacheKey('SUM');
expect(FORMULA_AST_CACHE.get('unit_sheet_SUM_expr')).toBeUndefined();
expect(FORMULA_AST_CACHE.get('unit_sheet_TEXT_expr')).toBeDefined();
});
it('should clear internal maps on dispose', () => {
const service = new FunctionService();
service.registerExecutors({ name: 'SUM' } as never);
service.registerDescriptions({
functionName: 'SUM',
functionType: FunctionType.Math,
description: 'sum',
abstract: 'sum',
functionParameter: [],
} as never);
service.dispose();
expect(service.getExecutors().size).toBe(0);
expect(service.getDescriptions().size).toBe(0);
});
});
@@ -0,0 +1,61 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache 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
*
* http://www.apache.org/licenses/LICENSE-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 { BehaviorSubject } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { GlobalComputingStatusService } from '../global-computing-status.service';
describe('GlobalComputingStatusService', () => {
it('should aggregate computing status from all registered subjects', () => {
const service = new GlobalComputingStatusService();
const subjectA = new BehaviorSubject(false);
const subjectB = new BehaviorSubject(true);
const disposableA = service.pushComputingStatusSubject(subjectA);
const disposableB = service.pushComputingStatusSubject(subjectB);
expect(service.computingStatus).toBe(false);
subjectA.next(true);
expect(service.computingStatus).toBe(true);
subjectB.next(false);
expect(service.computingStatus).toBe(false);
disposableB.dispose();
expect(service.computingStatus).toBe(true);
disposableA.dispose();
expect(service.computingStatus).toBe(true);
});
it('should expose computingStatus$ and cleanup on dispose', () => {
const service = new GlobalComputingStatusService();
const subject = new BehaviorSubject(true);
const observed: boolean[] = [];
service.computingStatus$.subscribe((value) => observed.push(value));
service.pushComputingStatusSubject(subject);
subject.next(false);
subject.next(true);
expect(observed).toContain(false);
expect(observed[0]).toBe(true);
service.dispose();
expect(subject.isStopped).toBe(true);
});
});

Some files were not shown because too many files have changed in this diff Show More