From 2c00c6afa33a1b777e37b625ce2bf7f9add0a45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 14:41:23 +0000 Subject: [PATCH] =?UTF-8?q?test=20PanelSlot=20=E2=80=94=20the=20chrome's?= =?UTF-8?q?=20mode=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/Workspace/PanelSlot.test.tsx | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 src/workspaces/officerdev/src/components/Workspace/PanelSlot.test.tsx diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.test.tsx b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.test.tsx new file mode 100644 index 00000000..c450925c --- /dev/null +++ b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.test.tsx @@ -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) => ) as AppRegistryMap[string]['icon']; + +const App = ({ panelId }: { panelId: string }) =>
{panelId}
; +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 => ({ + 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; +}; + +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( + + calls.setApp.push([id, type])} + onSplit={(id, dir) => calls.split.push([id, dir])} + onRemove={(id) => calls.remove.push(id)} + /> + , + ); +} + +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('Zoom in').disabled).toBe(true); + expect(screen.getByTitle('Zoom out').disabled).toBe(false); + view.unmount(); + + mount({ panel: panel('notes', { zoom: ZOOM_MIN }) }); + expect(screen.getByTitle('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('[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 }) =>
{panelId}
; + + 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 }) => {panelId}; + const Provider = ({ panelId, children }: { panelId: string; children: ReactNode }) => ( +
+ {children} +
+ ); + 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(); + }); +});