Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { DockviewComponent } from '../../dockview/dockviewComponent';
import { AllModules } from '../../dockview/allModules';
import { IContentRenderer } from '../../dockview/types';
import { DockviewComponentOptions } from '../../dockview/options';
import { _resetMissingModuleWarnings } from '../../dockview/modules';

class TestPanel implements IContentRenderer {
element = document.createElement('div');
init(): void {
// noop
}
layout(): void {
// noop
}
dispose(): void {
// noop
}
}

/**
* The API half of the missing-module diagnostic. `optionsModules.spec.ts` covers
* options; this covers commands.
*
* A command that touches an enterprise service and is reachable *without* its
* gating option must report the missing module itself, because no option rule
* will have run. `api.pinEdgeGroup()` is the case that motivated this: edge
* groups are free, so a build can hold one and call the auto-hide commands
* having never set `autoHideEdgeGroups`. Queries stay silent: `false` from
* `canUndo` is a truthful answer, not a misconfiguration.
*
* `AllModules` is core-only, so every component here is a free build; the
* enterprise modules only register on import of `dockview-enterprise`, which
* this package never imports.
*
* This is coverage by enumeration, the same hand-maintained kind the options
* side has: it locks in the commands that guard today, but adding a new gated
* command means adding it here too, and nothing will remind you.
*/
describe('enterprise API commands report the missing module on a free build', () => {
let consoleError: jest.SpyInstance;
const built: DockviewComponent[] = [];

function freeDockview(
options: Partial<DockviewComponentOptions> = {}
): DockviewComponent {
const container = document.createElement('div');
document.body.appendChild(container);
const dv = new DockviewComponent(container, {
createComponent: () => new TestPanel(),
modules: AllModules,
...options,
} as never);
dv.layout(1000, 800);
built.push(dv);
return dv;
}

beforeEach(() => {
// Dedup is page-global and outlives a test, so reset it or only the
// first component in this file would log.
_resetMissingModuleWarnings();
consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
});

afterEach(() => {
for (const dv of built.splice(0)) {
dv.dispose();
}
jest.restoreAllMocks();
});

/** The single string console.error was last called with. */
function lastMessage(): string {
expect(consoleError).toHaveBeenCalled();
return consoleError.mock.calls[consoleError.mock.calls.length - 1][0];
}

describe.each([
['pinEdgeGroup', (dv: DockviewComponent) => dv.pinEdgeGroup('left')],
[
'autoHideEdgeGroup',
(dv: DockviewComponent) => dv.autoHideEdgeGroup('left'),
],
[
'peekEdgeGroup',
(dv: DockviewComponent) => dv.peekEdgeGroup('left', true),
],
])('AutoHideEdgeGroup command %s', (name, call) => {
test('reports AutoHideEdgeGroup and names the package', () => {
call(freeDockview());

const message = lastMessage();
expect(message).toContain('AutoHideEdgeGroup');
expect(message).toContain('dockview-enterprise');
expect(message).toContain(`api.${name}`);
});

test('degrades to a no-op rather than throwing', () => {
expect(() => call(freeDockview())).not.toThrow();
});
});

describe.each([
['undo', (dv: DockviewComponent) => dv.undo()],
['redo', (dv: DockviewComponent) => dv.redo()],
])('LayoutHistory command %s', (name, call) => {
test('reports LayoutHistory and names the package', () => {
call(freeDockview());

const message = lastMessage();
expect(message).toContain('LayoutHistory');
expect(message).toContain('dockview-enterprise');
expect(message).toContain(`api.${name}`);
});
});

test('queries stay silent: canUndo/canRedo answer without logging', () => {
const dv = freeDockview();

expect(dv.canUndo).toBe(false);
expect(dv.canRedo).toBe(false);
expect(consoleError).not.toHaveBeenCalled();
});

test('a command dedups: calling it twice on one build logs once', () => {
const dv = freeDockview();

dv.pinEdgeGroup('left');
dv.pinEdgeGroup('right');

expect(consoleError).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { DockviewComponent } from '../../dockview/dockviewComponent';
import { AllModules } from '../../dockview/allModules';
import { IContentRenderer } from '../../dockview/types';
import { DockviewComponentOptions } from '../../dockview/options';
import { LocalSelectionTransfer, PanelTransfer } from '../../dnd/dataTransfer';
import { _resetMissingModuleWarnings } from '../../dockview/modules';

class TestPanel implements IContentRenderer {
element = document.createElement('div');
init(): void {
// noop
}
layout(): void {
// noop
}
dispose(): void {
// noop
}
}

/**
* Docking a dragged panel to a layout edge is an AutoEdgeGroup (enterprise)
* feature: `features.mdx` ticks only the Enterprise column. These tests pin the
* tier boundary so core can't hand the feature to free builds through a
* single-band fallback that performs the dock whenever the module is absent.
*
* `AllModules` is core-only, so this file always runs as a free build: the
* enterprise modules self-register on import of `dockview-enterprise`, which
* this package never imports.
*/
describe('dockToEdgeGroups is enterprise-only', () => {
/** `DEFAULT_ROOT_OVERLAY_MODEL.activationSize` in rootDropTargetService. */
const DEFAULT_ACTIVATION = 10;

const transfer = LocalSelectionTransfer.getInstance<PanelTransfer>();

let dockview: DockviewComponent;
const built: DockviewComponent[] = [];

/** A component with only core modules, i.e. the free package. */
function freeDockview(
options: Partial<DockviewComponentOptions>
): DockviewComponent {
const container = document.createElement('div');
document.body.appendChild(container);
const dv = new DockviewComponent(container, {
createComponent: () => new TestPanel(),
modules: AllModules,
...options,
} as never);
dv.layout(1000, 800);
built.push(dv);
return dv;
}

beforeEach(() => {
// The missing-module dedup is page-global and outlives a test, so
// without this only the first component built in this file would log.
_resetMissingModuleWarnings();
jest.spyOn(console, 'error').mockImplementation(() => undefined);

dockview = freeDockview({ dockToEdgeGroups: true });
});

afterEach(() => {
transfer.clearData(PanelTransfer.prototype);
for (const dv of built.splice(0)) {
dv.dispose();
}
jest.restoreAllMocks();
});

/**
* The resolved edge activation band, read off the drop target the service
* built. Reaches through privates deliberately: the band is not exposed
* anywhere public, and the alternative (simulating dragover at a measured
* offset) would assert the same thing through far more machinery.
*/
function bandActivationSize(dv: DockviewComponent): number | undefined {
const svc = (dv as never as { _rootDropTargetService: unknown })
._rootDropTargetService as {
_html5Target: {
options: {
overlayModel?: { activationSize?: { value?: number } };
};
};
};
return svc._html5Target.options.overlayModel?.activationSize?.value;
}

function dragPanelToEdge(panelId: string, groupId: string): void {
transfer.setData(
[new PanelTransfer(dockview.id, groupId, panelId)],
PanelTransfer.prototype
);
dockview.dockToLayoutEdge(new MouseEvent('drop') as never, 'left');
}

test('a root-edge drop does not create an edge group', () => {
const a = dockview.addPanel({ id: 'a', component: 'default' });
dockview.addPanel({ id: 'b', component: 'default' });

dragPanelToEdge('a', a.group.id);

// The dock is the paid behaviour: without AutoEdgeGroup the drop must
// fall through to the ordinary grid split, leaving no edge group.
expect(dockview.getEdgeGroup('left')).toBeUndefined();
expect(
dockview.groups.filter((g) => g.model.location.type === 'edge')
).toEqual([]);
});

test('the panel is still moved; the drop degrades, it does not vanish', () => {
const a = dockview.addPanel({ id: 'a', component: 'default' });
dockview.addPanel({ id: 'b', component: 'default' });

dragPanelToEdge('a', a.group.id);

expect(dockview.panels.map((p) => p.id).sort()).toEqual(['a', 'b']);
});

test('the option does not widen the edge activation band', () => {
// The widened band exists to fit the affordance's outer "dock as edge
// group" sub-band. With no affordance nothing consumes it, so widening
// would only enlarge the plain grid-split trigger, a bigger target for
// behaviour the default band already gives.
//
// Pinned to the concrete default rather than compared against a second
// free build: two builds agreeing proves nothing if the band ever
// resolves to undefined. 32 is the widened value this must never be;
// see the enterprise-side edgeDragRevealBand spec for the positive.
expect(bandActivationSize(dockview)).toBe(DEFAULT_ACTIVATION);
expect(
bandActivationSize(freeDockview({ dockToEdgeGroups: undefined }))
).toBe(DEFAULT_ACTIVATION);
});

test('setting the option reports the missing module', () => {
// The option is inert here, so the diagnostic is the only thing telling
// the user why; see optionsModules.ts.
const messages = (console.error as jest.Mock).mock.calls.map(
(c) => c[0] as string
);
expect(messages.some((m) => m.includes('AutoEdgeGroup'))).toBe(true);
});
});
42 changes: 38 additions & 4 deletions packages/dockview-core/src/__tests__/dockview/modules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ModuleRegistry,
_resetMissingModuleWarnings,
assertModule,
missingModuleMessage,
} from '../../dockview/modules';

describe('ModuleRegistry', () => {
Expand Down Expand Up @@ -135,11 +136,44 @@ describe('assertModule', () => {
assertModule(undefined, 'PopoutWindow', 'api.addPopoutGroup');
assertModule(undefined, 'PopoutWindow', 'api.popoutRestorationPromise');
expect(consoleError).toHaveBeenCalledTimes(2);
expect(consoleError.mock.calls[0][0]).toMatch(
/for api\.addPopoutGroup/
);
expect(consoleError.mock.calls[0][0]).toMatch(/`api\.addPopoutGroup`/);
expect(consoleError.mock.calls[1][0]).toMatch(
/for api\.popoutRestorationPromise/
/`api\.popoutRestorationPromise`/
);
});

test('names dockview-enterprise and the fix for an enterprise module', () => {
assertModule(undefined, 'SmartGuides', 'smartGuides');
const message = consoleError.mock.calls[0][0];
expect(message).toMatch(/ships in dockview-enterprise/);
expect(message).toMatch(/npm install dockview-enterprise/);
expect(message).toMatch(/import 'dockview-enterprise'/);
});

test('does not mention enterprise for a core module', () => {
assertModule(undefined, 'PopoutWindow', 'api.addPopoutGroup');
expect(consoleError.mock.calls[0][0]).not.toMatch(/enterprise/i);
});
});

describe('missingModuleMessage', () => {
test('is the same text assertModule logs, for callers that must throw', () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
_resetMissingModuleWarnings();

assertModule(undefined, 'EdgeGroup', 'api.addEdgeGroup');
expect(consoleError.mock.calls[0][0]).toBe(
missingModuleMessage('EdgeGroup', 'api.addEdgeGroup')
);

consoleError.mockRestore();
});

test('names the module and the reason', () => {
const message = missingModuleMessage('EdgeGroup', 'api.addEdgeGroup');
expect(message).toMatch(/EdgeGroup/);
expect(message).toMatch(/`api\.addEdgeGroup`/);
});
});
Loading
Loading