Files
Codeman/test/tab-layout.test.ts

384 lines
16 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
MAX_TAB_GROUP_NAME_LENGTH,
MAX_TAB_GROUPS,
MAX_TAB_REFS,
createGroup,
deleteGroup,
flattenOwnerSessionOrder,
flattenVisibleRefs,
followParent,
materializeOrphans,
moveRef,
normalizeTabLayout,
renameGroup,
reorderGroup,
setManualPlacement,
validateTabLayout,
type TabLayout,
type TabRefMetadata,
} from '../src/tab-layout.js';
const now = '2026-08-16T12:00:00.000Z';
const ref = (id: string, kind: 'session' | 'webview' = 'session') => ({ kind, id }) as const;
const session = (id: string, order: number, parentSessionId?: string): TabRefMetadata => ({
kind: 'session',
id,
order,
parentSessionId,
ownerValid: true,
visible: true,
});
const webview = (id: string, order: number): TabRefMetadata => ({
kind: 'webview',
id,
order,
ownerValid: true,
visible: true,
});
const layout = (groups: TabLayout['groups'] = [], ungrouped: TabLayout['ungrouped'] = []): TabLayout => ({
version: 3,
groups,
ungrouped,
updatedAt: now,
});
describe('tab layout validation and normalization', () => {
it('exports and enforces group, name, and raw ref ceilings at their boundaries', () => {
expect(MAX_TAB_GROUPS).toBe(32);
expect(MAX_TAB_GROUP_NAME_LENGTH).toBe(60);
expect(MAX_TAB_REFS).toBe(512);
const groups = Array.from({ length: MAX_TAB_GROUPS }, (_, index) => ({ id: `g${index}`, name: 'x', refs: [] }));
expect(validateTabLayout(layout(groups)).groups).toHaveLength(MAX_TAB_GROUPS);
expect(() => validateTabLayout(layout([...groups, { id: 'overflow', name: 'x', refs: [] }]))).toThrow(/32/);
expect(
validateTabLayout(layout([{ id: 'g', name: ` ${'x'.repeat(60)} `, refs: [] }])).groups[0].name
).toHaveLength(60);
expect(() => validateTabLayout(layout([{ id: 'g', name: 'x'.repeat(61), refs: [] }]))).toThrow(/60/);
const refs = Array.from({ length: MAX_TAB_REFS }, (_, index) => ref(`s${index}`));
expect(validateTabLayout(layout([], refs)).ungrouped).toHaveLength(MAX_TAB_REFS);
expect(() => validateTabLayout(layout([], [...refs, ref('overflow')]))).toThrow(/512/);
});
it('rejects malformed input deterministically and trims names', () => {
expect(() => validateTabLayout({ ...layout(), version: -1 })).toThrow(/version/);
expect(() => validateTabLayout(layout([{ id: 'g', name: ' ', refs: [] }]))).toThrow(/name/);
expect(() =>
validateTabLayout(
layout([
{ id: 'g', name: 'x', refs: [] },
{ id: 'g', name: 'y', refs: [] },
])
)
).toThrow(/duplicate group/);
expect(() => validateTabLayout(layout([], [{ kind: 'other', id: 'x' } as never]))).toThrow(/kind/);
expect(() =>
validateTabLayout(layout([{ id: 'g', name: 'G', refs: [ref('duplicate')] }], [ref('duplicate')]))
).toThrow(/duplicate ref/);
expect(
validateTabLayout(layout([{ id: 'g', name: 'G', refs: [ref('same')] }], [ref('same', 'webview')])).ungrouped
).toEqual([ref('same', 'webview')]);
expect(renameGroup(layout([{ id: 'g', name: ' Before ', refs: [] }]), 'g', ' After ').groups[0].name).toBe(
'After'
);
});
it('deduplicates first occurrence, namespaces kinds, and repairs missing valid refs in stable order', () => {
const input = layout(
[{ id: 'g', name: 'G', refs: [ref('same'), ref('same'), ref('same', 'webview')] }],
[ref('same'), ref('later')]
);
const normalized = normalizeTabLayout(input, [
session('same', 1),
webview('same', 2),
session('later', 3),
session('new', 4),
]);
expect(normalized.groups[0].refs).toEqual([ref('same'), ref('same', 'webview')]);
expect(normalized.ungrouped).toEqual([ref('later'), ref('new')]);
});
it('appends new roots and webviews and ignores metadata that is not owner-valid or visible', () => {
const metadata: TabRefMetadata[] = [
session('root', 2),
webview('dash', 3),
{ ...session('foreign', 0), ownerValid: false },
{ ...session('hidden-by-owner', 1), visible: false },
];
expect(normalizeTabLayout(layout(), metadata).ungrouped).toEqual([ref('root'), ref('dash', 'webview')]);
});
it('preserves metadata-unknown stored refs while removing explicitly invalid refs', () => {
const input = layout([], [ref('unknown'), ref('unknown-web', 'webview'), ref('foreign')]);
const metadata: TabRefMetadata[] = [{ ...session('foreign', 0), ownerValid: false }, session('new', 1)];
expect(normalizeTabLayout(input, metadata).ungrouped).toEqual([
ref('unknown'),
ref('unknown-web', 'webview'),
ref('new'),
]);
});
it('rejects a missing valid ref above the 512-ref ceiling without truncating the stored layout', () => {
const storedRefs = Array.from({ length: MAX_TAB_REFS }, (_, index) => ref(`s${index}`));
const input = layout([], storedRefs);
const snapshot = structuredClone(input);
const metadata = [
...storedRefs.map((stored, index) => session(stored.id, index)),
session('missing-overflow', MAX_TAB_REFS),
];
expect(() => normalizeTabLayout(input, metadata)).toThrow(/512/);
expect(input).toEqual(snapshot);
expect(input.ungrouped).toHaveLength(MAX_TAB_REFS);
});
it('does not mutate caller input', () => {
const input = layout([{ id: 'g', name: ' G ', refs: [ref('child')] }], [ref('parent')]);
const snapshot = structuredClone(input);
normalizeTabLayout(input, [session('parent', 0), session('child', 1, 'parent')]);
moveRef(input, ref('parent'), { groupId: 'g', index: 0 }, [session('parent', 0), session('child', 1, 'parent')]);
expect(input).toEqual(snapshot);
});
});
describe('tab group operations', () => {
it('creates, renames, reorders, and deletes groups without deleting refs', () => {
let value = layout([{ id: 'a', name: 'A', refs: [ref('one')] }], [ref('loose')]);
value = createGroup(value, { id: 'b', name: ' Bee ', index: 0 });
expect(value.groups.map((group) => [group.id, group.name])).toEqual([
['b', 'Bee'],
['a', 'A'],
]);
value = renameGroup(value, 'b', ' B ');
value = reorderGroup(value, 'a', 0);
expect(value.groups.map((group) => group.id)).toEqual(['a', 'b']);
value = deleteGroup(value, 'a');
expect(value.groups.map((group) => group.id)).toEqual(['b']);
expect(value.ungrouped).toEqual([ref('loose'), ref('one')]);
});
it('moves refs within, between, and into ungrouped', () => {
const metadata = [session('a', 0), session('b', 1), session('c', 2)];
let value = layout([{ id: 'g', name: 'G', refs: [ref('a'), ref('b')] }], [ref('c')]);
value = moveRef(value, ref('b'), { groupId: 'g', index: 0 }, metadata);
expect(value.groups[0].refs).toEqual([ref('b'), ref('a')]);
value = moveRef(value, ref('a'), { groupId: null, index: 1 }, metadata);
expect(value.ungrouped).toEqual([ref('c'), ref('a')]);
value = moveRef(value, ref('c'), { groupId: 'g', index: 1 }, metadata);
expect(value.groups[0].refs).toEqual([ref('b'), ref('c')]);
});
});
describe('session lineage', () => {
it('normalizes nested lineage into preorder and preserves stable sibling order', () => {
const metadata = [
session('root', 0),
session('child-b', 1, 'root'),
session('grandchild', 2, 'child-b'),
session('child-a', 3, 'root'),
];
const value = normalizeTabLayout(
layout([{ id: 'g', name: 'G', refs: [ref('root')] }], [ref('child-b'), ref('grandchild'), ref('child-a')]),
metadata
);
expect(value.groups[0].refs).toEqual([ref('root'), ref('child-b'), ref('grandchild'), ref('child-a')]);
});
it('inserts a missing new child after the existing descendant subtree', () => {
const value = normalizeTabLayout(layout([], [ref('root'), ref('child'), ref('grandchild')]), [
session('root', 0),
session('child', 1, 'root'),
session('grandchild', 2, 'child'),
session('new-child', 3, 'root'),
]);
expect(value.ungrouped).toEqual([ref('root'), ref('child'), ref('grandchild'), ref('new-child')]);
});
it('moves a parent as one preorder block', () => {
const metadata = [
session('root', 0),
session('child', 1, 'root'),
session('grandchild', 2, 'child'),
session('other', 3),
];
const input = layout(
[{ id: 'g', name: 'G', refs: [ref('other')] }],
[ref('root'), ref('child'), ref('grandchild')]
);
const value = moveRef(input, ref('root'), { groupId: 'g', index: 0 }, metadata);
expect(value.groups[0].refs).toEqual([ref('root'), ref('child'), ref('grandchild'), ref('other')]);
expect(value.ungrouped).toEqual([]);
});
it('moves a parent subtree within one container without disturbing surrounding sibling order', () => {
const metadata = [
session('before', 0),
session('root', 1),
session('child', 2, 'root'),
session('grandchild', 3, 'child'),
session('after', 4),
];
const input = layout([
{
id: 'g',
name: 'G',
refs: [ref('before'), ref('root'), ref('child'), ref('grandchild'), ref('after')],
},
]);
const value = moveRef(input, ref('root'), { groupId: 'g', index: 2 }, metadata);
expect(value.groups[0].refs).toEqual([ref('before'), ref('after'), ref('root'), ref('child'), ref('grandchild')]);
});
it('makes an independently moved child manual and followParent restores inheritance', () => {
const metadata = [session('root', 0), session('child', 1, 'root'), session('grandchild', 2, 'child')];
let value = layout([{ id: 'g', name: 'G', refs: [ref('root'), ref('child'), ref('grandchild')] }]);
value = moveRef(value, ref('child'), { groupId: null, index: 0 }, metadata);
expect(value.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('grandchild')]);
value = followParent(value, ref('child'), metadata);
expect(value.groups[0].refs).toEqual([ref('root'), ref('child'), ref('grandchild')]);
expect(value.ungrouped).toEqual([]);
});
it('sets manual placement but rejects direct clearing outside followParent', () => {
const input = layout([], [ref('a')]);
expect(setManualPlacement(input, ref('a'), true).ungrouped[0]).toEqual({ ...ref('a'), placement: 'manual' });
expect(() => setManualPlacement(setManualPlacement(input, ref('a'), true), ref('a'), false)).toThrow(
/followParent/
);
});
it('rejects stale follow-parent replay and keeps the child manual across later parent ID reuse', () => {
const materialized = layout([], [{ ...ref('child'), placement: 'manual' }]);
const staleMetadata = [session('child', 1, 'deleted-parent')];
expect(() => followParent(materialized, ref('child'), staleMetadata)).toThrow(/parent/);
expect(materialized.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }]);
const afterIdReuse = normalizeTabLayout(materialized, [
session('deleted-parent', 2),
session('child', 1, 'deleted-parent'),
]);
expect(afterIdReuse.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('deleted-parent')]);
});
it('rejects follow-parent for roots and webviews', () => {
expect(() =>
followParent(layout([], [{ ...ref('root'), placement: 'manual' }]), ref('root'), [session('root', 0)])
).toThrow(/parent/);
expect(() =>
followParent(layout([], [{ ...ref('dash', 'webview'), placement: 'manual' }]), ref('dash', 'webview'), [
webview('dash', 0),
])
).toThrow(/session/);
});
it.each([
{ label: 'invalid', parent: { ...session('parent', 0), ownerValid: false } },
{ label: 'invisible', parent: { ...session('parent', 0), visible: false } },
])('rejects follow-parent when the parent is $label', ({ parent }) => {
const input = layout([], [ref('parent'), { ...ref('child'), placement: 'manual' }]);
expect(() => followParent(input, ref('child'), [parent, session('child', 1, 'parent')])).toThrow(/parent/);
});
it('breaks the repeated cycle edge deterministically at its current location', () => {
const value = normalizeTabLayout(layout([], [ref('a'), ref('b')]), [session('a', 0, 'b'), session('b', 1, 'a')]);
expect(value.ungrouped).toEqual([{ ...ref('b'), placement: 'manual' }, ref('a')]);
});
it('breaks a multi-node cross-container cycle deterministically', () => {
const metadata = [session('a', 0, 'b'), session('b', 1, 'c'), session('c', 2, 'a'), session('tail', 3)];
const input = layout(
[
{ id: 'one', name: 'One', refs: [ref('a')] },
{ id: 'two', name: 'Two', refs: [ref('b')] },
],
[ref('c'), ref('tail')]
);
const first = normalizeTabLayout(input, metadata);
expect(first.groups.map((group) => group.refs)).toEqual([[], []]);
expect(first.ungrouped).toEqual([{ ...ref('c'), placement: 'manual' }, ref('b'), ref('a'), ref('tail')]);
expect(normalizeTabLayout(first, metadata)).toEqual(first);
});
it('materializes direct orphans and prevents a restored ID from re-adopting them', () => {
const metadata = [session('parent', 0), session('child', 1, 'parent'), session('grandchild', 2, 'child')];
let value = layout([], [ref('parent'), ref('child'), ref('grandchild')]);
value = materializeOrphans(value, ['parent'], metadata);
value = normalizeTabLayout(value, metadata);
expect(value.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('grandchild'), ref('parent')]);
});
it('materializes a direct orphan in its grouped location amid surrounding refs', () => {
const metadata = [
session('before', 0),
session('parent', 1),
session('child', 2, 'parent'),
session('grandchild', 3, 'child'),
session('after', 4),
];
const input = layout([
{
id: 'g',
name: 'G',
refs: [ref('before'), ref('parent'), ref('child'), ref('grandchild'), ref('after')],
},
]);
const materialized = materializeOrphans(input, ['parent'], metadata);
expect(materialized.groups[0].refs).toEqual([
ref('before'),
{ ...ref('child'), placement: 'manual' },
ref('grandchild'),
ref('after'),
]);
const restored = normalizeTabLayout(materialized, metadata);
expect(restored.groups[0].refs).toEqual([
ref('before'),
{ ...ref('child'), placement: 'manual' },
ref('grandchild'),
ref('after'),
]);
expect(restored.ungrouped).toEqual([ref('parent')]);
});
});
describe('flatten projections', () => {
const value = layout(
[
{ id: 'collapsed', name: 'Collapsed', refs: [ref('s1'), ref('closed', 'webview'), ref('s2')] },
{ id: 'open', name: 'Open', refs: [ref('s3'), ref('dash', 'webview')] },
],
[ref('s4')]
);
it('projects every owner session in layout order, including collapse-hidden members', () => {
expect(flattenOwnerSessionOrder(value)).toEqual(['s1', 's2', 's3', 's4']);
});
it('projects renderable refs, omits unopened webviews, and keeps a collapsed highlight visible', () => {
expect(
flattenVisibleRefs(value, {
liveSessionIds: new Set(['s1', 's2', 's3', 's4']),
openWebviewIds: new Set(['dash']),
collapsedGroupIds: new Set(['collapsed']),
highlighted: ref('s2'),
})
).toEqual([ref('s2'), ref('s3'), ref('dash', 'webview'), ref('s4')]);
});
it('keeps a highlighted open webview visible inside a collapsed group', () => {
expect(
flattenVisibleRefs(value, {
liveSessionIds: new Set(['s1', 's2', 's3', 's4']),
openWebviewIds: new Set(['closed', 'dash']),
collapsedGroupIds: new Set(['collapsed']),
highlighted: ref('closed', 'webview'),
})
).toEqual([ref('closed', 'webview'), ref('s3'), ref('dash', 'webview'), ref('s4')]);
});
});