test: reorganize and deduplicate service tests (#7255)

This commit is contained in:
白熱
2026-07-14 16:29:00 +08:00
committed by GitHub
parent 6efa263479
commit b8303adaf6
25 changed files with 1082 additions and 1433 deletions
@@ -1,273 +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 { Univer } from '../../univer';
import { UnitAction, UnitObject, UnitRole } from '@univerjs/protocol';
import { BehaviorSubject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { UnitModel, UniverInstanceType } from '../../common/unit';
import { IAuthzIoService } from '../authz-io/type';
import { IUniverInstanceService } from '../instance/instance.service';
import { IMentionIOService } from '../mention-io/type';
import { IResourceLoaderService } from '../resource-loader/type';
import { UserManagerService } from '../user-manager/user-manager.service';
import { createTestBed } from './create-test-bed';
interface ITestBoardData {
id: string;
name?: string;
resources?: Array<{ name: string; data: string }>;
}
class MockBoardUnit extends UnitModel<ITestBoardData, UniverInstanceType.UNIVER_BOARD> {
override readonly type = UniverInstanceType.UNIVER_BOARD;
override name$ = new BehaviorSubject('');
private readonly _snapshot: ITestBoardData;
constructor(snapshot: Partial<ITestBoardData> = {}) {
super();
this._snapshot = {
id: 'board-resource',
name: '',
...snapshot,
};
this.name$.next(this._snapshot.name ?? '');
}
override getUnitId(): string {
return this._snapshot.id;
}
override setName(name: string): void {
this._snapshot.name = name;
this.name$.next(name);
}
override getSnapshot(): ITestBoardData {
return this._snapshot;
}
override getRev(): number {
return 1;
}
override incrementRev(): void { }
override setRev(): void { }
}
describe('Authz/resource integration', () => {
let univer: Univer;
let unitId: string;
beforeEach(() => {
const instance = createTestBed();
univer = instance.univer;
unitId = instance.unitId;
});
afterEach(() => {
univer.dispose();
});
it('should persist and reload permission resources through a real workbook lifecycle', async () => {
const injector = univer.__getInjector();
const authzIoService = injector.get(IAuthzIoService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const userManagerService = injector.get(UserManagerService);
userManagerService.setCurrentUser({
userID: 'Owner_real-user',
name: 'Owner User',
avatar: '',
});
const objectID = await authzIoService.create({
objectType: UnitObject.SelectRange,
selectRangeObject: {
unitID: unitId,
name: 'Protected range',
collaborators: [],
scope: undefined,
},
});
expect(await authzIoService.allowed({ unitID: unitId, objectID, objectType: UnitObject.SelectRange, actions: [UnitAction.Edit] })).toEqual([
{ action: UnitAction.Edit, allowed: true },
]);
await authzIoService.update({
objectType: UnitObject.SelectRange,
objectID,
unitID: unitId,
share: undefined,
name: 'Protected range',
strategies: [{ action: UnitAction.Edit, role: UnitRole.Reader }],
scope: undefined,
collaborators: undefined,
});
expect(await authzIoService.allowed({ unitID: unitId, objectID, objectType: UnitObject.SelectRange, actions: [UnitAction.Edit] })).toEqual([
{ action: UnitAction.Edit, allowed: false },
]);
const listed = await authzIoService.list({
unitID: unitId,
objectIDs: [objectID],
actions: [UnitAction.Edit, UnitAction.View],
});
expect(listed).toHaveLength(1);
expect(listed[0].name).toBe('Protected range');
expect(listed[0].actions).toContainEqual({ action: UnitAction.Edit, allowed: false });
const batched = await authzIoService.batchAllowed([
{ unitID: unitId, objectID, objectType: UnitObject.SelectRange, actions: [UnitAction.Edit] },
{ unitID: unitId, objectID: 'missing', objectType: UnitObject.SelectRange, actions: [UnitAction.View] },
]);
expect(batched[0].actions).toEqual([{ action: UnitAction.Edit, allowed: false }]);
expect(batched[1].actions).toEqual([{ action: UnitAction.View, allowed: true }]);
expect(await authzIoService.listCollaborators({ unitID: unitId, objectID })).toEqual([]);
expect(await authzIoService.listRoles({ objectType: UnitObject.SelectRange })).toEqual({ roles: [], actions: [] });
await expect(authzIoService.createCollaborator({ unitID: unitId, objectID } as never)).resolves.toBeUndefined();
await expect(authzIoService.updateCollaborator({ unitID: unitId, objectID } as never)).resolves.toBeUndefined();
await expect(authzIoService.deleteCollaborator({ unitID: unitId, objectID } as never)).resolves.toBeUndefined();
await expect(authzIoService.putCollaborators({ unitID: unitId, objectID, collaborators: [] })).resolves.toBeUndefined();
const snapshot = resourceLoaderService.saveUnit(unitId);
const authzResource = snapshot?.resources.find((resource) => resource.name === 'SHEET_AuthzIoMockService_PLUGIN');
expect(authzResource?.data).toContain(objectID);
injector.get(IUniverInstanceService).disposeUnit(unitId);
const unloaded = await authzIoService.list({
unitID: unitId,
objectIDs: [objectID],
actions: [UnitAction.Edit],
});
expect(unloaded[0].name).toBe('');
expect(unloaded[0].actions).toEqual([{ action: UnitAction.Edit, allowed: false }]);
univer.createUnit(UniverInstanceType.UNIVER_SHEET, snapshot!);
const reloaded = await authzIoService.list({
unitID: unitId,
objectIDs: [objectID],
actions: [UnitAction.Edit],
});
expect(reloaded[0].name).toBe('Protected range');
expect(reloaded[0].actions).toEqual([{ action: UnitAction.Edit, allowed: false }]);
});
it('should persist and reload permission resources for board units', async () => {
const injector = univer.__getInjector();
const authzIoService = injector.get(IAuthzIoService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const univerInstanceService = injector.get(IUniverInstanceService);
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_BOARD, MockBoardUnit);
const board = univer.createUnit<ITestBoardData, MockBoardUnit>(UniverInstanceType.UNIVER_BOARD, {
id: 'board-resource',
});
const objectID = await authzIoService.create({
objectType: UnitObject.Workbook,
worksheetObject: {
unitID: board.getUnitId(),
name: 'Board permission',
collaborators: [],
strategies: [{ action: UnitAction.Edit, role: UnitRole.Owner }],
scope: undefined,
},
});
const snapshot = resourceLoaderService.saveUnit<ITestBoardData>(board.getUnitId());
const authzResource = snapshot?.resources?.find((resource) => resource.name === 'SHEET_AuthzIoMockService_PLUGIN');
expect(authzResource?.data).toContain(objectID);
expect(univerInstanceService.disposeUnit(board.getUnitId())).toBe(true);
const unloaded = await authzIoService.list({
unitID: board.getUnitId(),
objectIDs: [objectID],
actions: [UnitAction.Edit],
});
expect(unloaded[0].name).toBe('');
expect(unloaded[0].actions).toEqual([{ action: UnitAction.Edit, allowed: true }]);
univer.createUnit<ITestBoardData, MockBoardUnit>(UniverInstanceType.UNIVER_BOARD, snapshot!);
const reloaded = await authzIoService.list({
unitID: board.getUnitId(),
objectIDs: [objectID],
actions: [UnitAction.Edit],
});
expect(reloaded[0].name).toBe('Board permission');
expect(reloaded[0].actions).toEqual([{ action: UnitAction.Edit, allowed: true }]);
});
it('should expose current user data consistently through mention and user services', async () => {
const injector = univer.__getInjector();
const userManagerService = injector.get(UserManagerService);
const mentionIOService = injector.get(IMentionIOService);
const userEvents: string[] = [];
userManagerService.userChange$.subscribe((event) => {
userEvents.push(event.type);
});
userManagerService.setCurrentUser({
userID: 'Owner_alice',
name: 'Alice',
avatar: 'alice.png',
});
userManagerService.addUser({ userID: 'Editor_bob', name: 'Bob' });
expect(userManagerService.getCurrentUser().name).toBe('Alice');
expect(userManagerService.getUser('Editor_bob')?.name).toBe('Bob');
expect(userManagerService.list()).toHaveLength(2);
const mentionResult = await mentionIOService.list({ page: 2, size: 5 });
expect(mentionResult.page).toBe(2);
expect(mentionResult.size).toBe(5);
expect(mentionResult.list[0].mentions[0]).toMatchObject({
objectId: 'Owner_alice',
label: 'Alice',
metadata: {
icon: 'alice.png',
},
});
let callbackCalled = false;
expect(userManagerService.getUser('missing-user', () => {
callbackCalled = true;
})).toBeUndefined();
expect(callbackCalled).toBe(true);
userManagerService.delete('Editor_bob');
userManagerService.clear();
expect(userManagerService.list()).toEqual([]);
expect(userEvents).toEqual(['add', 'add', 'delete', 'clear']);
});
});
@@ -1,131 +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 { Univer } from '../../univer';
import type { IPermissionPoint } from '../permission/type';
import { UnitAction, UnitObject } from '@univerjs/protocol';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PermissionService } from '../permission/permission.service';
import { PermissionStatus } from '../permission/type';
import { createTestBed } from './create-test-bed';
class TestPermissionPoint implements IPermissionPoint {
type: UnitObject.Workbook;
id;
status: PermissionStatus.INIT;
subType: UnitAction.Copy;
value: boolean = false;
constructor(id: string) {
this.id = `${UnitObject.Workbook}.${UnitAction.CreateSheet}.${id}` as string;
}
}
describe('Test permission service', () => {
let univer: Univer;
let permissionService: PermissionService;
beforeEach(() => {
univer?.dispose();
const instance = createTestBed([[PermissionService]]);
univer = instance.univer;
permissionService = instance.get(PermissionService);
});
it('test get permission from permissionService', () => {
const point = new TestPermissionPoint('test');
permissionService.addPermissionPoint(point);
const result = permissionService.getPermissionPoint(point.id);
expect(result).toBe(point);
});
it('test get permission$ from permissionService', async () => {
const point = new TestPermissionPoint('test');
const initValue = point.value;
permissionService.addPermissionPoint(point);
const result$ = permissionService.getPermissionPoint$(point.id)!;
permissionService.updatePermissionPoint(point.id, !initValue);
const v = await firstValueFrom(result$);
expect(v.value).toBe(!initValue);
});
it('test compose permission', () => {
const point1 = new TestPermissionPoint('test1');
const point2 = new TestPermissionPoint('test2');
permissionService.addPermissionPoint(point1);
permissionService.addPermissionPoint(point2);
const result = permissionService.composePermission([point1.id, point2.id]);
expect(result).toEqual([point1, point2]);
});
it('test compose permission$', async () => {
const point1 = new TestPermissionPoint('test1');
const point2 = new TestPermissionPoint('test2');
permissionService.addPermissionPoint(point1);
permissionService.addPermissionPoint(point2);
const result$ = permissionService.composePermission$([point1.id, point2.id]);
permissionService.updatePermissionPoint(point2.id, !point2.value);
const v = await firstValueFrom(result$);
expect(v).toEqual([point1, point2]);
});
it('should manage permission updates, duplicates and visibility flags', () => {
const point = new TestPermissionPoint('subject');
const pointSubject = new BehaviorSubject<IPermissionPoint<boolean>>(point);
const updates: string[] = [];
const warningSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const subscription = permissionService.permissionPointUpdate$.subscribe((permission) => {
updates.push(`${permission.id}:${String(permission.value)}`);
});
expect(permissionService.getShowComponents()).toBe(true);
permissionService.setShowComponents(false);
expect(permissionService.getShowComponents()).toBe(false);
expect(permissionService.addPermissionPoint(pointSubject)).toBe(true);
expect(permissionService.addPermissionPoint(point)).toBe(false);
permissionService.updatePermissionPoint(point.id, true);
expect(permissionService.getPermissionPoint(point.id)).toMatchObject({
value: true,
status: PermissionStatus.DONE,
});
expect(updates).toEqual([
`${point.id}:false`,
`${point.id}:true`,
]);
const snapshot = permissionService.getAllPermissionPoint();
snapshot.clear();
expect(permissionService.getAllPermissionPoint().size).toBe(1);
permissionService.deletePermissionPoint(point.id);
expect(permissionService.getPermissionPoint(point.id)).toBeUndefined();
permissionService.clearPermissionMap();
expect(permissionService.getAllPermissionPoint().size).toBe(0);
subscription.unsubscribe();
warningSpy.mockRestore();
});
it('should throw clear errors when composing missing permission points', () => {
expect(() => permissionService.composePermission(['missing.permission'])).toThrow('[PermissionService]: missing.permission permissionPoint does not exist!');
expect(() => permissionService.composePermission$(['missing.permission'])).toThrow('[PermissionService]: missing.permission permissionPoint does not exist!');
});
});
@@ -1,63 +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 { Injector } from '../../common/di';
import { describe, expect, it, vi } from 'vitest';
import { UniverInstanceType } from '../../common/unit';
import { Univer } from '../../univer';
import { Plugin } from '../plugin/plugin.service';
describe('plugin version check', () => {
it('should allow registering plugin with same version as core', () => {
class SameVersionPlugin extends Plugin {
static override pluginName = 'same-version-plugin';
static override packageName = '@univerjs/same-version-plugin';
static override version = Plugin.version;
static override type = UniverInstanceType.UNIVER_SHEET;
protected override _injector!: Injector;
}
const univer = new Univer();
expect(() => univer.registerPlugin(SameVersionPlugin)).not.toThrow();
});
it('should log error with package name when plugin version mismatches', () => {
class MismatchVersionPlugin extends Plugin {
static override pluginName = 'mismatch-version-plugin';
static override packageName = '@univerjs/mismatch-version-plugin';
static override version = '__MISMATCH_VERSION__';
static override type = UniverInstanceType.UNIVER_SHEET;
protected override _injector!: Injector;
}
const univer = new Univer();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
expect(() => univer.registerPlugin(MismatchVersionPlugin)).not.toThrow();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('[PluginService]'),
expect.stringContaining('Plugin version mismatch.')
);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('[PluginService]'),
expect.stringContaining('package: "@univerjs/mismatch-version-plugin"')
);
errorSpy.mockRestore();
});
});
@@ -1,437 +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 { IDocumentData } from '../../types/interfaces';
import type { Univer } from '../../univer';
import type { IResources } from '../resource-manager/type';
import { BehaviorSubject } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '../../common/const';
import { UnitModel, UniverInstanceType } from '../../common/unit';
import { IUniverInstanceService } from '../instance/instance.service';
import { IResourceLoaderService } from '../resource-loader/type';
import { IResourceManagerService } from '../resource-manager/type';
import { createTestBed } from './create-test-bed';
function createDocData(id: string, resources?: NonNullable<IDocumentData['resources']>): Partial<IDocumentData> {
return {
id,
resources,
body: {
dataStream: 'Hello\r\n',
},
documentStyle: {
pageSize: { width: 100, height: 100 },
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
},
};
}
interface ITestSlideData {
id: string;
name?: string;
resources?: IResources;
}
interface ITestBoardData {
id: string;
name?: string;
resources?: IResources;
}
class MockSlideUnit extends UnitModel<ITestSlideData> {
override type = UniverInstanceType.UNIVER_SLIDE;
override name$ = new BehaviorSubject('');
private readonly _snapshot: ITestSlideData;
constructor(snapshot: Partial<ITestSlideData> = {}) {
super();
this._snapshot = {
id: 'slide-resource',
name: '',
...snapshot,
};
this.name$.next(this._snapshot.name ?? '');
}
override getUnitId(): string {
return this._snapshot.id;
}
override setName(name: string): void {
this._snapshot.name = name;
this.name$.next(name);
}
override getSnapshot(): ITestSlideData {
return this._snapshot;
}
override getRev(): number {
return 1;
}
override incrementRev(): void { }
override setRev(): void { }
}
class MockBoardUnit extends UnitModel<ITestBoardData, UniverInstanceType.UNIVER_BOARD> {
override readonly type = UniverInstanceType.UNIVER_BOARD;
override name$ = new BehaviorSubject('');
private readonly _snapshot: ITestBoardData;
constructor(snapshot: Partial<ITestBoardData> = {}) {
super();
this._snapshot = {
id: 'board-resource',
name: '',
...snapshot,
};
this.name$.next(this._snapshot.name ?? '');
}
override getUnitId(): string {
return this._snapshot.id;
}
override setName(name: string): void {
this._snapshot.name = name;
this.name$.next(name);
}
override getSnapshot(): ITestBoardData {
return this._snapshot;
}
override getRev(): number {
return 1;
}
override incrementRev(): void { }
override setRev(): void { }
}
describe('Test resources service', () => {
let univer: Univer;
beforeEach(() => {
univer?.dispose();
const instance = createTestBed();
univer = instance.univer;
});
it('test register resources', () => {
const resourceManagerService = univer.__getInjector().get(IResourceManagerService);
const resourceLoaderService = univer.__getInjector().get(IResourceLoaderService);
const pluginName = 'SHEET_test_PLUGIN';
const model: Record<string, unknown> = {};
resourceManagerService.registerPluginResource({
pluginName,
businesses: [UniverInstanceType.UNIVER_SHEET],
onLoad: () => { },
onUnLoad: () => { },
toJson: () => JSON.stringify(model),
parseJson: (bytes) => JSON.parse(bytes),
});
const snapshot = resourceLoaderService.saveUnit('test');
const resource = snapshot?.resources.find((item) => item.name === pluginName);
expect(!!resource).toBeTruthy();
expect(resource?.data).toBe(JSON.stringify(model));
model.a = 123;
const snapshotRev1 = resourceLoaderService.saveUnit('test');
const resourceRev1 = snapshotRev1?.resources.find((item) => item.name === pluginName);
expect(resourceRev1?.data).toBe(JSON.stringify(model));
});
it('test resources load', () => {
const resourceManagerService = univer.__getInjector().get(IResourceManagerService);
const pluginName = 'SHEET_test_PLUGIN';
const model: Record<string, unknown> = {};
let result = '';
resourceManagerService.registerPluginResource({
pluginName,
businesses: [UniverInstanceType.UNIVER_SHEET],
onLoad: (_unitId, resource) => { result = resource; },
onUnLoad: () => { },
toJson: () => JSON.stringify(model),
parseJson: (bytes) => JSON.parse(bytes),
});
expect(result).toEqual({ a: 123 });
});
it('should load and unload workbook/doc resources through the real unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const univerInstanceService = injector.get(IUniverInstanceService);
const loads: Array<[string, string]> = [];
const unloads: string[] = [];
resourceManagerService.registerPluginResource({
pluginName: 'DOC_test_PLUGIN',
businesses: [UniverInstanceType.UNIVER_DOC],
onLoad: (unitId, model: { kind: string }) => loads.push([unitId, model.kind]),
onUnLoad: (unitId) => unloads.push(unitId),
toJson: (unitId) => JSON.stringify({ unitId, kind: 'saved' }),
parseJson: (bytes) => JSON.parse(bytes),
});
const doc = univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData('doc-resource', [
{ name: 'DOC_test_PLUGIN', data: '{"kind":"doc"}' },
]));
const internalDoc = univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData(DOCS_NORMAL_EDITOR_UNIT_ID_KEY, [
{ name: 'DOC_test_PLUGIN', data: '{"kind":"internal"}' },
]));
expect(loads).toContainEqual(['doc-resource', 'doc']);
expect(loads).not.toContainEqual([DOCS_NORMAL_EDITOR_UNIT_ID_KEY, 'internal']);
expect(resourceLoaderService.saveUnit('missing-unit')).toBeNull();
expect(resourceLoaderService.saveUnit<IDocumentData>('doc-resource')?.resources).toEqual([
{ name: 'DOC_test_PLUGIN', data: JSON.stringify({ unitId: 'doc-resource', kind: 'saved' }) },
]);
expect(univerInstanceService.disposeUnit(doc.getUnitId())).toBe(true);
expect(univerInstanceService.disposeUnit(internalDoc.getUnitId())).toBe(true);
expect(unloads).toEqual(expect.arrayContaining(['doc-resource', DOCS_NORMAL_EDITOR_UNIT_ID_KEY]));
});
it('should load and unload slide resources through the real unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'SLIDE_TEST_PLUGIN' as never;
const loads: Array<[string, string]> = [];
const unloads: string[] = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, MockSlideUnit as never);
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_SLIDE],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: (unitId) => unloads.push(unitId),
toJson: (unitId) => JSON.stringify({ kind: `saved:${unitId}` }),
parseJson: (bytes) => JSON.parse(bytes),
});
const slide = univer.createUnit<ITestSlideData, MockSlideUnit>(UniverInstanceType.UNIVER_SLIDE, {
id: 'slide-resource',
resources: [
{ name: pluginName, data: '{"kind":"loaded"}' },
],
});
expect(loads).toContainEqual(['slide-resource', 'loaded']);
expect(resourceLoaderService.saveUnit<ITestSlideData>('slide-resource')?.resources).toEqual([
{ name: pluginName, data: '{"kind":"saved:slide-resource"}' },
]);
expect(univerInstanceService.disposeUnit(slide.getUnitId())).toBe(true);
expect(unloads).toEqual(['slide-resource']);
});
it('should load resources for existing slide units when hooks register later', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'SLIDE_LATE_PLUGIN' as never;
const loads: Array<[string, string]> = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, MockSlideUnit as never);
univer.createUnit<ITestSlideData, MockSlideUnit>(UniverInstanceType.UNIVER_SLIDE, {
id: 'slide-late-resource',
resources: [
{ name: pluginName, data: '{"kind":"late"}' },
],
});
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_SLIDE],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
expect(loads).toEqual([['slide-late-resource', 'late']]);
});
it('should load and unload board resources through the real unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'BOARD_TEST_PLUGIN' as never;
const loads: Array<[string, string]> = [];
const unloads: string[] = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_BOARD, MockBoardUnit);
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_BOARD],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: (unitId) => unloads.push(unitId),
toJson: (unitId) => JSON.stringify({ kind: `saved:${unitId}` }),
parseJson: (bytes) => JSON.parse(bytes),
});
const board = univer.createUnit<ITestBoardData, MockBoardUnit>(UniverInstanceType.UNIVER_BOARD, {
id: 'board-resource',
resources: [
{ name: pluginName, data: '{"kind":"loaded"}' },
],
});
expect(loads).toContainEqual(['board-resource', 'loaded']);
expect(resourceLoaderService.saveUnit<ITestBoardData>('board-resource')?.resources).toEqual([
{ name: pluginName, data: '{"kind":"saved:board-resource"}' },
]);
expect(univerInstanceService.disposeUnit(board.getUnitId())).toBe(true);
expect(unloads).toEqual(['board-resource']);
});
it('should load resources for existing board units when hooks register later', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'BOARD_LATE_PLUGIN' as never;
const loads: Array<[string, string]> = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_BOARD, MockBoardUnit);
univer.createUnit<ITestBoardData, MockBoardUnit>(UniverInstanceType.UNIVER_BOARD, {
id: 'board-late-resource',
resources: [
{ name: pluginName, data: '{"kind":"late"}' },
],
});
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_BOARD],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
expect(loads).toEqual([['board-late-resource', 'late']]);
});
it('should load array-shaped slide plugin resources through the unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'SLIDE_ARRAY_PLUGIN' as never;
const loads: Array<[string, string]> = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, MockSlideUnit as never);
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_SLIDE],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
univer.createUnit<ITestSlideData, MockSlideUnit>(UniverInstanceType.UNIVER_SLIDE, {
id: 'slide-array-resource',
resources: [
{ name: pluginName, data: '{"kind":"array"}' },
],
});
expect(loads).toEqual([['slide-array-resource', 'array']]);
});
it('should load serialized object slide plugin resources through the unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'SLIDE_SERIALIZED_OBJECT_PLUGIN' as never;
const loads: Array<[string, string]> = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, MockSlideUnit as never);
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_SLIDE],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
univer.createUnit<ITestSlideData, MockSlideUnit>(UniverInstanceType.UNIVER_SLIDE, {
id: 'slide-serialized-object-resource',
resources: [
{ name: pluginName, data: '{"kind":"serialized-object"}' },
],
});
expect(loads).toEqual([['slide-serialized-object-resource', 'serialized-object']]);
});
it('should load empty persisted resource payloads when hooks are registered later', () => {
const resourceManagerService = univer.__getInjector().get(IResourceManagerService);
const onLoad = vi.fn();
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData('doc-empty-resource', [
{ name: 'DOC_EMPTY_PLUGIN', data: '' },
]));
resourceManagerService.registerPluginResource({
pluginName: 'DOC_EMPTY_PLUGIN',
businesses: [UniverInstanceType.UNIVER_DOC],
onLoad,
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => bytes ? JSON.parse(bytes) : {},
});
expect(onLoad).toHaveBeenCalledWith('doc-empty-resource', {});
});
it('should ignore malformed persisted resource payloads when hooks are registered later', () => {
const resourceManagerService = univer.__getInjector().get(IResourceManagerService);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const onLoad = vi.fn();
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData('doc-bad-resource', [
{ name: 'DOC_BAD_PLUGIN', data: '{bad json}' },
]));
resourceManagerService.registerPluginResource({
pluginName: 'DOC_BAD_PLUGIN',
businesses: [UniverInstanceType.UNIVER_DOC],
onLoad,
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
expect(onLoad).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith('Load Document{doc-bad-resource} Resources{DOC_BAD_PLUGIN} Data Error.');
errorSpy.mockRestore();
});
});
@@ -1,121 +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 { afterEach, describe, expect, it, vi } from 'vitest';
import { Injector } from '../../common/di';
import { afterInitApply } from '../../shared/after-init-apply';
import { CommandService, CommandType, ICommandService } from '../command/command.service';
import { ConfigService, IConfigService } from '../config/config.service';
import { TestConfirmService } from '../confirm/confirm.service';
import { ContextService, IContextService } from '../context/context.service';
import { ErrorService } from '../error/error.service';
import { DesktopLogService, ILogService } from '../log/log.service';
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
function createCommandInjector(): Injector {
const injector = new Injector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
return injector;
}
describe('support services and helpers', () => {
it('should emit errors through ErrorService obtained from DI and complete on dispose', () => {
const injector = new Injector([[ErrorService]]);
const service = injector.get(ErrorService);
const received: string[] = [];
let completed = false;
service.error$.subscribe({
next: (error) => received.push(error.errorKey),
complete: () => {
completed = true;
},
});
service.emit('formula.ref');
service.emit('permission.denied');
injector.dispose();
expect(received).toEqual(['formula.ref', 'permission.denied']);
expect(completed).toBe(true);
});
it('should resolve and fail through TestConfirmService obtained from DI', async () => {
const injector = new Injector([[TestConfirmService]]);
const service = injector.get(TestConfirmService<string>);
let completed = false;
service.confirmOptions$.subscribe({
complete: () => {
completed = true;
},
});
await expect(service.confirm('continue')).resolves.toBe(true);
expect(() => service.open('open')).toThrow('This is not implemented in the test service!');
expect(() => service.close('id')).toThrow('This is not implemented in the test service!');
injector.dispose();
expect(completed).toBe(true);
});
it('should resolve afterInitApply on mutation execution before fallback timer', async () => {
vi.useFakeTimers();
const injector = createCommandInjector();
const commandService = injector.get(ICommandService);
commandService.registerCommand({
id: 'support.mutation',
type: CommandType.MUTATION,
handler: () => true,
});
const pending = afterInitApply(commandService);
await commandService.executeCommand('support.mutation');
await vi.advanceTimersByTimeAsync(16);
await expect(pending).resolves.toBeUndefined();
injector.dispose();
});
it('should resolve afterInitApply through the fallback timer when no mutation runs', async () => {
vi.useFakeTimers();
const injector = createCommandInjector();
const commandService = injector.get(ICommandService);
let settled = false;
const pending = afterInitApply(commandService).then(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(320);
await pending;
expect(settled).toBe(true);
injector.dispose();
});
});
@@ -1,109 +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 { Univer } from '../../univer';
import { beforeEach, describe, expect, it } from 'vitest';
import { ThemeService } from '../theme/theme.service';
import { createTestBed } from './create-test-bed';
describe('Test theme service', () => {
let univer: Univer;
beforeEach(() => {
univer?.dispose();
const instance = createTestBed();
univer = instance.univer;
});
beforeEach(() => {
univer?.dispose();
const instance = createTestBed();
univer = instance.univer;
});
it('should get default theme', () => {
const themeService = univer.__getInjector().get(ThemeService);
expect(themeService.getCurrentTheme()).toBeDefined();
});
it('should set and get theme', () => {
const themeService = univer.__getInjector().get(ThemeService);
const oldTheme = themeService.getCurrentTheme();
const theme = {
...oldTheme,
primary: {
...oldTheme.primary,
600: '#123456',
},
};
themeService.setTheme(theme);
expect(themeService.getCurrentTheme().primary[600]).toBe('#123456');
});
it('should set and get dark mode', () => {
const themeService = univer.__getInjector().get(ThemeService);
themeService.setDarkMode(true);
expect(themeService.darkMode).toBe(true);
themeService.setDarkMode(false);
expect(themeService.darkMode).toBe(false);
});
it('should validate theme color', () => {
const themeService = univer.__getInjector().get(ThemeService);
expect(themeService.isValidThemeColor('primary.600')).toBe(true);
expect(themeService.isValidThemeColor('notexist')).toBe(false);
});
it('should get color from theme', () => {
const themeService = univer.__getInjector().get(ThemeService);
const oldTheme = themeService.getCurrentTheme();
const theme = {
...oldTheme,
primary: {
...oldTheme.primary,
600: '#abcdef',
},
};
themeService.setTheme(theme);
expect(themeService.getColorFromTheme('primary.600')).toBe('#abcdef');
});
it('should get semantic highlight background token from theme', () => {
const themeService = univer.__getInjector().get(ThemeService);
const token = themeService.getColorFromTheme<{ color: string; alpha: number }>('highlight.background.1');
expect(token).toEqual({ color: 'purple.500', alpha: 0.3 });
expect(themeService.getColorFromTheme(token.color)).toBe('#9061F9');
});
it('should tap cached theme color', () => {
const themeService = univer.__getInjector().get(ThemeService);
const oldTheme = themeService.getCurrentTheme();
const theme = {
...oldTheme,
primary: {
...oldTheme.primary,
600: '#abcdef',
},
};
themeService.setTheme(theme);
expect(themeService.isValidThemeColor('primary.600')).toBe(true);
expect(themeService.isValidThemeColor('primary.600')).toBe(true); // Should hit the cache
expect(themeService.getColorFromTheme('primary.600')).toBe('#abcdef');
expect(themeService.getColorFromTheme('primary.600')).toBe('#abcdef'); // Should hit the cache
});
});
@@ -14,49 +14,214 @@
* limitations under the License.
*/
import { UnitObject } from '@univerjs/protocol';
import { beforeEach, describe, expect, it } from 'vitest';
import { Injector } from '../../../common/di';
import { DesktopLogService, ILogService } from '../../log/log.service';
import { ResourceManagerService } from '../../resource-manager/resource-manager.service';
import { IResourceManagerService } from '../../resource-manager/type';
import type { Univer } from '../../../univer';
import { UnitAction, UnitObject, UnitRole } from '@univerjs/protocol';
import { BehaviorSubject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { UnitModel, UniverInstanceType } from '../../../common/unit';
import { createTestBed } from '../../__tests__/create-test-bed';
import { IUniverInstanceService } from '../../instance/instance.service';
import { IResourceLoaderService } from '../../resource-loader/type';
import { UserManagerService } from '../../user-manager/user-manager.service';
import { AuthzIoLocalService } from '../authz-io-local.service';
import { IAuthzIoService } from '../type';
interface ITestBoardData {
id: string;
name?: string;
resources?: Array<{ name: string; data: string }>;
}
class MockBoardUnit extends UnitModel<ITestBoardData, UniverInstanceType.UNIVER_BOARD> {
override readonly type = UniverInstanceType.UNIVER_BOARD;
override name$ = new BehaviorSubject('');
private readonly _snapshot: ITestBoardData;
constructor(snapshot: Partial<ITestBoardData> = {}) {
super();
this._snapshot = {
id: 'board-resource',
name: '',
...snapshot,
};
this.name$.next(this._snapshot.name ?? '');
}
override getUnitId(): string {
return this._snapshot.id;
}
override setName(name: string): void {
this._snapshot.name = name;
this.name$.next(name);
}
override getSnapshot(): ITestBoardData {
return this._snapshot;
}
override getRev(): number {
return 1;
}
override incrementRev(): void { }
override setRev(): void { }
}
describe('AuthzIoLocalService', () => {
let service: AuthzIoLocalService;
let univer: Univer;
let unitId: string;
beforeEach(() => {
const injector = new Injector();
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IResourceManagerService, { useClass: ResourceManagerService }]);
injector.add([UserManagerService]);
injector.add([AuthzIoLocalService]);
service = injector.get(AuthzIoLocalService);
const instance = createTestBed();
univer = instance.univer;
unitId = instance.unitId;
});
it('creates range permission objects and evaluates default owner actions', async () => {
const objectID = await service.create({
objectType: UnitObject.SelectRange,
selectRangeObject: { unitID: 'book-1', name: 'Protected range' },
} as never);
afterEach(() => {
univer.dispose();
});
await expect(service.allowed({ unitID: 'book-1', objectID, actions: [6, 16] } as never)).resolves.toEqual([
{ action: 6, allowed: true },
{ action: 16, allowed: true },
it('should persist and reload permission resources through a real workbook lifecycle', async () => {
const injector = univer.__getInjector();
const authzIoService = injector.get(IAuthzIoService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const userManagerService = injector.get(UserManagerService);
userManagerService.setCurrentUser({
userID: 'Owner_real-user',
name: 'Owner User',
avatar: '',
});
const objectID = await authzIoService.create({
objectType: UnitObject.SelectRange,
selectRangeObject: {
unitID: unitId,
name: 'Protected range',
collaborators: [],
scope: undefined,
},
});
expect(await authzIoService.allowed({ unitID: unitId, objectID, objectType: UnitObject.SelectRange, actions: [UnitAction.Edit] })).toEqual([
{ action: UnitAction.Edit, allowed: true },
]);
await authzIoService.update({
objectType: UnitObject.SelectRange,
objectID,
unitID: unitId,
share: undefined,
name: 'Protected range',
strategies: [{ action: UnitAction.Edit, role: UnitRole.Reader }],
scope: undefined,
collaborators: undefined,
});
expect(await authzIoService.allowed({ unitID: unitId, objectID, objectType: UnitObject.SelectRange, actions: [UnitAction.Edit] })).toEqual([
{ action: UnitAction.Edit, allowed: false },
]);
const listed = await authzIoService.list({
unitID: unitId,
objectIDs: [objectID],
actions: [UnitAction.Edit, UnitAction.View],
});
expect(listed).toHaveLength(1);
expect(listed[0].name).toBe('Protected range');
expect(listed[0].actions).toContainEqual({ action: UnitAction.Edit, allowed: false });
const batched = await authzIoService.batchAllowed([
{ unitID: unitId, objectID, objectType: UnitObject.SelectRange, actions: [UnitAction.Edit] },
{ unitID: unitId, objectID: 'missing', objectType: UnitObject.SelectRange, actions: [UnitAction.View] },
]);
expect(batched[0].actions).toEqual([{ action: UnitAction.Edit, allowed: false }]);
expect(batched[1].actions).toEqual([{ action: UnitAction.View, allowed: true }]);
expect(await authzIoService.listCollaborators({ unitID: unitId, objectID })).toEqual([]);
expect(await authzIoService.listRoles({ objectType: UnitObject.SelectRange })).toEqual({ roles: [], actions: [] });
await expect(authzIoService.createCollaborator({ unitID: unitId, objectID } as never)).resolves.toBeUndefined();
await expect(authzIoService.updateCollaborator({ unitID: unitId, objectID } as never)).resolves.toBeUndefined();
await expect(authzIoService.deleteCollaborator({ unitID: unitId, objectID } as never)).resolves.toBeUndefined();
await expect(authzIoService.putCollaborators({ unitID: unitId, objectID, collaborators: [] })).resolves.toBeUndefined();
const snapshot = resourceLoaderService.saveUnit(unitId);
const authzResource = snapshot?.resources.find((resource) => resource.name === 'SHEET_AuthzIoMockService_PLUGIN');
expect(authzResource?.data).toContain(objectID);
injector.get(IUniverInstanceService).disposeUnit(unitId);
const unloaded = await authzIoService.list({
unitID: unitId,
objectIDs: [objectID],
actions: [UnitAction.Edit],
});
expect(unloaded[0].name).toBe('');
expect(unloaded[0].actions).toEqual([{ action: UnitAction.Edit, allowed: false }]);
univer.createUnit(UniverInstanceType.UNIVER_SHEET, snapshot!);
const reloaded = await authzIoService.list({
unitID: unitId,
objectIDs: [objectID],
actions: [UnitAction.Edit],
});
expect(reloaded[0].name).toBe('Protected range');
expect(reloaded[0].actions).toEqual([{ action: UnitAction.Edit, allowed: false }]);
});
it('lists created permission objects with their range metadata', async () => {
const objectID = await service.create({
objectType: UnitObject.SelectRange,
selectRangeObject: { unitID: 'book-1', name: 'Protected range' },
} as never);
it('should persist and reload permission resources for board units', async () => {
const injector = univer.__getInjector();
const authzIoService = injector.get(IAuthzIoService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const univerInstanceService = injector.get(IUniverInstanceService);
const [permissionPoint] = await service.list({ unitID: 'book-1', objectIDs: [objectID], actions: [6] } as never);
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_BOARD, MockBoardUnit);
const board = univer.createUnit<ITestBoardData, MockBoardUnit>(UniverInstanceType.UNIVER_BOARD, {
id: 'board-resource',
});
const objectID = await authzIoService.create({
objectType: UnitObject.Workbook,
worksheetObject: {
unitID: board.getUnitId(),
name: 'Board permission',
collaborators: [],
strategies: [{ action: UnitAction.Edit, role: UnitRole.Owner }],
scope: undefined,
},
});
expect(permissionPoint.objectID).toBe(objectID);
expect(permissionPoint.name).toBe('Protected range');
expect(permissionPoint.unitID).toBe('book-1');
const snapshot = resourceLoaderService.saveUnit<ITestBoardData>(board.getUnitId());
const authzResource = snapshot?.resources?.find((resource) => resource.name === 'SHEET_AuthzIoMockService_PLUGIN');
expect(authzResource?.data).toContain(objectID);
expect(univerInstanceService.disposeUnit(board.getUnitId())).toBe(true);
const unloaded = await authzIoService.list({
unitID: board.getUnitId(),
objectIDs: [objectID],
actions: [UnitAction.Edit],
});
expect(unloaded[0].name).toBe('');
expect(unloaded[0].actions).toEqual([{ action: UnitAction.Edit, allowed: true }]);
univer.createUnit<ITestBoardData, MockBoardUnit>(UniverInstanceType.UNIVER_BOARD, snapshot!);
const reloaded = await authzIoService.list({
unitID: board.getUnitId(),
objectIDs: [objectID],
actions: [UnitAction.Edit],
});
expect(reloaded[0].name).toBe('Board permission');
expect(reloaded[0].actions).toEqual([{ action: UnitAction.Edit, allowed: true }]);
});
});
@@ -36,4 +36,15 @@ describe('ErrorService', () => {
expect(errors).toEqual(['permission-denied', 'network-timeout']);
});
it('completes the error stream when its injector is disposed', () => {
const injector = new Injector([[ErrorService]]);
const disposableService = injector.get(ErrorService);
let completed = false;
disposableService.error$.subscribe({ complete: () => completed = true });
injector.dispose();
expect(completed).toBe(true);
});
});
@@ -14,49 +14,118 @@
* limitations under the License.
*/
import type { Univer } from '../../../univer';
import type { IPermissionPoint } from '../type';
import { UnitAction, UnitObject } from '@univerjs/protocol';
import { beforeEach, describe, expect, it } from 'vitest';
import { Injector } from '../../../common/di';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createTestBed } from '../../__tests__/create-test-bed';
import { PermissionService } from '../permission.service';
import { PermissionStatus } from '../type';
describe('PermissionService', () => {
let service: PermissionService;
class TestPermissionPoint implements IPermissionPoint {
type: UnitObject.Workbook;
id;
status: PermissionStatus.INIT;
subType: UnitAction.Copy;
value: boolean = false;
const permissionPoint = (id: string, value: boolean, status: PermissionStatus): IPermissionPoint => ({
id,
value,
status,
type: UnitObject.Workbook,
subType: UnitAction.Edit,
});
constructor(id: string) {
this.id = `${UnitObject.Workbook}.${UnitAction.CreateSheet}.${id}` as string;
}
}
describe('PermissionService', () => {
let univer: Univer;
let permissionService: PermissionService;
beforeEach(() => {
const injector = new Injector();
injector.add([PermissionService]);
service = injector.get(PermissionService);
univer?.dispose();
const instance = createTestBed([[PermissionService]]);
univer = instance.univer;
permissionService = instance.get(PermissionService);
});
it('updates a permission point and composes the latest decision set', () => {
it('test get permission from permissionService', () => {
const point = new TestPermissionPoint('test');
permissionService.addPermissionPoint(point);
const result = permissionService.getPermissionPoint(point.id);
expect(result).toBe(point);
});
it('test get permission$ from permissionService', async () => {
const point = new TestPermissionPoint('test');
const initValue = point.value;
permissionService.addPermissionPoint(point);
const result$ = permissionService.getPermissionPoint$(point.id)!;
permissionService.updatePermissionPoint(point.id, !initValue);
const v = await firstValueFrom(result$);
expect(v.value).toBe(!initValue);
});
it('test compose permission', () => {
const point1 = new TestPermissionPoint('test1');
const point2 = new TestPermissionPoint('test2');
permissionService.addPermissionPoint(point1);
permissionService.addPermissionPoint(point2);
const result = permissionService.composePermission([point1.id, point2.id]);
expect(result).toEqual([point1, point2]);
});
it('test compose permission$', async () => {
const point1 = new TestPermissionPoint('test1');
const point2 = new TestPermissionPoint('test2');
permissionService.addPermissionPoint(point1);
permissionService.addPermissionPoint(point2);
const result$ = permissionService.composePermission$([point1.id, point2.id]);
permissionService.updatePermissionPoint(point2.id, !point2.value);
const v = await firstValueFrom(result$);
expect(v).toEqual([point1, point2]);
});
it('should manage permission updates, duplicates and visibility flags', () => {
const point = new TestPermissionPoint('subject');
const pointSubject = new BehaviorSubject<IPermissionPoint<boolean>>(point);
const updates: string[] = [];
service.permissionPointUpdate$.subscribe((point) => updates.push(`${point.id}:${String(point.value)}`));
const warningSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const subscription = permissionService.permissionPointUpdate$.subscribe((permission) => {
updates.push(`${permission.id}:${String(permission.value)}`);
});
expect(service.addPermissionPoint(permissionPoint('sheet-edit', false, PermissionStatus.INIT))).toBe(true);
expect(service.addPermissionPoint(permissionPoint('sheet-view', true, PermissionStatus.DONE))).toBe(true);
expect(permissionService.getShowComponents()).toBe(true);
permissionService.setShowComponents(false);
expect(permissionService.getShowComponents()).toBe(false);
service.updatePermissionPoint('sheet-edit', true);
expect(permissionService.addPermissionPoint(pointSubject)).toBe(true);
expect(permissionService.addPermissionPoint(point)).toBe(false);
expect(service.composePermission(['sheet-edit', 'sheet-view']).map((point) => point.value)).toEqual([true, true]);
expect(service.getPermissionPoint('sheet-edit')?.status).toBe(PermissionStatus.DONE);
expect(updates).toEqual(['sheet-edit:false', 'sheet-view:true', 'sheet-edit:true']);
permissionService.updatePermissionPoint(point.id, true);
expect(permissionService.getPermissionPoint(point.id)).toMatchObject({
value: true,
status: PermissionStatus.DONE,
});
expect(updates).toEqual([
`${point.id}:false`,
`${point.id}:true`,
]);
const snapshot = permissionService.getAllPermissionPoint();
snapshot.clear();
expect(permissionService.getAllPermissionPoint().size).toBe(1);
permissionService.deletePermissionPoint(point.id);
expect(permissionService.getPermissionPoint(point.id)).toBeUndefined();
permissionService.clearPermissionMap();
expect(permissionService.getAllPermissionPoint().size).toBe(0);
subscription.unsubscribe();
warningSpy.mockRestore();
});
it('removes permission decisions that no longer apply to the current document', () => {
service.addPermissionPoint(permissionPoint('range-lock', true, PermissionStatus.DONE));
service.deletePermissionPoint('range-lock');
expect(service.getPermissionPoint('range-lock')).toBeUndefined();
expect(() => service.composePermission(['range-lock'])).toThrow('[PermissionService]: range-lock permissionPoint does not exist!');
it('should throw clear errors when composing missing permission points', () => {
expect(() => permissionService.composePermission(['missing.permission'])).toThrow('[PermissionService]: missing.permission permissionPoint does not exist!');
expect(() => permissionService.composePermission$(['missing.permission'])).toThrow('[PermissionService]: missing.permission permissionPoint does not exist!');
});
});
@@ -519,4 +519,56 @@ describe('PluginService', () => {
expect(constructed).toEqual(['started-sheet-plugin']);
univer.dispose();
});
it('should accept plugins built with the current core version', () => {
class SameVersionPlugin extends Plugin {
static override pluginName = 'same-version-plugin';
static override packageName = '@univerjs/same-version-plugin';
static override version = Plugin.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
}
const univer = new Univer();
expect(() => univer.registerPlugin(SameVersionPlugin)).not.toThrow();
univer.dispose();
});
it('should report the package name when a plugin version does not match core', () => {
class MismatchVersionPlugin extends Plugin {
static override pluginName = 'mismatch-version-plugin';
static override packageName = '@univerjs/mismatch-version-plugin';
static override version = '__MISMATCH_VERSION__';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
}
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const univer = new Univer();
expect(() => univer.registerPlugin(MismatchVersionPlugin)).not.toThrow();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('[PluginService]'),
expect.stringContaining('Plugin version mismatch.')
);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('[PluginService]'),
expect.stringContaining('package: "@univerjs/mismatch-version-plugin"')
);
univer.dispose();
});
});
@@ -14,130 +14,502 @@
* limitations under the License.
*/
import { Subject } from 'rxjs';
import type { IDocumentData } from '../../../types/interfaces';
import type { Univer } from '../../../univer';
import type { IResources } from '../../resource-manager/type';
import { BehaviorSubject } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Injector } from '../../../common/di';
import { UniverInstanceType } from '../../../common/unit';
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '../../../common/const';
import { UnitModel, UniverInstanceType } from '../../../common/unit';
import { createTestBed } from '../../__tests__/create-test-bed';
import { IUniverInstanceService } from '../../instance/instance.service';
import { IResourceManagerService } from '../../resource-manager/type';
import { ResourceLoaderService } from '../resource-loader.service';
import { IResourceLoaderService } from '../type';
function createDocData(id: string, resources?: NonNullable<IDocumentData['resources']>): Partial<IDocumentData> {
return {
id,
resources,
body: {
dataStream: 'Hello\r\n',
},
documentStyle: {
pageSize: { width: 100, height: 100 },
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
},
};
}
interface ITestSlideData {
id: string;
name?: string;
resources?: IResources;
}
interface ITestBoardData {
id: string;
name?: string;
resources?: IResources;
}
interface ITestBaseData {
id: string;
name?: string;
resources?: IResources;
}
class MockSlideUnit extends UnitModel<ITestSlideData> {
override type = UniverInstanceType.UNIVER_SLIDE;
override name$ = new BehaviorSubject('');
private readonly _snapshot: ITestSlideData;
constructor(snapshot: Partial<ITestSlideData> = {}) {
super();
this._snapshot = {
id: 'slide-resource',
name: '',
...snapshot,
};
this.name$.next(this._snapshot.name ?? '');
}
override getUnitId(): string {
return this._snapshot.id;
}
override setName(name: string): void {
this._snapshot.name = name;
this.name$.next(name);
}
override getSnapshot(): ITestSlideData {
return this._snapshot;
}
override getRev(): number {
return 1;
}
override incrementRev(): void { }
override setRev(): void { }
}
class MockBoardUnit extends UnitModel<ITestBoardData, UniverInstanceType.UNIVER_BOARD> {
override readonly type = UniverInstanceType.UNIVER_BOARD;
override name$ = new BehaviorSubject('');
private readonly _snapshot: ITestBoardData;
constructor(snapshot: Partial<ITestBoardData> = {}) {
super();
this._snapshot = {
id: 'board-resource',
name: '',
...snapshot,
};
this.name$.next(this._snapshot.name ?? '');
}
override getUnitId(): string {
return this._snapshot.id;
}
override setName(name: string): void {
this._snapshot.name = name;
this.name$.next(name);
}
override getSnapshot(): ITestBoardData {
return this._snapshot;
}
override getRev(): number {
return 1;
}
override incrementRev(): void { }
override setRev(): void { }
}
class MockBaseUnit extends UnitModel<ITestBaseData, UniverInstanceType.UNIVER_BASE> {
override readonly type = UniverInstanceType.UNIVER_BASE;
override name$ = new BehaviorSubject('');
private readonly _snapshot: ITestBaseData;
constructor(snapshot: Partial<ITestBaseData> = {}) {
super();
this._snapshot = {
id: 'base-resource',
name: '',
...snapshot,
};
this.name$.next(this._snapshot.name ?? '');
}
override getUnitId(): string {
return this._snapshot.id;
}
override setName(name: string): void {
this._snapshot.name = name;
this.name$.next(name);
}
override getSnapshot(): ITestBaseData {
return this._snapshot;
}
override getRev(): number {
return 1;
}
override incrementRev(): void { }
override setRev(): void { }
}
describe('ResourceLoaderService', () => {
let service: ResourceLoaderService;
let register$: Subject<never>;
let sheetAdded$: Subject<unknown>;
let docAdded$: Subject<unknown>;
let slideAdded$: Subject<unknown>;
let baseAdded$: Subject<unknown>;
let sheetDisposed$: Subject<unknown>;
let docDisposed$: Subject<unknown>;
let baseDisposed$: Subject<unknown>;
let slideDisposed$: Subject<unknown>;
let resourceManagerService: {
getAllResourceHooks: ReturnType<typeof vi.fn>;
register$: Subject<never>;
loadResources: ReturnType<typeof vi.fn>;
unloadResources: ReturnType<typeof vi.fn>;
getResources: ReturnType<typeof vi.fn>;
};
let univerInstanceService: {
getAllUnitsForType: ReturnType<typeof vi.fn>;
getTypeOfUnitAdded$: ReturnType<typeof vi.fn>;
getTypeOfUnitDisposed$: ReturnType<typeof vi.fn>;
getUnit: ReturnType<typeof vi.fn>;
};
let univer: Univer;
beforeEach(() => {
register$ = new Subject();
sheetAdded$ = new Subject();
docAdded$ = new Subject();
slideAdded$ = new Subject();
baseAdded$ = new Subject();
sheetDisposed$ = new Subject();
docDisposed$ = new Subject();
baseDisposed$ = new Subject();
slideDisposed$ = new Subject();
resourceManagerService = {
getAllResourceHooks: vi.fn(() => []),
register$,
loadResources: vi.fn(),
unloadResources: vi.fn(),
getResources: vi.fn(() => [{ name: 'plugin', data: '{}' }]),
};
univerInstanceService = {
getAllUnitsForType: vi.fn(() => []),
getTypeOfUnitAdded$: vi.fn((type) => {
if (type === UniverInstanceType.UNIVER_SHEET) return sheetAdded$;
if (type === UniverInstanceType.UNIVER_DOC) return docAdded$;
if (type === UniverInstanceType.UNIVER_BASE) return baseAdded$;
return slideAdded$;
}),
getTypeOfUnitDisposed$: vi.fn((type) => {
if (type === UniverInstanceType.UNIVER_SHEET) return sheetDisposed$;
if (type === UniverInstanceType.UNIVER_DOC) return docDisposed$;
if (type === UniverInstanceType.UNIVER_BASE) return baseDisposed$;
return slideDisposed$;
}),
getUnit: vi.fn(),
};
class TestResourceManagerService {
getAllResourceHooks = resourceManagerService.getAllResourceHooks;
register$ = resourceManagerService.register$;
loadResources = resourceManagerService.loadResources;
unloadResources = resourceManagerService.unloadResources;
getResources = resourceManagerService.getResources;
}
class TestUniverInstanceService {
getAllUnitsForType = univerInstanceService.getAllUnitsForType;
getTypeOfUnitAdded$ = univerInstanceService.getTypeOfUnitAdded$;
getTypeOfUnitDisposed$ = univerInstanceService.getTypeOfUnitDisposed$;
getUnit = univerInstanceService.getUnit;
}
const injector = new Injector();
injector.add([IResourceManagerService, { useClass: TestResourceManagerService as never }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([ResourceLoaderService]);
service = injector.get(ResourceLoaderService);
univer?.dispose();
const instance = createTestBed();
univer = instance.univer;
});
it('loads resources when a workbook unit is added and unloads them when disposed', () => {
const workbook = {
getUnitId: () => 'book-1',
getSnapshot: () => ({ resources: [{ name: 'sheet-plugin', data: '{}' }] }),
};
sheetAdded$.next({ unit: workbook });
sheetDisposed$.next(workbook);
expect(resourceManagerService.loadResources).toHaveBeenCalledWith('book-1', [{ name: 'sheet-plugin', data: '{}' }]);
expect(resourceManagerService.unloadResources).toHaveBeenCalledWith('book-1', UniverInstanceType.UNIVER_SHEET);
it('test register resources', () => {
const resourceManagerService = univer.__getInjector().get(IResourceManagerService);
const resourceLoaderService = univer.__getInjector().get(IResourceLoaderService);
const pluginName = 'SHEET_test_PLUGIN';
const model: Record<string, unknown> = {};
resourceManagerService.registerPluginResource({
pluginName,
businesses: [UniverInstanceType.UNIVER_SHEET],
onLoad: () => { },
onUnLoad: () => { },
toJson: () => JSON.stringify(model),
parseJson: (bytes) => JSON.parse(bytes),
});
const snapshot = resourceLoaderService.saveUnit('test');
const resource = snapshot?.resources.find((item) => item.name === pluginName);
expect(!!resource).toBeTruthy();
expect(resource?.data).toBe(JSON.stringify(model));
model.a = 123;
const snapshotRev1 = resourceLoaderService.saveUnit('test');
const resourceRev1 = snapshotRev1?.resources.find((item) => item.name === pluginName);
expect(resourceRev1?.data).toBe(JSON.stringify(model));
});
it('loads resources when a base unit is added and unloads them when disposed', () => {
const base = {
getUnitId: () => 'base-1',
getSnapshot: () => ({ resources: [{ name: 'base-plugin', data: '{}' }] }),
};
baseAdded$.next({ unit: base });
baseDisposed$.next(base);
expect(resourceManagerService.loadResources).toHaveBeenCalledWith('base-1', [{ name: 'base-plugin', data: '{}' }]);
expect(resourceManagerService.unloadResources).toHaveBeenCalledWith('base-1', UniverInstanceType.UNIVER_BASE);
it('test resources load', () => {
const resourceManagerService = univer.__getInjector().get(IResourceManagerService);
const pluginName = 'SHEET_test_PLUGIN';
const model: Record<string, unknown> = {};
let result = '';
resourceManagerService.registerPluginResource({
pluginName,
businesses: [UniverInstanceType.UNIVER_SHEET],
onLoad: (_unitId, resource) => { result = resource; },
onUnLoad: () => { },
toJson: () => JSON.stringify(model),
parseJson: (bytes) => JSON.parse(bytes),
});
expect(result).toEqual({ a: 123 });
});
it('saves a unit snapshot with current plugin resources', () => {
univerInstanceService.getUnit.mockReturnValue({
type: UniverInstanceType.UNIVER_SHEET,
getSnapshot: () => ({ id: 'book-1', sheets: {} }),
it('should load and unload workbook/doc resources through the real unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const univerInstanceService = injector.get(IUniverInstanceService);
const loads: Array<[string, string]> = [];
const unloads: string[] = [];
resourceManagerService.registerPluginResource({
pluginName: 'DOC_test_PLUGIN',
businesses: [UniverInstanceType.UNIVER_DOC],
onLoad: (unitId, model: { kind: string }) => loads.push([unitId, model.kind]),
onUnLoad: (unitId) => unloads.push(unitId),
toJson: (unitId) => JSON.stringify({ unitId, kind: 'saved' }),
parseJson: (bytes) => JSON.parse(bytes),
});
expect(service.saveUnit('book-1')).toEqual({
id: 'book-1',
sheets: {},
resources: [{ name: 'plugin', data: '{}' }],
const doc = univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData('doc-resource', [
{ name: 'DOC_test_PLUGIN', data: '{"kind":"doc"}' },
]));
const internalDoc = univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData(DOCS_NORMAL_EDITOR_UNIT_ID_KEY, [
{ name: 'DOC_test_PLUGIN', data: '{"kind":"internal"}' },
]));
expect(loads).toContainEqual(['doc-resource', 'doc']);
expect(loads).not.toContainEqual([DOCS_NORMAL_EDITOR_UNIT_ID_KEY, 'internal']);
expect(resourceLoaderService.saveUnit('missing-unit')).toBeNull();
expect(resourceLoaderService.saveUnit<IDocumentData>('doc-resource')?.resources).toEqual([
{ name: 'DOC_test_PLUGIN', data: JSON.stringify({ unitId: 'doc-resource', kind: 'saved' }) },
]);
expect(univerInstanceService.disposeUnit(doc.getUnitId())).toBe(true);
expect(univerInstanceService.disposeUnit(internalDoc.getUnitId())).toBe(true);
expect(unloads).toEqual(expect.arrayContaining(['doc-resource', DOCS_NORMAL_EDITOR_UNIT_ID_KEY]));
});
it('should load and unload slide resources through the real unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'SLIDE_TEST_PLUGIN' as never;
const loads: Array<[string, string]> = [];
const unloads: string[] = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, MockSlideUnit as never);
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_SLIDE],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: (unitId) => unloads.push(unitId),
toJson: (unitId) => JSON.stringify({ kind: `saved:${unitId}` }),
parseJson: (bytes) => JSON.parse(bytes),
});
const slide = univer.createUnit<ITestSlideData, MockSlideUnit>(UniverInstanceType.UNIVER_SLIDE, {
id: 'slide-resource',
resources: [
{ name: pluginName, data: '{"kind":"loaded"}' },
],
});
expect(loads).toContainEqual(['slide-resource', 'loaded']);
expect(resourceLoaderService.saveUnit<ITestSlideData>('slide-resource')?.resources).toEqual([
{ name: pluginName, data: '{"kind":"saved:slide-resource"}' },
]);
expect(univerInstanceService.disposeUnit(slide.getUnitId())).toBe(true);
expect(unloads).toEqual(['slide-resource']);
});
it('should load resources for existing slide units when hooks register later', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'SLIDE_LATE_PLUGIN' as never;
const loads: Array<[string, string]> = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, MockSlideUnit as never);
univer.createUnit<ITestSlideData, MockSlideUnit>(UniverInstanceType.UNIVER_SLIDE, {
id: 'slide-late-resource',
resources: [
{ name: pluginName, data: '{"kind":"late"}' },
],
});
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_SLIDE],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
expect(loads).toEqual([['slide-late-resource', 'late']]);
});
it('should load and unload board resources through the real unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'BOARD_TEST_PLUGIN' as never;
const loads: Array<[string, string]> = [];
const unloads: string[] = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_BOARD, MockBoardUnit);
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_BOARD],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: (unitId) => unloads.push(unitId),
toJson: (unitId) => JSON.stringify({ kind: `saved:${unitId}` }),
parseJson: (bytes) => JSON.parse(bytes),
});
const board = univer.createUnit<ITestBoardData, MockBoardUnit>(UniverInstanceType.UNIVER_BOARD, {
id: 'board-resource',
resources: [
{ name: pluginName, data: '{"kind":"loaded"}' },
],
});
expect(loads).toContainEqual(['board-resource', 'loaded']);
expect(resourceLoaderService.saveUnit<ITestBoardData>('board-resource')?.resources).toEqual([
{ name: pluginName, data: '{"kind":"saved:board-resource"}' },
]);
expect(univerInstanceService.disposeUnit(board.getUnitId())).toBe(true);
expect(unloads).toEqual(['board-resource']);
});
it('should load and unload base resources through the real unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const resourceLoaderService = injector.get(IResourceLoaderService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'BASE_TEST_PLUGIN' as never;
const loads: Array<[string, string]> = [];
const unloads: string[] = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_BASE, MockBaseUnit);
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_BASE],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: (unitId) => unloads.push(unitId),
toJson: (unitId) => JSON.stringify({ kind: `saved:${unitId}` }),
parseJson: (bytes) => JSON.parse(bytes),
});
const base = univer.createUnit<ITestBaseData, MockBaseUnit>(UniverInstanceType.UNIVER_BASE, {
id: 'base-resource',
resources: [
{ name: pluginName, data: '{"kind":"loaded"}' },
],
});
expect(loads).toEqual([['base-resource', 'loaded']]);
expect(resourceLoaderService.saveUnit<ITestBaseData>('base-resource')?.resources).toEqual([
{ name: pluginName, data: '{"kind":"saved:base-resource"}' },
]);
expect(univerInstanceService.disposeUnit(base.getUnitId())).toBe(true);
expect(unloads).toEqual(['base-resource']);
});
it('should load resources for existing board units when hooks register later', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'BOARD_LATE_PLUGIN' as never;
const loads: Array<[string, string]> = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_BOARD, MockBoardUnit);
univer.createUnit<ITestBoardData, MockBoardUnit>(UniverInstanceType.UNIVER_BOARD, {
id: 'board-late-resource',
resources: [
{ name: pluginName, data: '{"kind":"late"}' },
],
});
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_BOARD],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
expect(loads).toEqual([['board-late-resource', 'late']]);
});
it('should load array-shaped slide plugin resources through the unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'SLIDE_ARRAY_PLUGIN' as never;
const loads: Array<[string, string]> = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, MockSlideUnit as never);
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_SLIDE],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
univer.createUnit<ITestSlideData, MockSlideUnit>(UniverInstanceType.UNIVER_SLIDE, {
id: 'slide-array-resource',
resources: [
{ name: pluginName, data: '{"kind":"array"}' },
],
});
expect(loads).toEqual([['slide-array-resource', 'array']]);
});
it('should load serialized object slide plugin resources through the unit lifecycle', () => {
const injector = univer.__getInjector();
const resourceManagerService = injector.get(IResourceManagerService);
const univerInstanceService = injector.get(IUniverInstanceService);
const pluginName = 'SLIDE_SERIALIZED_OBJECT_PLUGIN' as never;
const loads: Array<[string, string]> = [];
univerInstanceService.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, MockSlideUnit as never);
resourceManagerService.registerPluginResource<{ kind: string }>({
pluginName,
businesses: [UniverInstanceType.UNIVER_SLIDE],
onLoad: (unitId, resource) => loads.push([unitId, resource.kind]),
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
univer.createUnit<ITestSlideData, MockSlideUnit>(UniverInstanceType.UNIVER_SLIDE, {
id: 'slide-serialized-object-resource',
resources: [
{ name: pluginName, data: '{"kind":"serialized-object"}' },
],
});
expect(loads).toEqual([['slide-serialized-object-resource', 'serialized-object']]);
});
it('should load empty persisted resource payloads when hooks are registered later', () => {
const resourceManagerService = univer.__getInjector().get(IResourceManagerService);
const onLoad = vi.fn();
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData('doc-empty-resource', [
{ name: 'DOC_EMPTY_PLUGIN', data: '' },
]));
resourceManagerService.registerPluginResource({
pluginName: 'DOC_EMPTY_PLUGIN',
businesses: [UniverInstanceType.UNIVER_DOC],
onLoad,
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => bytes ? JSON.parse(bytes) : {},
});
expect(onLoad).toHaveBeenCalledWith('doc-empty-resource', {});
});
it('should ignore malformed persisted resource payloads when hooks are registered later', () => {
const resourceManagerService = univer.__getInjector().get(IResourceManagerService);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const onLoad = vi.fn();
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData('doc-bad-resource', [
{ name: 'DOC_BAD_PLUGIN', data: '{bad json}' },
]));
resourceManagerService.registerPluginResource({
pluginName: 'DOC_BAD_PLUGIN',
businesses: [UniverInstanceType.UNIVER_DOC],
onLoad,
onUnLoad: () => undefined,
toJson: () => '{}',
parseJson: (bytes) => JSON.parse(bytes),
});
expect(onLoad).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith('Load Document{doc-bad-resource} Resources{DOC_BAD_PLUGIN} Data Error.');
errorSpy.mockRestore();
});
});
@@ -14,43 +14,90 @@
* limitations under the License.
*/
import { defaultTheme } from '@univerjs/themes';
import type { Univer } from '../../../univer';
import { beforeEach, describe, expect, it } from 'vitest';
import { Injector } from '../../../common/di';
import { createTestBed } from '../../__tests__/create-test-bed';
import { ThemeService } from '../theme.service';
describe('ThemeService', () => {
let service: ThemeService;
let univer: Univer;
beforeEach(() => {
const injector = new Injector();
injector.add([ThemeService]);
service = injector.get(ThemeService);
univer?.dispose();
const instance = createTestBed();
univer = instance.univer;
});
it('publishes theme and dark-mode changes used by UI renderers', () => {
const darkModes: boolean[] = [];
const primaryColors: string[] = [];
service.darkMode$.subscribe((darkMode) => darkModes.push(darkMode));
service.currentTheme$.subscribe((theme) => primaryColors.push(theme.primary[600]));
it('should get default theme', () => {
const themeService = univer.__getInjector().get(ThemeService);
expect(themeService.getCurrentTheme()).toBeDefined();
});
service.setDarkMode(true);
service.setTheme({
...defaultTheme,
it('should set and get theme', () => {
const themeService = univer.__getInjector().get(ThemeService);
const oldTheme = themeService.getCurrentTheme();
const theme = {
...oldTheme,
primary: {
...defaultTheme.primary,
...oldTheme.primary,
600: '#123456',
},
});
expect(darkModes).toEqual([false, true]);
expect(primaryColors).toEqual([defaultTheme.primary[600], '#123456']);
expect(service.getColorFromTheme('primary.600')).toBe('#123456');
};
themeService.setTheme(theme);
expect(themeService.getCurrentTheme().primary[600]).toBe('#123456');
});
it('validates only theme color tokens that exist in the active theme shape', () => {
expect(service.isValidThemeColor('primary.600')).toBe(true);
expect(service.isValidThemeColor('missing.600')).toBe(false);
expect(service.isValidThemeColor('primary.999')).toBe(false);
it('should set and get dark mode', () => {
const themeService = univer.__getInjector().get(ThemeService);
themeService.setDarkMode(true);
expect(themeService.darkMode).toBe(true);
themeService.setDarkMode(false);
expect(themeService.darkMode).toBe(false);
});
it('should validate theme color', () => {
const themeService = univer.__getInjector().get(ThemeService);
expect(themeService.isValidThemeColor('primary.600')).toBe(true);
expect(themeService.isValidThemeColor('notexist')).toBe(false);
});
it('should get color from theme', () => {
const themeService = univer.__getInjector().get(ThemeService);
const oldTheme = themeService.getCurrentTheme();
const theme = {
...oldTheme,
primary: {
...oldTheme.primary,
600: '#abcdef',
},
};
themeService.setTheme(theme);
expect(themeService.getColorFromTheme('primary.600')).toBe('#abcdef');
});
it('should get semantic highlight background token from theme', () => {
const themeService = univer.__getInjector().get(ThemeService);
const token = themeService.getColorFromTheme<{ color: string; alpha: number }>('highlight.background.1');
expect(token).toEqual({ color: 'purple.500', alpha: 0.3 });
expect(themeService.getColorFromTheme(token.color)).toBe('#9061F9');
});
it('should tap cached theme color', () => {
const themeService = univer.__getInjector().get(ThemeService);
const oldTheme = themeService.getCurrentTheme();
const theme = {
...oldTheme,
primary: {
...oldTheme.primary,
600: '#abcdef',
},
};
themeService.setTheme(theme);
expect(themeService.isValidThemeColor('primary.600')).toBe(true);
expect(themeService.isValidThemeColor('primary.600')).toBe(true); // Should hit the cache
expect(themeService.getColorFromTheme('primary.600')).toBe('#abcdef');
expect(themeService.getColorFromTheme('primary.600')).toBe('#abcdef'); // Should hit the cache
});
});
@@ -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 { afterEach, describe, expect, it, vi } from 'vitest';
import { Injector } from '../../common/di';
import { CommandService, CommandType, ICommandService } from '../../services/command/command.service';
import { ConfigService, IConfigService } from '../../services/config/config.service';
import { ContextService, IContextService } from '../../services/context/context.service';
import { DesktopLogService, ILogService } from '../../services/log/log.service';
import { afterInitApply } from '../after-init-apply';
function createCommandInjector(): Injector {
return new Injector([
[ICommandService, { useClass: CommandService }],
[ILogService, { useClass: DesktopLogService }],
[IContextService, { useClass: ContextService }],
[IConfigService, { useClass: ConfigService }],
]);
}
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('afterInitApply', () => {
it('resolves after a mutation executes before the fallback timer', async () => {
vi.useFakeTimers();
const injector = createCommandInjector();
const commandService = injector.get(ICommandService);
commandService.registerCommand({
id: 'after-init-apply.mutation',
type: CommandType.MUTATION,
handler: () => true,
});
const pending = afterInitApply(commandService);
await commandService.executeCommand('after-init-apply.mutation');
await vi.advanceTimersByTimeAsync(16);
await expect(pending).resolves.toBeUndefined();
injector.dispose();
});
it('resolves through the fallback timer when no mutation executes', async () => {
vi.useFakeTimers();
const injector = createCommandInjector();
const commandService = injector.get(ICommandService);
const pending = afterInitApply(commandService);
await vi.advanceTimersByTimeAsync(320);
await expect(pending).resolves.toBeUndefined();
injector.dispose();
});
});
@@ -15,7 +15,19 @@
*/
import type { Dependency, IWorkbookData, Workbook } from '@univerjs/core';
import { BooleanNumber, ILogService, Inject, Injector, IUniverInstanceService, LocaleType, LogLevel, Plugin, Tools, Univer, UniverInstanceType } from '@univerjs/core';
import {
BooleanNumber,
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleType,
LogLevel,
Plugin,
Tools,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FormulaDataModel } from '@univerjs/engine-formula';
import { SheetInterceptorService, SheetSkeletonService, SheetsSelectionsService } from '@univerjs/sheets';
import { SheetsSortController } from '../../../controllers/sheets-sort.controller';
@@ -14,22 +14,6 @@
* limitations under the License.
*/
/**
* 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 {
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
IContextService,
@@ -16,9 +16,16 @@
import type { IRange, Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule, SpreadsheetSkeleton } from '@univerjs/engine-render';
import type { ICancelMarkDirtyRowAutoHeightOperationParams, IMarkDirtyRowAutoHeightOperationParams } from '@univerjs/sheets';
import type {
ICancelMarkDirtyRowAutoHeightOperationParams,
IMarkDirtyRowAutoHeightOperationParams,
} from '@univerjs/sheets';
import { createIdentifier, Disposable, ICommandService, Inject, Rectangle } from '@univerjs/core';
import { CancelMarkDirtyRowAutoHeightOperation, MarkDirtyRowAutoHeightOperation, SetWorksheetRowAutoHeightMutation } from '@univerjs/sheets';
import {
CancelMarkDirtyRowAutoHeightOperation,
MarkDirtyRowAutoHeightOperation,
SetWorksheetRowAutoHeightMutation,
} from '@univerjs/sheets';
import { SheetSkeletonManagerService } from './sheet-skeleton-manager.service';
export interface IAutoHeightTask {
@@ -14,14 +14,41 @@
* limitations under the License.
*/
import type { DrawingTypeEnum, ICommandInfo, IDisposable, INeedCheckDisposable, Injector, IRange, Nullable, Workbook, Worksheet } from '@univerjs/core';
import type {
DrawingTypeEnum,
ICommandInfo,
IDisposable,
INeedCheckDisposable,
Injector,
IRange,
Nullable,
Workbook,
Worksheet,
} from '@univerjs/core';
import type { BaseObject, IBoundRectNoAngle, IRender, IShapeProps, Shape, SpreadsheetSkeleton, Viewport } from '@univerjs/engine-render';
import type { ISetWorksheetRowAutoHeightMutationParams, ISheetLocationBase } from '@univerjs/sheets';
import type { IPopup } from '@univerjs/ui';
import type { Observable } from 'rxjs';
import { Disposable, DisposableCollection, fromEventSubject, ICommandService, Inject, IUniverInstanceService, toDisposable, UniverInstanceType } from '@univerjs/core';
import {
Disposable,
DisposableCollection,
fromEventSubject,
ICommandService,
Inject,
IUniverInstanceService,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { IRenderManagerService, RENDER_CLASS_TYPE } from '@univerjs/engine-render';
import { COMMAND_LISTENER_SKELETON_CHANGE, IRefSelectionsService, RefRangeService, SetFrozenMutation, SetSelectionsOperation, SetWorksheetRowAutoHeightMutation, SheetsSelectionsService } from '@univerjs/sheets';
import {
COMMAND_LISTENER_SKELETON_CHANGE,
IRefSelectionsService,
RefRangeService,
SetFrozenMutation,
SetSelectionsOperation,
SetWorksheetRowAutoHeightMutation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { ICanvasPopupService } from '@univerjs/ui';
import { BehaviorSubject, map, throttleTime } from 'rxjs';
import { SetScrollOperation } from '../commands/operations/scroll.operation';
@@ -17,7 +17,13 @@
import type { IDisposable } from '@univerjs/core';
import type { ISheetLocation } from '@univerjs/sheets';
import type { ICellDropdown } from '../views/dropdown';
import { createIdentifier, Disposable, DisposableCollection, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, Inject } from '@univerjs/core';
import {
createIdentifier,
Disposable,
DisposableCollection,
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
Inject,
} from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ComponentManager } from '@univerjs/ui';
import { dropdownMap } from '../views/dropdown';
@@ -34,8 +34,19 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { getCanvasOffsetByEngine, IEditorService } from '@univerjs/docs-ui';
import { convertTextRotation, convertTransformToOffsetX, convertTransformToOffsetY, DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render';
import { attachPrimaryWithCoord, BEFORE_CELL_EDIT, SheetInterceptorService, SheetSkeletonService } from '@univerjs/sheets';
import {
convertTextRotation,
convertTransformToOffsetX,
convertTransformToOffsetY,
DeviceInputEventType,
IRenderManagerService,
} from '@univerjs/engine-render';
import {
attachPrimaryWithCoord,
BEFORE_CELL_EDIT,
SheetInterceptorService,
SheetSkeletonService,
} from '@univerjs/sheets';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { ISheetSelectionRenderService } from './selection/base-selection-render.service';
@@ -15,7 +15,13 @@
*/
import type { ICustomRange, IParagraph, IPosition, Nullable, Workbook, Worksheet } from '@univerjs/core';
import type { IBoundRectNoAngle, IDocumentSkeletonDrawing, IMouseEvent, IPointerEvent, IRender } from '@univerjs/engine-render';
import type {
IBoundRectNoAngle,
IDocumentSkeletonDrawing,
IMouseEvent,
IPointerEvent,
IRender,
} from '@univerjs/engine-render';
import type { ISheetLocation, ISheetLocationBase, ISheetSkeletonManagerParam } from '@univerjs/sheets';
import { CellValueType, Disposable, isRealNum, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { IRenderManagerService, SHEET_VIEWPORT_KEY, Vector2 } from '@univerjs/engine-render';
@@ -21,7 +21,10 @@ import { Disposable, Inject } from '@univerjs/core';
import { SHEET_VIEWPORT_KEY } from '@univerjs/engine-render';
import { SheetSkeletonService, SheetsSelectionsService } from '@univerjs/sheets';
import { BehaviorSubject } from 'rxjs';
import { SetColumnHeaderHeightCommand, SetRowHeaderWidthCommand } from '../commands/commands/headersize-changed.command';
import {
SetColumnHeaderHeightCommand,
SetRowHeaderWidthCommand,
} from '../commands/commands/headersize-changed.command';
import { ISheetSelectionRenderService } from './selection/base-selection-render.service';
export interface ISheetSkeletonManagerSearch {
@@ -15,7 +15,13 @@
*/
import type { IUniverInstanceService, Workbook } from '@univerjs/core';
import type { IDefinedNamesService, IDefinedNamesServiceParam, IFunctionService, ISuperTableService, LexerTreeBuilder } from '@univerjs/engine-formula';
import type {
IDefinedNamesService,
IDefinedNamesServiceParam,
IFunctionService,
ISuperTableService,
LexerTreeBuilder,
} from '@univerjs/engine-formula';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import { AbsoluteRefType } from '@univerjs/core';
import { isReferenceStringWithEffectiveColumn, serializeRangeWithSheet } from '@univerjs/engine-formula';
@@ -15,8 +15,20 @@
*/
import type { ICellWithCoord, ICustomRange, Injector, IParagraph, ITextRangeParam, Workbook } from '@univerjs/core';
import type { DocumentSkeleton, IBoundRectNoAngle, IDocumentSkeletonGlyph, IFontCacheItem } from '@univerjs/engine-render';
import { CustomRangeType, HorizontalAlign, IUniverInstanceService, PresetListType, UniverInstanceType, VerticalAlign } from '@univerjs/core';
import type {
DocumentSkeleton,
IBoundRectNoAngle,
IDocumentSkeletonGlyph,
IFontCacheItem,
} from '@univerjs/engine-render';
import {
CustomRangeType,
HorizontalAlign,
IUniverInstanceService,
PresetListType,
UniverInstanceType,
VerticalAlign,
} from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { DOC_VERTICAL_PADDING, getLineBounding, NodePositionConvertToCursor } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
@@ -1,61 +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 { describe, expect, it } from 'vitest';
import { KeyCode, KeyCodeToChar, MetaKeys } from '../keycode';
describe('keycode mappings', () => {
it('should map common control keys to labels', () => {
expect(KeyCodeToChar[KeyCode.BACKSPACE]).toBe('Backspace');
expect(KeyCodeToChar[KeyCode.ENTER]).toBe('Enter');
expect(KeyCodeToChar[KeyCode.DELETE]).toBe('Del');
expect(KeyCodeToChar[KeyCode.ESC]).toBe('Esc');
expect(KeyCodeToChar[KeyCode.SPACE]).toBe('Space');
expect(KeyCodeToChar[KeyCode.HOME]).toBe('Home');
expect(KeyCodeToChar[KeyCode.END]).toBe('End');
});
it('should map alphanumeric and function keys', () => {
expect(KeyCodeToChar[KeyCode.Digit0]).toBe('0');
expect(KeyCodeToChar[KeyCode.Digit9]).toBe('9');
expect(KeyCodeToChar[KeyCode.A]).toBe('A');
expect(KeyCodeToChar[KeyCode.Z]).toBe('Z');
expect(KeyCodeToChar[KeyCode.F1]).toBe('F1');
expect(KeyCodeToChar[KeyCode.F12]).toBe('F12');
});
it('should map punctuation and arrow keys', () => {
expect(KeyCodeToChar[KeyCode.MINUS]).toBe('-');
expect(KeyCodeToChar[KeyCode.EQUAL]).toBe('=');
expect(KeyCodeToChar[KeyCode.PERIOD]).toBe('.');
expect(KeyCodeToChar[KeyCode.COMMA]).toBe(',');
expect(KeyCodeToChar[KeyCode.BACK_SLASH]).toBe('\\');
expect(KeyCodeToChar[KeyCode.ARROW_LEFT]).toBe('←');
expect(KeyCodeToChar[KeyCode.ARROW_RIGHT]).toBe('→');
expect(KeyCodeToChar[KeyCode.ARROW_UP]).toBe('↑');
expect(KeyCodeToChar[KeyCode.ARROW_DOWN]).toBe('↓');
});
it('should return undefined for unknown key code', () => {
expect(KeyCodeToChar[KeyCode.UNKNOWN]).toBeUndefined();
});
it('should define non-overlapping meta key bitmasks', () => {
expect(MetaKeys.SHIFT & MetaKeys.ALT).toBe(0);
expect(MetaKeys.ALT & MetaKeys.CTRL_COMMAND).toBe(0);
expect(MetaKeys.CTRL_COMMAND & MetaKeys.MAC_CTRL).toBe(0);
});
});
@@ -14,22 +14,6 @@
* limitations under the License.
*/
/**
* 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.
*/
/**
* @vitest-environment jsdom
*/