Files
Codeman/test/session-order.test.ts
Aamer AkhterandClaude Opus 4.8 8807b3ff6d COD-131 sync tab order across devices via server state
Tab reordering (drag-and-drop + Ctrl+Shift+{/}) persisted only to
localStorage (codeman-session-order), so each device kept its own private
order. Add server-side persistence so the order follows the user across
devices, live. Takes the issue's recommended default (a): one global order,
server authoritative, localStorage as offline fallback.

- session-order.ts (new, pure + unit-tested): normalizeSessionOrder (coerce
  to string[], drop empty/non-string, dedup) and mergeSessionOrder (the
  pushing device's order wins; ids the device hadn't loaded fall to the end
  in their existing relative order, never dropped — graceful for
  closed/remote/parked sessions absent on that device).
- AppState.sessionOrder?: string[]; StateStore get/setSessionOrder + the field
  added to buildPartialJson() (the incremental serializer whitelists fields,
  so without this the value never reached disk / survived a restart).
- PUT /api/session-order (session-routes): parse -> merge -> persist ->
  broadcast session:orderChanged; getLightState() init snapshot now carries
  sessionOrder so a fresh load/reconnect restores it.
- SSE event session:orderChanged registered in sse-events.ts + constants.js.
- app.js: handleInit seeds localStorage from the server snapshot before
  syncSessionOrder(); saveSessionOrder() also PUTs to the server (debounced
  400ms, covers drag + both keyboard moves); _onSessionOrderChanged adopts a
  remote order and re-renders (no-op-guarded to avoid echo flicker).

Verified (orchestrator re-ran all gates): tsc 0, lint 0, frontend-syntax +
prettier clean, build ok; session-order + session-order-routes + state-store
56/56. Functional round-trip on an isolated beta: PUT {a,b,c} -> status
snapshot reflects it; merge PUT {c,a} vs {a,b,c} -> {c,a,b} (b preserved at
end); malformed payload rejected with a clean 400; sessionOrder persisted to
state.json and survived a restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

(cherry picked from commit 79415f2fdfbdf3fbe362a063534e7f84c553eefb)
2026-07-17 16:21:16 -04:00

75 lines
2.7 KiB
TypeScript

/**
* @fileoverview Unit tests for the pure session-order helpers
* (normalizeSessionOrder, mergeSessionOrder) used by global tab-order sync (COD-131).
*/
import { describe, it, expect } from 'vitest';
import { normalizeSessionOrder, mergeSessionOrder } from '../src/session-order.js';
describe('normalizeSessionOrder', () => {
it('keeps a clean array unchanged', () => {
expect(normalizeSessionOrder(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('dedups, first occurrence wins', () => {
expect(normalizeSessionOrder(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']);
});
it('drops empty strings', () => {
expect(normalizeSessionOrder(['a', '', 'b', ' '])).toEqual(['a', 'b', ' ']);
expect(normalizeSessionOrder([''])).toEqual([]);
});
it('drops non-string entries', () => {
expect(normalizeSessionOrder(['a', 1, null, undefined, {}, 'b', true])).toEqual(['a', 'b']);
});
it('returns [] for non-array input', () => {
expect(normalizeSessionOrder(undefined)).toEqual([]);
expect(normalizeSessionOrder(null)).toEqual([]);
expect(normalizeSessionOrder('abc')).toEqual([]);
expect(normalizeSessionOrder(42)).toEqual([]);
expect(normalizeSessionOrder({ 0: 'a' })).toEqual([]);
});
it('returns [] for empty array', () => {
expect(normalizeSessionOrder([])).toEqual([]);
});
});
describe('mergeSessionOrder', () => {
it('incoming order wins', () => {
expect(mergeSessionOrder(['c', 'a', 'b'], ['a', 'b', 'c'])).toEqual(['c', 'a', 'b']);
});
it('appends server-only ids (not in incoming) at the end, preserving their relative order', () => {
expect(mergeSessionOrder(['a', 'b'], ['x', 'a', 'y', 'b', 'z'])).toEqual(['a', 'b', 'x', 'y', 'z']);
});
it('empty incoming yields the existing order (normalized)', () => {
expect(mergeSessionOrder([], ['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('empty existing yields the incoming order (normalized)', () => {
expect(mergeSessionOrder(['a', 'b', 'c'], [])).toEqual(['a', 'b', 'c']);
});
it('both empty yields empty', () => {
expect(mergeSessionOrder([], [])).toEqual([]);
});
it('normalizes both args (dedup + drop junk) before merging', () => {
expect(mergeSessionOrder(['a', 'a', '', 'b'], ['b', 'c', 'c', ''])).toEqual(['a', 'b', 'c']);
});
it('does not duplicate an id present in both', () => {
expect(mergeSessionOrder(['a', 'b'], ['b', 'a'])).toEqual(['a', 'b']);
});
it('handles non-array / junk inputs defensively', () => {
// @ts-expect-error testing runtime robustness against bad input
expect(mergeSessionOrder(null, ['a', 'b'])).toEqual(['a', 'b']);
// @ts-expect-error testing runtime robustness against bad input
expect(mergeSessionOrder(['a'], 'nope')).toEqual(['a']);
});
});