Skip to content
127 changes: 127 additions & 0 deletions src/components/TilingLayout.styles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';

const css = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8');
const tilingLayoutSource = readFileSync(
resolve(process.cwd(), 'src/components/TilingLayout.tsx'),
'utf8',
);

function hasRule(selector: string): boolean {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?:^|\\n)${escapedSelector}\\s*\\{([^}]*)\\}`).test(css);
}

function declarationsFor(selector: string): Record<string, string> {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(`(?:^|\\n)${escapedSelector}\\s*\\{([^}]*)\\}`));
if (!match) throw new Error(`Missing CSS rule for ${selector}`);

return Object.fromEntries(
match[1]
.split(';')
.map((declaration) => declaration.trim())
.filter(Boolean)
.map((declaration) => {
const separatorIndex = declaration.indexOf(':');
return [
declaration.slice(0, separatorIndex).trim(),
declaration.slice(separatorIndex + 1).trim(),
];
}),
);
}

describe('tiling layout group divider styles', () => {
it('keeps the group-between resize handle inside the dark group gap', () => {
const groupWrapper = declarationsFor('.panel-group-wrapper');
const groupBetweenHandle = declarationsFor('.group-between-handle');
const islandsGroupBetweenHandle = declarationsFor(
"html[data-look^='islands-'] .tiling-layout-strip > .group-between-handle",
);

expect(groupWrapper['margin-right']).toBe('0');
expect(groupBetweenHandle.width).toBe('10px');
expect(groupBetweenHandle.margin).toBe('0');
expect(islandsGroupBetweenHandle.margin).toBe('0');
});

it('keeps inner group resize handles visible in expanded groups', () => {
expect(hasRule('.resize-handle-h.group-inner-handle::before')).toBe(false);
});

it('fills inner group resize handle gaps with the group background', () => {
const groupInnerHandle = declarationsFor('.resize-handle-h.group-inner-handle');
const groupInnerHandleHover = declarationsFor('.resize-handle-h.group-inner-handle:hover');

expect(groupInnerHandle.margin).toBe('0');
expect(groupInnerHandle.position).toBe('relative');
expect(groupInnerHandle['z-index']).toBe('3');
expect(groupInnerHandle.background).toBe('inherit');
expect(groupInnerHandleHover.background).toContain('color-mix');
});
});

describe('tiling layout group collapse controls', () => {
it('uses prominent arrow icons for collapse and expand controls', () => {
const collapseIcon = declarationsFor('.panel-group-collapse-btn svg');
const expandIcon = declarationsFor('.panel-group-expand-btn svg');

expect(collapseIcon.width).toBe('16px');
expect(collapseIcon.height).toBe('16px');
expect(expandIcon.width).toBe('16px');
expect(expandIcon.height).toBe('16px');
});

it('keeps the expand control as a side strip beside the visible collapsed panel', () => {
const expandButton = declarationsFor('.panel-group-expand-btn');

expect(expandButton.position).toBe('relative');
expect(expandButton.width).toBe('18px');
expect(expandButton['flex-shrink']).toBe('0');
expect(expandButton.background).toBe('transparent');
});

it('uses the same color treatment for collapse and expand controls', () => {
const collapseButton = declarationsFor('.panel-group-collapse-btn');
const expandButton = declarationsFor('.panel-group-expand-btn');
const collapseButtonHover = declarationsFor('.panel-group-collapse-btn:hover');
const expandButtonHover = declarationsFor('.panel-group-expand-btn:hover');

expect(expandButton.background).toBe(collapseButton.background);
expect(collapseButton.color).toBe('var(--fg-subtle)');
expect(expandButton.color).toBe(collapseButton.color);
expect(expandButtonHover.filter).toBe(collapseButtonHover.filter);
expect(expandButtonHover.color).toBe(collapseButtonHover.color);
expect(tilingLayoutSource).not.toContain('style={{ color: info().color }}');
});
});

describe('task panel active styles', () => {
it('does not let panel group colors tint inactive task panels through opacity', () => {
const taskColumn = declarationsFor('.task-column');

expect(taskColumn.opacity).toBeUndefined();
});

it('draws a neutral gray overlay over inactive task panels', () => {
const inactiveOverlay = declarationsFor('.task-column:not(.active)::before');

expect(inactiveOverlay.content).toBe("''");
expect(inactiveOverlay.background).toBe('rgba(128, 128, 128, 0.18)');
expect(inactiveOverlay['pointer-events']).toBe('none');
});

it('does not draw an accent glow around the active task panel', () => {
const activeTaskColumn = declarationsFor('.task-column.active');

expect(activeTaskColumn['box-shadow']).toBeUndefined();
expect(activeTaskColumn.opacity).toBe('1');
});

it('does not draw an accent outline around a collapsed active panel group', () => {
expect(tilingLayoutSource).not.toContain('groupCollapsed() && groupHasActive()');
expect(tilingLayoutSource).not.toContain('inset 0 0 0 2px ${theme.accent}');
});
});
181 changes: 181 additions & 0 deletions src/components/TilingLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, expect, it, vi } from 'vitest';

vi.mock('../lib/terminalFitManager', () => ({
markDirty: vi.fn(),
}));

import {
buildGroupPanelEntriesForTest,
buildRenderSegmentsForTest,
groupResizeHandleIndexForTest,
groupPanelInnerHandleHiddenForTest,
groupWrapperPaddingForTest,
hiddenPanelContentStyleForTest,
isPanelGroupCollapseControlVisibleForTest,
isPanelGroupCollapsibleForTest,
isGroupPanelHiddenForTest,
} from './TilingLayout';

describe('TilingLayout panel group collapse planning', () => {
it('keeps the first task panel visible when a group is collapsed', () => {
const entries = buildGroupPanelEntriesForTest({
projectId: 'project-1',
groupType: 'independent',
panelIds: ['task-1', 'task-2'],
color: '#334455',
collapsed: true,
});

expect(entries.map((entry) => entry.id)).toEqual(['task-1', 'task-2']);
expect(entries.filter((entry) => entry.type === 'panel').map((entry) => entry.hidden)).toEqual([
false,
true,
]);
expect(entries.some((entry) => entry.type !== 'panel')).toBe(false);
});

it('does not add a collapsed placeholder for expanded groups', () => {
const entries = buildGroupPanelEntriesForTest({
projectId: 'project-1',
groupType: 'independent',
panelIds: ['task-1', 'task-2'],
color: '#334455',
collapsed: false,
});

expect(entries.map((entry) => entry.id)).toEqual(['task-1', 'task-2']);
expect(entries.map((entry) => entry.hidden)).toEqual([false, false]);
});

it('derives hidden state from the current collapsed state', () => {
const [, secondEntry] = buildGroupPanelEntriesForTest({
projectId: 'project-1',
groupType: 'independent',
panelIds: ['task-1', 'task-2'],
color: '#334455',
collapsed: false,
});

expect(isGroupPanelHiddenForTest(secondEntry.groupInfo, false)).toBe(false);
expect(isGroupPanelHiddenForTest(secondEntry.groupInfo, true)).toBe(true);
});

it('keeps the group background padding when collapsed', () => {
expect(groupWrapperPaddingForTest(false)).toBe('0 6px');
expect(groupWrapperPaddingForTest(true)).toBe('0 6px');
});

it('anchors the collapsed group resize handle to the visible first panel', () => {
expect(groupResizeHandleIndexForTest(3, 5, false)).toBe(5);
expect(groupResizeHandleIndexForTest(3, 5, true)).toBe(3);
});

it('hides handles between task panels while a group is collapsed', () => {
expect(groupPanelInnerHandleHiddenForTest(false, false)).toBe(false);
expect(groupPanelInnerHandleHiddenForTest(true, false)).toBe(true);
expect(groupPanelInnerHandleHiddenForTest(true, true)).toBe(true);
});

it('supports deriving inner handle visibility after collapsed state changes', () => {
let collapsed = false;
const hideHandle = () => groupPanelInnerHandleHiddenForTest(collapsed, false);

expect(hideHandle()).toBe(false);
collapsed = true;
expect(hideHandle()).toBe(true);
});

it('does not allow a single task panel group to collapse', () => {
expect(isPanelGroupCollapsibleForTest(1)).toBe(false);
expect(isPanelGroupCollapsibleForTest(2)).toBe(true);
});

it('hides the collapse control for a single task panel group', () => {
expect(isPanelGroupCollapseControlVisibleForTest(false, false, true, 1)).toBe(false);
expect(isPanelGroupCollapseControlVisibleForTest(false, false, true, 2)).toBe(true);
});

it('changes segment keys when a single slot becomes a grouped project panel', () => {
const before = buildRenderSegmentsForTest([
{ id: 'task-1', groupInfo: undefined },
{ id: '__placeholder', groupInfo: undefined },
]);
const after = buildRenderSegmentsForTest([
{
id: 'task-1',
groupInfo: {
projectId: 'project-1',
groupType: 'independent',
isFirst: true,
isLast: false,
panelCount: 2,
color: '#334455',
},
},
{
id: 'task-2',
groupInfo: {
projectId: 'project-1',
groupType: 'independent',
isFirst: false,
isLast: true,
panelCount: 2,
color: '#334455',
},
},
{ id: '__placeholder', groupInfo: undefined },
]);

expect(before[0]).toMatchObject({ type: 'single', key: 'single:task-1' });
expect(after[0]).toMatchObject({ type: 'group', key: 'group:project-1:independent' });
});

it('reuses unchanged render segment objects so collapsed groups do not remount panels', () => {
const items = [
{
id: 'task-1',
groupInfo: {
projectId: 'project-1',
groupType: 'independent' as const,
isFirst: true,
isLast: false,
panelCount: 2,
color: '#334455',
},
},
{
id: 'task-2',
groupInfo: {
projectId: 'project-1',
groupType: 'independent' as const,
isFirst: false,
isLast: true,
panelCount: 2,
color: '#334455',
},
},
{ id: '__placeholder', groupInfo: undefined },
];

const before = buildRenderSegmentsForTest(items);
const after = buildRenderSegmentsForTest(items);

expect(after[0]).toBe(before[0]);
expect(after[1]).toBe(before[1]);
});

it('preserves hidden panel content width so terminals are not resized to zero', () => {
expect(hiddenPanelContentStyleForTest(true, 520)).toMatchObject({
width: '520px',
height: '100%',
visibility: 'hidden',
'pointer-events': 'none',
});
expect(hiddenPanelContentStyleForTest(false, 520)).toMatchObject({
width: '100%',
height: '100%',
});
expect(hiddenPanelContentStyleForTest(false, 520)).not.toHaveProperty('visibility');
expect(hiddenPanelContentStyleForTest(false, 520)).not.toHaveProperty('pointer-events');
});
});
Loading
Loading