/** * @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 => ({ kind: 'session', id, ownerValid: true, visible: true, order: 0, ...overrides, }); const layout = (overrides: Partial = {}): 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__']); }); });