feat(client): open views follow member, settings, tag, atlas and journey changes made elsewhere

This commit is contained in:
Maurice
2026-10-22 12:14:28 +02:00
parent b24a82fe33
commit 8d4d3423ff
53 changed files with 1269 additions and 133 deletions
@@ -0,0 +1,176 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { offlineDb } from '../../../src/db/offlineDb';
import { useAuthStore } from '../../../src/store/authStore';
import { useTripStore } from '../../../src/store/tripStore';
import {
buildAssignment,
buildBudgetItem,
buildDay,
buildPackingItem,
buildReservation,
buildTodoItem,
buildTrip,
} from '../../helpers/factories';
import { resetAllStores } from '../../helpers/store';
/**
* The trip-scoped events the broadcast parity work taught the store (plan 2.8):
* changes written elsewhere (a fellow traveller, the assistant, an MCP client)
* that used to be dropped and only showed after a reload.
*/
const ME = 7;
beforeEach(() => {
resetAllStores();
useAuthStore.setState({ user: { id: ME, username: 'me', email: 'me@example.test' } as never });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
const send = (event: Record<string, unknown>) => useTripStore.getState().handleRemoteEvent(event as { type: string });
describe('remoteEventHandler > broadcast parity', () => {
it('FE-BCAST-001: assignment:participants sets who joins the visit and caches its day', async () => {
const put = vi.spyOn(offlineDb.days, 'put');
useTripStore.setState({
days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
assignments: {
'10': [buildAssignment({ id: 100, day_id: 10 })],
'20': [buildAssignment({ id: 200, day_id: 20 }), buildAssignment({ id: 201, day_id: 20 })],
},
});
const participants = [{ user_id: 3, username: 'ana', avatar: null }];
send({ type: 'assignment:participants', assignmentId: 201, participants });
const { assignments } = useTripStore.getState();
expect(assignments['20'].find((a) => a.id === 201)?.participants).toEqual(participants);
expect(assignments['20'].find((a) => a.id === 200)?.participants).not.toEqual(participants);
expect(assignments['10'][0].participants).not.toEqual(participants);
await vi.waitFor(() => expect(put).toHaveBeenCalledTimes(1));
expect(put.mock.calls[0][0]).toMatchObject({ id: 20 });
});
it('FE-BCAST-002: assignment:participants for an assignment this tab does not hold changes nothing', () => {
const before = { '10': [buildAssignment({ id: 100, day_id: 10 })] };
useTripStore.setState({ days: [buildDay({ id: 10 })], assignments: before });
send({ type: 'assignment:participants', assignmentId: 999, participants: [] });
expect(useTripStore.getState().assignments).toBe(before);
});
it('FE-BCAST-003: packing:reordered numbers the named rows as the server does and caches them', async () => {
const bulkPut = vi.spyOn(offlineDb.packingItems, 'bulkPut');
useTripStore.setState({
packingItems: [
buildPackingItem({ id: 1, sort_order: 0 }),
buildPackingItem({ id: 2, sort_order: 1 }),
buildPackingItem({ id: 3, sort_order: 2 }),
],
});
// 404 is a row this tab does not hold (somebody's private item): it still takes
// index 1 on the server, so item 1 lands on 2. Item 2 is not named and keeps its order.
send({ type: 'packing:reordered', orderedIds: [3, 404, 1] });
const items = useTripStore.getState().packingItems;
expect(items.map((i) => [i.id, i.sort_order])).toEqual([
[3, 0],
[1, 2],
[2, 1],
]);
await vi.waitFor(() => expect(bulkPut).toHaveBeenCalledTimes(1));
expect(bulkPut.mock.calls[0][0]).toEqual(items);
});
it('FE-BCAST-004: todo:reordered applies the order the same way and caches it', async () => {
const bulkPut = vi.spyOn(offlineDb.todoItems, 'bulkPut');
useTripStore.setState({
todoItems: [buildTodoItem({ id: 1, sort_order: 0 }), buildTodoItem({ id: 2, sort_order: 1 })],
});
send({ type: 'todo:reordered', orderedIds: [2, 1] });
const items = useTripStore.getState().todoItems;
expect(items.map((i) => [i.id, i.sort_order])).toEqual([
[2, 0],
[1, 1],
]);
await vi.waitFor(() => expect(bulkPut).toHaveBeenCalledTimes(1));
expect(bulkPut.mock.calls[0][0]).toEqual(items);
});
it('FE-BCAST-005: a reorder without ids leaves the list alone', () => {
const packingItems = [buildPackingItem({ id: 1 })];
useTripStore.setState({ packingItems });
send({ type: 'packing:reordered' });
expect(useTripStore.getState().packingItems).toBe(packingItems);
});
it('FE-BCAST-006: trip:deleted of the open trip marks it gone, another trip does not', async () => {
const drop = vi.spyOn(offlineDb.trips, 'delete');
useTripStore.setState({ trip: buildTrip({ id: 5 }) });
send({ type: 'trip:deleted', tripId: 6, id: 6 });
expect(useTripStore.getState().tripGone).toBeNull();
send({ type: 'trip:deleted', tripId: 5, id: 5 });
expect(useTripStore.getState().tripGone).toEqual({ tripId: 5, reason: 'deleted' });
await vi.waitFor(() => expect(drop).toHaveBeenCalledWith(5));
});
it('FE-BCAST-007: member:removed marks the trip gone only when it is this user', () => {
useTripStore.setState({ trip: buildTrip({ id: 5 }) });
send({ type: 'member:removed', tripId: 5, userId: 99 });
expect(useTripStore.getState().tripGone).toBeNull();
send({ type: 'member:removed', tripId: 5, userId: ME });
expect(useTripStore.getState().tripGone).toEqual({ tripId: 5, reason: 'removed' });
});
it('FE-BCAST-025: member:updated renames the guest on every row that lists it and caches those rows', async () => {
const putBudget = vi.spyOn(offlineDb.budgetItems, 'bulkPut');
const putDay = vi.spyOn(offlineDb.days, 'put');
const guest = { user_id: 3, username: 'Ana' };
const other = { user_id: 4, username: 'Bo' };
const untouched = buildBudgetItem({ id: 61, members: [{ ...other, paid: 0 }] });
useTripStore.setState({
days: [buildDay({ id: 10 })],
budgetItems: [buildBudgetItem({ id: 60, members: [{ ...guest, paid: 0 }, { ...other, paid: 1 }] }), untouched],
reservations: [buildReservation({ id: 70, travelers: [guest] })],
packingItems: [buildPackingItem({ id: 80, recipients: [guest], contributors: [{ ...guest, status: 'packed' }] })],
assignments: { '10': [buildAssignment({ id: 100, day_id: 10, participants: [guest] })] },
});
send({ type: 'member:updated', tripId: 5, member: { id: 3, username: 'Anna' } });
const state = useTripStore.getState();
expect(state.budgetItems[0].members?.map((m) => m.username)).toEqual(['Anna', 'Bo']);
expect(state.budgetItems[1]).toBe(untouched);
expect(state.reservations[0].travelers?.[0].username).toBe('Anna');
expect(state.packingItems[0].recipients?.[0].username).toBe('Anna');
expect(state.packingItems[0].contributors?.[0]).toEqual({ user_id: 3, username: 'Anna', status: 'packed' });
expect(state.assignments['10'][0].participants?.[0].username).toBe('Anna');
await vi.waitFor(() => expect(putDay).toHaveBeenCalledTimes(1));
expect(putBudget.mock.calls[0][0]).toEqual([state.budgetItems[0]]);
});
it('FE-BCAST-026: only this user's removal drops the open trip from the offline cache', async () => {
const drop = vi.spyOn(offlineDb.trips, 'delete');
useTripStore.setState({ trip: buildTrip({ id: 5 }) });
send({ type: 'member:removed', tripId: 5, userId: 99 });
send({ type: 'member:removed', tripId: 5, userId: ME });
await vi.waitFor(() => expect(drop).toHaveBeenCalledWith(5));
expect(drop).toHaveBeenCalledTimes(1);
});
it('FE-BCAST-008: loading another trip forgets a trip that went away', () => {
useTripStore.setState({ tripGone: { tripId: 5, reason: 'removed' } });
useTripStore.getState().resetTrip();
expect(useTripStore.getState().tripGone).toBeNull();
});
it('FE-BCAST-009: a burst of packing:template-applied reads the list once, after it settles', () => {
vi.useFakeTimers();
const reload = vi.fn(async () => undefined);
useTripStore.setState({ trip: buildTrip({ id: 5 }), reloadPackingItems: reload });
for (let i = 0; i < 5; i++) send({ type: 'packing:template-applied', tripId: 5, items: [] });
expect(reload).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(reload).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenCalledWith(5);
});
});
@@ -0,0 +1,271 @@
import { act, renderHook } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { journeyApi, packingApi } from '../../../src/api/client';
import { collectionsApi } from '../../../src/api/collections';
import { addListener, removeListener } from '../../../src/api/websocket';
import { useSaveToCollection } from '../../../src/components/Collections/useSaveToCollection';
import { useBagTotalsPing } from '../../../src/components/Packing/useBagTotalsPing';
import { usePackingCategoryAssignees } from '../../../src/components/Packing/usePackingCategoryAssignees';
import { useInAppNotificationListener } from '../../../src/hooks/useInAppNotificationListener';
import { useJourney } from '../../../src/pages/journey/useJourney';
import { useTripRosterSync } from '../../../src/pages/tripPlanner/useTripRosterSync';
import { useAuthStore } from '../../../src/store/authStore';
import { useInAppNotificationStore, type InAppNotification } from '../../../src/store/inAppNotificationStore';
import { useJourneyStore } from '../../../src/store/journeyStore';
import { useSaveToCollectionStore } from '../../../src/store/saveToCollectionStore';
import { useSettingsStore } from '../../../src/store/settingsStore';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores } from '../../helpers/store';
/**
* The views that read their data again after a change written elsewhere (plan
* 2.8): every one folds a burst into one read, because one assistant step can
* apply up to fifty changes, each its own event.
*/
const nav = vi.hoisted(() => ({ navigate: vi.fn() }));
const toast = vi.hoisted(() => ({ info: vi.fn(), error: vi.fn(), success: vi.fn(), warning: vi.fn() }));
vi.mock('react-router', async (importOriginal) => ({
...(await importOriginal<typeof import('react-router')>()),
useNavigate: () => nav.navigate,
}));
vi.mock('../../../src/components/shared/Toast', () => ({ useToast: () => toast }));
/** A bell row as the notification store holds it. */
function note(id: number, isRead: boolean): InAppNotification {
return {
id,
type: 'simple',
scope: 'trip',
target: 5,
sender_id: null,
sender_username: null,
sender_avatar: null,
recipient_id: 7,
title_key: 'notif.title',
title_params: {},
text_key: 'notif.text',
text_params: {},
positive_text_key: null,
negative_text_key: null,
response: null,
navigate_text_key: null,
navigate_target: null,
is_read: isRead,
created_at: '2026-10-01T09:00:00Z',
};
}
/** The same row as the server sends it, read state as a number. */
const wire = (id: number, isRead: boolean) => ({ ...note(id, isRead), is_read: Number(isRead) }) as never;
/** Deliver an event to every listener still registered, the way the socket layer does. */
function emit(event: Record<string, unknown>): void {
const removed = new Set(vi.mocked(removeListener).mock.calls.map(([fn]) => fn));
for (const [fn] of vi.mocked(addListener).mock.calls) if (!removed.has(fn)) fn(event);
}
/** Past the 250ms window useServerPing collects a burst in. */
async function settle(): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
}
beforeEach(() => {
resetAllStores();
vi.mocked(addListener).mockClear();
vi.mocked(removeListener).mockClear();
nav.navigate.mockClear();
toast.info.mockClear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('broadcast reloads', () => {
it('FE-BCAST-010: the bag events and the totals ping of one burst are one read of the bags', async () => {
const reload = vi.fn(async () => {});
renderHook(() => useBagTotalsPing(true, reload));
act(() => {
emit({ type: 'packing:bag-created', bag: { id: 1 } });
emit({ type: 'packing:bag-members-updated', bagId: 1, members: [] });
emit({ type: 'packing:bag-updated', bag: { id: 1 } });
emit({ type: 'packing:bag-totals' });
emit({ type: 'packing:bag-deleted', bagId: 2 });
emit({ type: 'packing:created', item: { id: 9 } });
});
await settle();
expect(reload).toHaveBeenCalledTimes(1);
});
it('FE-BCAST-011: category assignees set elsewhere are read again', async () => {
const get = vi
.spyOn(packingApi, 'getCategoryAssignees')
.mockResolvedValueOnce({ assignees: {} })
.mockResolvedValueOnce({ assignees: { Camp: [{ user_id: 3, username: 'anna' }] } });
const { result } = renderHook(() =>
usePackingCategoryAssignees({ tripId: 7, t: (k: string) => k, toast: { error: vi.fn() } })
);
await settle();
act(() => {
emit({ type: 'packing:assignees', category: 'Camp', assignees: [] });
emit({ type: 'packing:assignees', category: 'Food', assignees: [] });
});
await settle();
expect(get).toHaveBeenCalledTimes(2);
expect(result.current.categoryAssignees).toEqual({ Camp: [{ user_id: 3, username: 'anna' }] });
});
it('FE-BCAST-012: a notification read or unread elsewhere moves the unread count', () => {
const fetchUnreadCount = vi.fn(async () => {});
useInAppNotificationStore.setState({
notifications: [note(1, false), note(2, true)],
unreadCount: 1,
fetchUnreadCount,
});
const store = useInAppNotificationStore.getState();
store.handleUpdatedNotification(wire(1, true));
expect(useInAppNotificationStore.getState().unreadCount).toBe(0);
store.handleUpdatedNotification(wire(2, false));
store.handleUpdatedNotification(wire(1, false));
expect(useInAppNotificationStore.getState().unreadCount).toBe(2);
// One this tab never loaded says nothing about its old state: the count is asked for.
store.handleUpdatedNotification(wire(77, true));
expect(fetchUnreadCount).toHaveBeenCalledTimes(1);
expect(useInAppNotificationStore.getState().unreadCount).toBe(2);
});
it('FE-BCAST-013: notification:read-all clears the bell, settings and tags are read again once per burst', async () => {
const loadSettings = vi.fn(async () => {});
const reloadTags = vi.fn(async () => {});
useAuthStore.setState({ isAuthenticated: true });
useSettingsStore.setState({ loadSettings });
useTripStore.setState({ reloadTags });
useInAppNotificationStore.setState({
notifications: [note(1, false), note(2, false)],
unreadCount: 2,
});
renderHook(() => useInAppNotificationListener());
act(() => {
emit({ type: 'notification:read-all' });
emit({ type: 'settings:updated', keys: ['dark_mode'] });
emit({ type: 'settings:updated', keys: ['week_start'] });
emit({ type: 'tags:changed' });
emit({ type: 'tags:changed' });
});
const bell = useInAppNotificationStore.getState();
expect(bell.unreadCount).toBe(0);
expect(bell.notifications.every((n) => n.is_read)).toBe(true);
await settle();
expect(loadSettings).toHaveBeenCalledTimes(1);
expect(reloadTags).toHaveBeenCalledTimes(1);
});
it('FE-BCAST-014: member events read the roster again, once per burst', async () => {
const refresh = vi.fn(async () => {});
renderHook(() => useTripRosterSync(5, refresh));
act(() => {
emit({ type: 'member:added', tripId: 5, member: { id: 3 } });
emit({ type: 'member:updated', tripId: 5, member: { id: 3, username: 'Ana' } });
emit({ type: 'member:removed', tripId: 5, userId: 3 });
});
await settle();
expect(refresh).toHaveBeenCalledTimes(1);
});
it('FE-BCAST-015: a trip that went away is left for the trip list with a notice', () => {
renderHook(() => useTripRosterSync(5, async () => {}));
act(() => useTripStore.setState({ tripGone: { tripId: 6, reason: 'deleted' } }));
expect(nav.navigate).not.toHaveBeenCalled();
act(() => useTripStore.setState({ tripGone: { tripId: 5, reason: 'removed' } }));
expect(toast.info).toHaveBeenCalledWith('trip.toast.accessLost');
expect(nav.navigate).toHaveBeenCalledWith('/dashboard', { replace: true });
expect(useTripStore.getState().tripGone).toBeNull();
});
it('FE-BCAST-016: a trip deleted elsewhere says so', () => {
renderHook(() => useTripRosterSync(5, async () => {}));
act(() => useTripStore.setState({ tripGone: { tripId: 5, reason: 'deleted' } }));
expect(toast.info).toHaveBeenCalledWith('dashboard.toast.deleted');
expect(nav.navigate).toHaveBeenCalledTimes(1);
});
it('FE-BCAST-019: a place saved elsewhere re-checks the membership and the inspector bookmark once', async () => {
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [], incomingInvites: [] } as never);
const membership = vi.spyOn(collectionsApi, 'membership').mockResolvedValue({ saved: false, lists: [] } as never);
useSaveToCollectionStore.getState().open({ name: 'Louvre', lat: 48.86, lng: 2.34 });
renderHook(() => useSaveToCollection());
await settle();
membership.mockClear();
const version = useSaveToCollectionStore.getState().version;
act(() => {
for (let i = 0; i < 3; i++) emit({ type: 'collections:updated', collectionId: 4 });
emit({ type: 'collections:invite', collectionId: 5, from: { id: 2, username: 'bo' } });
});
await settle();
expect(membership).toHaveBeenCalledTimes(1);
expect(useSaveToCollectionStore.getState().version).toBe(version + 1);
});
it('FE-BCAST-021: the journey list reads itself again after journeys change elsewhere, once per burst', async () => {
vi.spyOn(journeyApi, 'suggestions').mockResolvedValue({ trips: [] } as never);
const loadJourneys = vi.fn(async () => {});
useJourneyStore.setState({ loadJourneys });
renderHook(() => useJourney(), { wrapper: MemoryRouter });
await settle();
loadJourneys.mockClear();
act(() => {
emit({ type: 'journey:updated', journeyId: 3 });
emit({ type: 'journey:deleted', journeyId: 4 });
emit({ type: 'journey:entry:created', journeyId: 3, entry: { id: 8 } });
});
await settle();
expect(loadJourneys).toHaveBeenCalledTimes(1);
});
it('FE-BCAST-029: a contributor taken off a journey elsewhere reads the journey list again', async () => {
vi.spyOn(journeyApi, 'suggestions').mockResolvedValue({ trips: [] } as never);
const loadJourneys = vi.fn(async () => {});
useJourneyStore.setState({ loadJourneys });
renderHook(() => useJourney(), { wrapper: MemoryRouter });
await settle();
loadJourneys.mockClear();
act(() => emit({ type: 'journey:contributor:changed', journeyId: 3, targetUserId: 2, role: null }));
await settle();
expect(loadJourneys).toHaveBeenCalledTimes(1);
});
it('FE-BCAST-023: opening a trip (a room join) re-reads trip data, never the account-wide state', async () => {
const loadSettings = vi.fn(async () => {});
const reloadTags = vi.fn(async () => {});
const refresh = vi.fn(async () => {});
useAuthStore.setState({ isAuthenticated: true });
useSettingsStore.setState({ loadSettings });
useTripStore.setState({ reloadTags });
renderHook(() => useInAppNotificationListener());
renderHook(() => useSaveToCollection());
renderHook(() => useTripRosterSync(5, refresh));
const version = useSaveToCollectionStore.getState().version;
act(() => emit({ type: 'joined', tripId: 5 }));
await settle();
expect(refresh).toHaveBeenCalledTimes(1);
expect(loadSettings).not.toHaveBeenCalled();
expect(reloadTags).not.toHaveBeenCalled();
expect(useSaveToCollectionStore.getState().version).toBe(version);
});
it('FE-BCAST-028: accepting an invite to a list elsewhere re-checks the bookmark too', async () => {
renderHook(() => useSaveToCollection());
const version = useSaveToCollectionStore.getState().version;
act(() => emit({ type: 'collections:accepted', collectionId: 4 }));
await settle();
expect(useSaveToCollectionStore.getState().version).toBe(version + 1);
});
});
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { TREK_WS_EVENT_NAMES, TREK_WS_TRIP_EVENT_NAMES } from '@trek/shared';
import { DEXIE_WRITERS, STATE_APPLIERS } from '../../../src/store/slices/remoteEventHandler';
import { DEXIE_WRITERS, STATE_APPLIERS, STORE_REFRESHES } from '../../../src/store/slices/remoteEventHandler';
import { AWAITING_SENDER_WS_EVENTS, HANDLED_OUTSIDE_TRIP_STORE, IGNORED_WS_EVENTS } from '../../../src/api/wsEventPolicy';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores } from '../../helpers/store';
@@ -10,16 +10,18 @@ import { buildPackingItem, buildPlace } from '../../helpers/factories';
* The exhaustiveness ratchet (roadmap: WS event contract registry). Every
* event in the shared registry must be classified on the client — applied by
* the tripStore reducer, consumed by a dedicated listener, explicitly
* ignored, or registered ahead of the write that will send it. A server-side event added without a client decision turns this
* test red instead of vanishing into the old silent `default:` branches.
* ignored with a reason, or registered ahead of the write that will send it.
* A server-side event added without a client decision turns this test red
* instead of vanishing into the old silent `default:` branches.
*/
const handledInStore = new Set<string>([
...Object.keys(STATE_APPLIERS),
...Object.keys(DEXIE_WRITERS),
...Object.keys(STORE_REFRESHES),
]);
const handledElsewhere = new Set<string>(HANDLED_OUTSIDE_TRIP_STORE);
const ignored = new Set<string>(IGNORED_WS_EVENTS);
const ignored = new Set<string>(Object.keys(IGNORED_WS_EVENTS));
const awaitingSender = new Set<string>(AWAITING_SENDER_WS_EVENTS);
beforeEach(() => {
@@ -80,9 +82,9 @@ describe('remoteEventHandler > registry parity', () => {
it('FE-WSEVT-REGISTRY-006: an explicitly ignored event leaves the store untouched', () => {
const items = [buildPackingItem({ id: 1 }), buildPackingItem({ id: 2 })];
useTripStore.setState({ packingItems: items, places: [buildPlace({ id: 5 })] });
// 'packing:reordered' is on IGNORED_WS_EVENTS — the reducer must swallow it
// instead of throwing or clearing the slice.
useTripStore.getState().handleRemoteEvent({ type: 'packing:reordered', orderedIds: [2, 1] });
// 'todo:assignees' is on IGNORED_WS_EVENTS: the reducer must swallow it
// instead of throwing or clearing a slice.
useTripStore.getState().handleRemoteEvent({ type: 'todo:assignees', category: 'Docs', assignees: [] });
const state = useTripStore.getState();
expect(state.packingItems.map(i => i.id)).toEqual([1, 2]);
expect(state.places.map(p => p.id)).toEqual([5]);
@@ -95,17 +97,17 @@ describe('remoteEventHandler > registry parity', () => {
});
it('FE-WSEVT-REGISTRY-008: the events awaiting a sender only shrink', () => {
// Registered by the broadcast parity work ahead of their writes. A new event
// gets a handler or a deliberate IGNORED_WS_EVENTS slot, never a place here.
const registeredAhead = [
'member:updated',
'todo:reordered',
'trips:changed',
'settings:updated',
'tags:changed',
'notification:read-all',
'atlas:changed',
];
expect(AWAITING_SENDER_WS_EVENTS.filter(name => !registeredAhead.includes(name))).toEqual([]);
// Every event registered ahead of its write by the broadcast parity work has
// a handler now. A new event gets a handler or a deliberate IGNORED_WS_EVENTS
// slot, never a place here.
expect(AWAITING_SENDER_WS_EVENTS).toEqual([]);
});
it('FE-WSEVT-REGISTRY-009: the ignored events only shrink, and each says why', () => {
// Only the todo category assignees stay ignored: no screen shows them. A key
// added here is a product decision, so this list is edited by hand with it.
const allowed = ['todo:assignees'];
expect(Object.keys(IGNORED_WS_EVENTS).filter(name => !allowed.includes(name))).toEqual([]);
for (const reason of Object.values(IGNORED_WS_EVENTS)) expect(reason?.length ?? 0).toBeGreaterThan(20);
});
});