mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(tabs): COD-359 add owner-scoped tab layouts
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @fileoverview Pure compatibility translation between legacy session order and owner tab layouts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyLegacySessionRank,
|
||||
recomposeGlobalSessionOrder,
|
||||
type OwnerOrderProjection,
|
||||
} from '../src/tab-layout-legacy-order.js';
|
||||
import { normalizeTabLayout, validateTabLayout, type TabLayout, type TabRefMetadata } from '../src/tab-layout.js';
|
||||
|
||||
const session = (id: string, placement?: 'manual') =>
|
||||
placement ? { kind: 'session' as const, id, placement } : { kind: 'session' as const, id };
|
||||
const webview = (id: string) => ({ kind: 'webview' as const, id });
|
||||
const metadata = (id: string, overrides: Partial<TabRefMetadata> = {}): TabRefMetadata => ({
|
||||
kind: 'session',
|
||||
id,
|
||||
ownerValid: true,
|
||||
visible: true,
|
||||
order: 0,
|
||||
...overrides,
|
||||
});
|
||||
const layout = (overrides: Partial<TabLayout> = {}): TabLayout => ({
|
||||
version: 1,
|
||||
groups: [],
|
||||
ungrouped: [],
|
||||
updatedAt: '2026-08-23T12:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('applyLegacySessionRank', () => {
|
||||
it('ranks only authoritative sessions while anchoring webviews and refs absent from metadata', () => {
|
||||
const input = layout({
|
||||
ungrouped: [session('a'), webview('w1'), session('unknown'), session('b')],
|
||||
});
|
||||
|
||||
expect(applyLegacySessionRank(input, ['b', 'a'], [metadata('a'), metadata('b')])).toEqual(
|
||||
layout({
|
||||
ungrouped: [session('b'), webview('w1'), session('unknown'), session('a')],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ranks sessions only inside their current containers without changing groups or webview slots', () => {
|
||||
const input = layout({
|
||||
groups: [
|
||||
{ id: 'first', name: 'First', refs: [session('a'), webview('w1'), session('d')] },
|
||||
{ id: 'second', name: 'Second', refs: [session('b'), webview('w2'), session('c')] },
|
||||
],
|
||||
ungrouped: [session('e'), webview('w3'), session('f')],
|
||||
});
|
||||
const facts = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, order) => metadata(id, { order }));
|
||||
|
||||
const result = applyLegacySessionRank(input, ['f', 'c', 'd', 'e', 'b', 'a'], facts);
|
||||
|
||||
expect(result.groups).toEqual([
|
||||
{ id: 'first', name: 'First', refs: [session('d'), webview('w1'), session('a')] },
|
||||
{ id: 'second', name: 'Second', refs: [session('c'), webview('w2'), session('b')] },
|
||||
]);
|
||||
expect(result.ungrouped).toEqual([session('f'), webview('w3'), session('e')]);
|
||||
});
|
||||
|
||||
it('materializes ranked children with represented owner-valid parents before normalization', () => {
|
||||
const input = layout({
|
||||
groups: [
|
||||
{
|
||||
id: 'family',
|
||||
name: 'Family',
|
||||
refs: [session('parent'), session('child'), session('other')],
|
||||
},
|
||||
],
|
||||
});
|
||||
const facts = [
|
||||
metadata('parent', { order: 0 }),
|
||||
metadata('child', { order: 1, parentSessionId: 'parent' }),
|
||||
metadata('other', { order: 2 }),
|
||||
];
|
||||
|
||||
const result = applyLegacySessionRank(input, ['child', 'other', 'parent'], facts);
|
||||
|
||||
expect(result.groups[0].refs).toEqual([session('child', 'manual'), session('other'), session('parent')]);
|
||||
expect(normalizeTabLayout(result, facts)).toEqual(result);
|
||||
});
|
||||
|
||||
it('keeps a ranked child manual in its group when normalization materializes its missing parent', () => {
|
||||
const input = layout({
|
||||
groups: [{ id: 'child-group', name: 'Child', refs: [session('child')] }],
|
||||
});
|
||||
const facts = [metadata('parent', { order: 0 }), metadata('child', { order: 1, parentSessionId: 'parent' })];
|
||||
|
||||
const result = applyLegacySessionRank(input, ['child', 'parent'], facts);
|
||||
|
||||
expect(result.groups[0].refs).toEqual([session('child', 'manual')]);
|
||||
expect(result.ungrouped).toEqual([session('parent')]);
|
||||
expect(normalizeTabLayout(result, facts)).toEqual(result);
|
||||
});
|
||||
|
||||
it('does not mutate inputs and returns a validated canonical deep clone', () => {
|
||||
const input = layout({
|
||||
groups: [{ id: 'g', name: ' Work ', refs: [session('a'), webview('w')] }],
|
||||
ungrouped: [session('b')],
|
||||
});
|
||||
const requested = ['b', 'a'];
|
||||
const facts = [metadata('a', { order: 0 }), metadata('b', { order: 1 })];
|
||||
const beforeInput = structuredClone(input);
|
||||
const beforeRequested = [...requested];
|
||||
const beforeFacts = structuredClone(facts);
|
||||
|
||||
const result = applyLegacySessionRank(input, requested, facts);
|
||||
|
||||
expect(input).toEqual(beforeInput);
|
||||
expect(requested).toEqual(beforeRequested);
|
||||
expect(facts).toEqual(beforeFacts);
|
||||
expect(result.groups[0].name).toBe('Work');
|
||||
expect(validateTabLayout(result)).toEqual(result);
|
||||
expect(result).not.toBe(input);
|
||||
expect(result.groups[0]).not.toBe(input.groups[0]);
|
||||
expect(result.groups[0].refs[0]).not.toBe(input.groups[0].refs[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recomposeGlobalSessionOrder', () => {
|
||||
it('replaces only owner slots, preserves interleaving, appends new IDs once, and deduplicates', () => {
|
||||
const current = ['a1', 'foreign', 'b1', 'a2', 'unmapped', 'a1'];
|
||||
const projections: OwnerOrderProjection[] = [
|
||||
{
|
||||
owner: 'alice',
|
||||
ownedIds: ['a1', 'a2', 'a3', 'a3'],
|
||||
order: ['a3', 'a2', 'a2', 'a1', 'foreign'],
|
||||
},
|
||||
];
|
||||
const beforeCurrent = [...current];
|
||||
const beforeProjections = structuredClone(projections);
|
||||
|
||||
const result = recomposeGlobalSessionOrder(current, projections);
|
||||
|
||||
expect(result).toEqual(['a3', 'foreign', 'b1', 'a2', 'unmapped', 'a1']);
|
||||
expect(current).toEqual(beforeCurrent);
|
||||
expect(projections).toEqual(beforeProjections);
|
||||
});
|
||||
|
||||
it('starts from merged preferred order when preserving admin cross-owner intent', () => {
|
||||
expect(
|
||||
recomposeGlobalSessionOrder(
|
||||
['a1', 'b1', 'a2', 'server-only'],
|
||||
[{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }],
|
||||
['b1', 'a1', 'a2']
|
||||
)
|
||||
).toEqual(['b1', 'a2', 'a1', 'server-only']);
|
||||
});
|
||||
|
||||
it('derives sequential owner projections from the latest order without a lost update', () => {
|
||||
const initial = ['a1', 'b1', 'a2', 'b2'];
|
||||
const afterAlice = recomposeGlobalSessionOrder(initial, [
|
||||
{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] },
|
||||
]);
|
||||
const afterBob = recomposeGlobalSessionOrder(afterAlice, [
|
||||
{ owner: 'bob', ownedIds: ['b1', 'b2'], order: ['b2', 'b1'] },
|
||||
]);
|
||||
|
||||
expect(afterAlice).toEqual(['a2', 'b1', 'a1', 'b2']);
|
||||
expect(afterBob).toEqual(['a2', 'b2', 'a1', 'b1']);
|
||||
});
|
||||
|
||||
it('handles prototype-like owner names and IDs without corrupting membership', () => {
|
||||
expect(
|
||||
recomposeGlobalSessionOrder(
|
||||
['__proto__', 'foreign', 'constructor'],
|
||||
[
|
||||
{
|
||||
owner: 'constructor',
|
||||
ownedIds: ['__proto__', 'constructor', 'toString'],
|
||||
order: ['toString', 'constructor', '__proto__'],
|
||||
},
|
||||
]
|
||||
)
|
||||
).toEqual(['toString', 'foreign', 'constructor', '__proto__']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user