mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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)
75 lines
2.7 KiB
TypeScript
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']);
|
|
});
|
|
});
|