test PanelSlot — the chrome's mode matrix
25 tests over which controls exist in which mode and whether each calls the handler it is named after. interactive, locked, isMobile, isLastPanel, maximized and an app's own zoomable/transparent flags combine in six separate ternaries; a control present in a mode that should not have it is a way to edit a locked screen, and a control missing from one that should is what the close button was. Drives PanelSlot directly rather than through WorkspaceView, so the workspace can be put into states a whole view cannot easily be pushed into — maximized, mid-swap, mobile. Nothing new found: the close button is the only defect the chrome had, and it was pinned last commit.
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, test, beforeEach } from 'bun:test';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import type { AppRegistryMap, LayoutPanel, PanelComponents } from './types';
|
||||
import type { WorkspaceContextValue } from './WorkspaceContext';
|
||||
import { WorkspaceProvider, inertInteraction } from './WorkspaceContext';
|
||||
import { ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from './layout-utils';
|
||||
import { PanelSlot } from './PanelSlot';
|
||||
|
||||
/**
|
||||
* The panel's chrome. It has no state of its own worth speaking of, which is exactly why it went
|
||||
* untested and exactly why it was able to ship a button that did nothing it said: every question here is
|
||||
* "in this mode, which controls exist, and does each one call the handler it is named after" — cheap to
|
||||
* answer and nobody had.
|
||||
*
|
||||
* The mode matrix is the substance. `interactive`, `locked`, `isMobile`, `isLastPanel`, `maximizedPanelId`
|
||||
* and an app's own `zoomable`/`transparent` flags combine into chrome that ranges from full traffic
|
||||
* lights down to a bare outline, and the combinations are chosen in six separate ternaries spread over
|
||||
* the file. A control appearing in a mode that should not have it is a way to edit a locked screen; a
|
||||
* control missing from a mode that should is what the close button was.
|
||||
*
|
||||
* `WorkspaceView.test.tsx` covers the same chrome from above, through the real renderer. This drives
|
||||
* `PanelSlot` directly so it can put the workspace into states a whole view cannot easily be pushed
|
||||
* into — maximized, mid-swap, mobile.
|
||||
*/
|
||||
|
||||
const Icon = ((props: Record<string, unknown>) => <svg {...props} />) as AppRegistryMap[string]['icon'];
|
||||
|
||||
const App = ({ panelId }: { panelId: string }) => <div data-testid="app">{panelId}</div>;
|
||||
const Boom = () => {
|
||||
throw new Error('app exploded');
|
||||
};
|
||||
|
||||
const registry: AppRegistryMap = {
|
||||
notes: { name: 'Notes', icon: Icon, component: App },
|
||||
terminal: { name: 'Terminal', icon: Icon, component: App, zoomable: false },
|
||||
overlay: { name: 'Overlay', icon: Icon, component: App, transparent: true },
|
||||
boom: { name: 'Boom', icon: Icon, component: Boom },
|
||||
hidden: { name: 'Hidden', icon: Icon, component: App, availableOnPanel: false },
|
||||
};
|
||||
|
||||
const panel = (appType: string | null, extra: Partial<LayoutPanel> = {}): LayoutPanel => ({
|
||||
type: 'panel',
|
||||
id: 'p1',
|
||||
appType,
|
||||
...extra,
|
||||
});
|
||||
|
||||
type Calls = {
|
||||
setApp: [string, string | null][];
|
||||
split: [string, 'horizontal' | 'vertical'][];
|
||||
remove: string[];
|
||||
zoom: [string, number][];
|
||||
swap: [string, string][];
|
||||
maximize: (string | null)[];
|
||||
swapSource: (string | null)[];
|
||||
mobileBack: number;
|
||||
};
|
||||
|
||||
let calls: Calls;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = { setApp: [], split: [], remove: [], zoom: [], swap: [], maximize: [], swapSource: [], mobileBack: 0 };
|
||||
});
|
||||
|
||||
type MountOptions = {
|
||||
panel?: LayoutPanel;
|
||||
interactive?: boolean;
|
||||
locked?: boolean;
|
||||
noHeader?: boolean;
|
||||
isLastPanel?: boolean;
|
||||
components?: PanelComponents;
|
||||
context?: Partial<WorkspaceContextValue>;
|
||||
};
|
||||
|
||||
function mount(options: MountOptions = {}) {
|
||||
const context: WorkspaceContextValue = {
|
||||
workspace: null,
|
||||
cwd: '~',
|
||||
panelConfigs: {},
|
||||
...inertInteraction,
|
||||
isMobile: false,
|
||||
onMobileBack: null,
|
||||
onSwap: (source, target) => calls.swap.push([source, target]),
|
||||
setSwapSourceId: (id) => calls.swapSource.push(id),
|
||||
setMaximizedPanelId: (id) => calls.maximize.push(id),
|
||||
onSetZoom: (id, z) => calls.zoom.push([id, z]),
|
||||
...options.context,
|
||||
};
|
||||
|
||||
return render(
|
||||
<WorkspaceProvider value={context}>
|
||||
<PanelSlot
|
||||
panel={options.panel ?? panel('notes')}
|
||||
registry={registry}
|
||||
components={options.components}
|
||||
interactive={options.interactive ?? true}
|
||||
locked={options.locked ?? false}
|
||||
noHeader={options.noHeader ?? false}
|
||||
isLastPanel={options.isLastPanel ?? false}
|
||||
onSetApp={(id, type) => calls.setApp.push([id, type])}
|
||||
onSplit={(id, dir) => calls.split.push([id, dir])}
|
||||
onRemove={(id) => calls.remove.push(id)}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('the red button does what its own label says', () => {
|
||||
test('a panel with siblings closes', () => {
|
||||
// Regression pin, from the other side. `TrafficLights` used `isLastPanel` only to pick the tooltip
|
||||
// and called `onClearApp` in both branches, so this button emptied the panel and left the box. It is
|
||||
// the one control that removes anything, and it removed nothing for as long as it existed.
|
||||
mount({ isLastPanel: false });
|
||||
|
||||
fireEvent.click(screen.getByTitle('Close panel'));
|
||||
|
||||
expect(calls.remove).toEqual(['p1']);
|
||||
expect(calls.setApp).toEqual([]);
|
||||
});
|
||||
|
||||
test('the only panel clears, because removing it would leave nothing', () => {
|
||||
mount({ isLastPanel: true });
|
||||
|
||||
fireEvent.click(screen.getByTitle('Clear app'));
|
||||
|
||||
expect(calls.setApp).toEqual([['p1', null]]);
|
||||
expect(calls.remove).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which chrome each mode gets', () => {
|
||||
test('interactive and unlocked: close and maximize', () => {
|
||||
mount();
|
||||
|
||||
expect(screen.getByTitle('Close panel')).toBeTruthy();
|
||||
expect(screen.getByTitle('Maximize')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('locked: maximize only — a locked screen is not the user’s to edit', () => {
|
||||
mount({ locked: true });
|
||||
|
||||
expect(screen.queryByTitle('Close panel')).toBeNull();
|
||||
expect(screen.queryByTitle('Clear app')).toBeNull();
|
||||
expect(screen.getByTitle('Maximize')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('not interactive: no chrome buttons at all', () => {
|
||||
mount({ interactive: false });
|
||||
|
||||
expect(screen.queryByTitle('Close panel')).toBeNull();
|
||||
expect(screen.queryByTitle('Maximize')).toBeNull();
|
||||
expect(screen.queryByTitle('Zoom in')).toBeNull();
|
||||
expect(screen.getByTestId('app')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mobile: no traffic lights and no zoom — the header is a Back button', () => {
|
||||
mount({ context: { isMobile: true, onMobileBack: () => calls.mobileBack++ } });
|
||||
|
||||
expect(screen.queryByTitle('Close panel')).toBeNull();
|
||||
expect(screen.queryByTitle('Zoom in')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByText('Back'));
|
||||
expect(calls.mobileBack).toBe(1);
|
||||
});
|
||||
|
||||
test('maximized: Restore replaces close, so the way out is never a way to delete', () => {
|
||||
mount({ context: { maximizedPanelId: 'p1' } });
|
||||
|
||||
expect(screen.queryByTitle('Close panel')).toBeNull();
|
||||
fireEvent.click(screen.getByTitle('Restore'));
|
||||
|
||||
expect(calls.maximize).toEqual([null]);
|
||||
expect(calls.remove).toEqual([]);
|
||||
});
|
||||
|
||||
test('maximize sets this panel, and a locked maximize toggles', () => {
|
||||
const view = mount();
|
||||
fireEvent.click(screen.getByTitle('Maximize'));
|
||||
expect(calls.maximize).toEqual(['p1']);
|
||||
view.unmount();
|
||||
|
||||
mount({ locked: true, context: { maximizedPanelId: 'p1' } });
|
||||
fireEvent.click(screen.getByTitle('Restore'));
|
||||
expect(calls.maximize).toEqual(['p1', null]);
|
||||
});
|
||||
|
||||
test('noHeader drops the whole header bar but keeps the app', () => {
|
||||
mount({ noHeader: true });
|
||||
|
||||
expect(screen.queryByTitle('Close panel')).toBeNull();
|
||||
expect(screen.queryByText('Notes')).toBeNull();
|
||||
expect(screen.getByTestId('app')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the empty panel', () => {
|
||||
test('offers the apps, and picking one sets it', () => {
|
||||
mount({ panel: panel(null) });
|
||||
|
||||
fireEvent.click(screen.getByText('Notes'));
|
||||
|
||||
expect(calls.setApp).toEqual([['p1', 'notes']]);
|
||||
});
|
||||
|
||||
test('an app marked availableOnPanel: false is not on offer', () => {
|
||||
mount({ panel: panel(null) });
|
||||
|
||||
expect(screen.queryByText('Hidden')).toBeNull();
|
||||
});
|
||||
|
||||
test('locked or inert, it is an outline and nothing else', () => {
|
||||
const view = mount({ panel: panel(null), locked: true });
|
||||
expect(screen.queryByText('Notes')).toBeNull();
|
||||
view.unmount();
|
||||
|
||||
mount({ panel: panel(null), interactive: false });
|
||||
expect(screen.queryByText('Notes')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoom', () => {
|
||||
test('the controls step by ZOOM_STEP and reset to 1', () => {
|
||||
mount({ panel: panel('notes', { zoom: 1.1 }) });
|
||||
|
||||
fireEvent.click(screen.getByTitle('Zoom in'));
|
||||
fireEvent.click(screen.getByTitle('Zoom out'));
|
||||
fireEvent.click(screen.getByTitle('Reset zoom'));
|
||||
|
||||
expect(calls.zoom).toEqual([
|
||||
['p1', 1.1 + ZOOM_STEP],
|
||||
['p1', 1.1 - ZOOM_STEP],
|
||||
['p1', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the controls disable at the bounds rather than sending an out-of-range value', () => {
|
||||
const view = mount({ panel: panel('notes', { zoom: ZOOM_MAX }) });
|
||||
expect(screen.getByTitle<HTMLButtonElement>('Zoom in').disabled).toBe(true);
|
||||
expect(screen.getByTitle<HTMLButtonElement>('Zoom out').disabled).toBe(false);
|
||||
view.unmount();
|
||||
|
||||
mount({ panel: panel('notes', { zoom: ZOOM_MIN }) });
|
||||
expect(screen.getByTitle<HTMLButtonElement>('Zoom out').disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('an app that opted out gets no control', () => {
|
||||
mount({ panel: panel('terminal') });
|
||||
|
||||
expect(screen.queryByTitle('Zoom in')).toBeNull();
|
||||
expect(screen.getByTitle('Close panel')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('the scale is CSS `zoom`, and is absent at 100%', () => {
|
||||
// Not `transform: scale`. A transform repaints at a different size without re-laying out, so the
|
||||
// panel keeps its 100% geometry and anything sized against a percentage lands wrong — it broke
|
||||
// Chat's bottom-pinned composer. Writing no style at all at 1 keeps an untouched panel byte-identical
|
||||
// to what it rendered before the control existed.
|
||||
const view = mount({ panel: panel('notes', { zoom: 1.4 }) });
|
||||
const zoomed = document.querySelector<HTMLElement>('[style*="zoom"]');
|
||||
expect(zoomed?.style.zoom).toBe('1.4');
|
||||
expect(zoomed?.style.transform).toBeFalsy();
|
||||
view.unmount();
|
||||
|
||||
mount({ panel: panel('notes', { zoom: 1 }) });
|
||||
expect(document.querySelector('[style*="zoom"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an app that throws is contained', () => {
|
||||
test('the fallback replaces the app and the chrome survives', () => {
|
||||
mount({ panel: panel('boom') });
|
||||
|
||||
expect(screen.getByText('This panel failed to render')).toBeTruthy();
|
||||
expect(screen.getByText('app exploded')).toBeTruthy();
|
||||
// The rest of the workspace is still the user's to drive — that is the whole point of the boundary.
|
||||
expect(screen.getByTitle('Close panel')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('the fallback offers to clear the panel, and clearing is what recovers it', () => {
|
||||
mount({ panel: panel('boom') });
|
||||
|
||||
fireEvent.click(screen.getByText('Clear this panel'));
|
||||
|
||||
expect(calls.setApp).toEqual([['p1', null]]);
|
||||
});
|
||||
|
||||
test('a locked panel is offered no clear — there is nothing there it may edit', () => {
|
||||
mount({ panel: panel('boom'), locked: true });
|
||||
|
||||
expect(screen.getByText('This panel failed to render')).toBeTruthy();
|
||||
expect(screen.queryByText('Clear this panel')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a screen-supplied component beats the registry', () => {
|
||||
const Custom = ({ panelId }: { panelId: string }) => <div data-testid="custom">{panelId}</div>;
|
||||
|
||||
test('a bare component is rendered, and is told which panel it is', () => {
|
||||
mount({ components: { p1: Custom } });
|
||||
|
||||
expect(screen.getByTestId('custom').textContent).toBe('p1');
|
||||
expect(screen.queryByTestId('app')).toBeNull();
|
||||
});
|
||||
|
||||
test('an entry’s header, provider and onClose are all honoured', () => {
|
||||
const Header = ({ panelId }: { panelId: string }) => <span data-testid="header">{panelId}</span>;
|
||||
const Provider = ({ panelId, children }: { panelId: string; children: ReactNode }) => (
|
||||
<div data-testid="provider" data-panel={panelId}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
let closed = 0;
|
||||
|
||||
mount({ components: { p1: { component: Custom, header: Header, provider: Provider, onClose: () => closed++ } } });
|
||||
|
||||
expect(screen.getByTestId('header').textContent).toBe('p1');
|
||||
expect(screen.getByTestId('provider').getAttribute('data-panel')).toBe('p1');
|
||||
// The header's own close — an app-level dismissal, not the panel's traffic light.
|
||||
fireEvent.click(screen.getByTestId('header').parentElement!.querySelector('button')!);
|
||||
expect(closed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('swap', () => {
|
||||
test('another panel’s swap offers this one as a target, and clicking it swaps', () => {
|
||||
mount({ context: { swapSourceId: 'p9' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Swap here'));
|
||||
|
||||
expect(calls.swap).toEqual([['p9', 'p1']]);
|
||||
});
|
||||
|
||||
test('the source panel shows a cancel affordance instead of a target', () => {
|
||||
mount({ context: { swapSourceId: 'p1' } });
|
||||
|
||||
expect(screen.queryByText('Swap here')).toBeNull();
|
||||
fireEvent.click(screen.getByText('Swapping... click to cancel'));
|
||||
|
||||
expect(calls.swapSource).toEqual([null]);
|
||||
});
|
||||
|
||||
test('a locked panel is not a swap target — a swap would edit the layout', () => {
|
||||
mount({ locked: true, context: { swapSourceId: 'p9' } });
|
||||
|
||||
expect(screen.queryByText('Swap here')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a transparent app gets no chrome, by its own declaration', () => {
|
||||
test('no header, no card — but still an error boundary', () => {
|
||||
const view = mount({ panel: panel('overlay') });
|
||||
|
||||
expect(screen.queryByText('Overlay')).toBeNull();
|
||||
expect(screen.queryByTitle('Close panel')).toBeNull();
|
||||
expect(screen.getByTestId('app')).toBeTruthy();
|
||||
view.unmount();
|
||||
|
||||
mount({ panel: { type: 'panel', id: 'p1', appType: 'overlay' }, components: { p1: Boom } });
|
||||
expect(screen.getByText('This panel failed to render')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user