fix(timeline): clear multi-select on drag start and lock in SSE edge-case coverage

- useTimelineDrag: onProjectBarMouseDown and single-alloc drag path now reset
  multiSelectRef + multiSelectState before starting a new drag, so the
  FloatingActionBar is dismissed immediately when an unrelated drag begins
- FloatingActionBar.test.tsx: 4 regression tests for the null-render guard
  (count=0) and all three label variants
- useTimelineSSE.test.ts: 2 new tests — tab hides during pending reconnect
  timer (clears timer, resyncs on next open) and first-ever connection fails
  before any open (retry open still resyncs correctly)
- assistant-tools-user-admin-inventory-read.test.ts: add isActive to expected
  findMany select shape (already in production, test was stale)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-02 21:16:10 +02:00
parent 8d9e26872b
commit e7e525df49
6 changed files with 231 additions and 63 deletions
+4
View File
@@ -496,6 +496,8 @@ export function useTimelineDrag({
},
) => {
if (e.button !== 0) return;
multiSelectRef.current = INITIAL_MULTI_SELECT;
setMultiSelectState(INITIAL_MULTI_SELECT);
e.preventDefault();
e.stopPropagation();
const state = createProjectDragState<DragState>({
@@ -610,6 +612,8 @@ export function useTimelineDrag({
// ── Single allocation drag ────────────────────────────────────────────
multiSelectRef.current = INITIAL_MULTI_SELECT;
setMultiSelectState(INITIAL_MULTI_SELECT);
const initial = createAllocationDragState<AllocDragState, AllocDragMode, AllocDragScope>({
mode: opts.mode,
scope: opts.scope,
+54
View File
@@ -240,4 +240,58 @@ describe("useTimelineSSE", () => {
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
);
});
it("hides during pending reconnect timer — clears timer and resyncs on next open", () => {
useTimelineSSE();
const firstConnection = MockEventSource.instances[0];
expect(firstConnection).toBeDefined();
// Establish connection, then break it — schedules a 2s reconnect timer
firstConnection?.emitOpen();
firstConnection?.emitError(); // shouldResyncOnOpen = true, timer scheduled
// Tab hides while timer is still pending (no active `es`)
mockDocument.setVisibility("hidden"); // clears timer, shouldResyncOnOpen stays true
// No timer should fire — still only one EventSource
vi.advanceTimersByTime(5_000);
expect(MockEventSource.instances).toHaveLength(1);
// Tab becomes visible — should connect fresh
mockDocument.setVisibility("visible");
expect(MockEventSource.instances).toHaveLength(2);
// Successful open on the new connection → resync
const secondConnection = MockEventSource.instances[1];
secondConnection?.emitOpen();
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
expect(invalidateQueries.mock.calls).toEqual(
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
);
});
it("resyncs after the first-ever connection attempt fails before any open", () => {
useTimelineSSE();
const firstConnection = MockEventSource.instances[0];
expect(firstConnection).toBeDefined();
// Error fires without any preceding open — shouldResyncOnOpen becomes true
firstConnection?.emitError();
// Advance past the 2s backoff
vi.advanceTimersByTime(2_000);
expect(MockEventSource.instances).toHaveLength(2);
// Retry succeeds
const secondConnection = MockEventSource.instances[1];
secondConnection?.emitOpen();
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
expect(invalidateQueries.mock.calls).toEqual(
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
);
});
});