mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Server:
- Split monolithic voice_handlers.go into voice_join/leave/controls/broadcast
- Add metrics endpoint (admin-IP-restricted /api/v1/metrics)
- Add orphaned attachment cleanup in maintenance loop
- Add sentinel errors (db/errors.go, ws/errors.go)
- Add ring buffer for event replay on reconnect
- Add heartbeat monitoring with stale connection sweep
- Improve hub with panic recovery, graceful shutdown, seq tracking
- Typed message structs replace raw map[string]interface{}
Client:
- Decompose MainPage into ChatArea + SidebarArea controllers
- Add disposable.ts lifecycle management pattern
- Add member list right-click context menu (kick/ban/role)
- Tighten CSP (media-src, font-src, object-src, base-uri)
- Improve store with shallowEqual, 500-msg cap, batch updates
- Add search API endpoint wiring
- Fix LiveKit session cleanup and reconnection
Docs:
- Add CODEMAPS for architecture, backend, frontend, data, deps
- Add protocol-schema.json (machine-readable, 36 message types)
- Add platform research report
- Update PROTOCOL.md with seq/replay fields
279 lines
8.5 KiB
TypeScript
279 lines
8.5 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { createStore } from '../../src/lib/store';
|
|
|
|
interface TestState {
|
|
count: number;
|
|
name: string;
|
|
}
|
|
|
|
const initialState: TestState = { count: 0, name: 'test' };
|
|
|
|
function freshStore() {
|
|
return createStore<TestState>({ ...initialState });
|
|
}
|
|
|
|
describe('createStore', () => {
|
|
it('getState returns initial state', () => {
|
|
const store = freshStore();
|
|
expect(store.getState()).toEqual({ count: 0, name: 'test' });
|
|
});
|
|
|
|
it('setState updates state via updater function', () => {
|
|
const store = freshStore();
|
|
store.setState((prev) => ({ ...prev, count: prev.count + 1 }));
|
|
expect(store.getState()).toEqual({ count: 1, name: 'test' });
|
|
});
|
|
|
|
it('setState calls all subscribers with new state', () => {
|
|
const store = freshStore();
|
|
const listener1 = vi.fn();
|
|
const listener2 = vi.fn();
|
|
store.subscribe(listener1);
|
|
store.subscribe(listener2);
|
|
|
|
store.setState((prev) => ({ ...prev, count: 5 }));
|
|
store.flush();
|
|
|
|
expect(listener1).toHaveBeenCalledTimes(1);
|
|
expect(listener1).toHaveBeenCalledWith({ count: 5, name: 'test' });
|
|
expect(listener2).toHaveBeenCalledTimes(1);
|
|
expect(listener2).toHaveBeenCalledWith({ count: 5, name: 'test' });
|
|
});
|
|
|
|
it('subscribe returns unsubscribe function that works', () => {
|
|
const store = freshStore();
|
|
const listener = vi.fn();
|
|
const unsubscribe = store.subscribe(listener);
|
|
|
|
store.setState((prev) => ({ ...prev, count: 1 }));
|
|
store.flush();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
|
|
unsubscribe();
|
|
|
|
store.setState((prev) => ({ ...prev, count: 2 }));
|
|
store.flush();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('multiple subscribers all get called', () => {
|
|
const store = freshStore();
|
|
const calls: number[] = [];
|
|
store.subscribe(() => calls.push(1));
|
|
store.subscribe(() => calls.push(2));
|
|
store.subscribe(() => calls.push(3));
|
|
|
|
store.setState((prev) => ({ ...prev, count: 10 }));
|
|
store.flush();
|
|
|
|
expect(calls).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('unsubscribed listener does not get called', () => {
|
|
const store = freshStore();
|
|
const kept = vi.fn();
|
|
const removed = vi.fn();
|
|
|
|
store.subscribe(kept);
|
|
const unsub = store.subscribe(removed);
|
|
unsub();
|
|
|
|
store.setState((prev) => ({ ...prev, count: 99 }));
|
|
store.flush();
|
|
|
|
expect(kept).toHaveBeenCalledTimes(1);
|
|
expect(removed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('select derives value from state', () => {
|
|
const store = freshStore();
|
|
store.setState((prev) => ({ ...prev, count: 42 }));
|
|
|
|
const count = store.select((s) => s.count);
|
|
const name = store.select((s) => s.name);
|
|
|
|
expect(count).toBe(42);
|
|
expect(name).toBe('test');
|
|
});
|
|
|
|
it('setState does NOT mutate previous state reference', () => {
|
|
const store = freshStore();
|
|
const before = store.getState();
|
|
|
|
store.setState((prev) => ({ ...prev, count: prev.count + 1 }));
|
|
const after = store.getState();
|
|
|
|
expect(before).toEqual({ count: 0, name: 'test' });
|
|
expect(after).toEqual({ count: 1, name: 'test' });
|
|
expect(before).not.toBe(after);
|
|
});
|
|
|
|
it('subscriber receives new state not old state', () => {
|
|
const store = freshStore();
|
|
const received: TestState[] = [];
|
|
store.subscribe((s) => received.push(s));
|
|
|
|
store.setState((prev) => ({ ...prev, count: 7 }));
|
|
store.flush();
|
|
store.setState((prev) => ({ ...prev, name: 'updated' }));
|
|
store.flush();
|
|
|
|
expect(received).toEqual([
|
|
{ count: 7, name: 'test' },
|
|
{ count: 7, name: 'updated' },
|
|
]);
|
|
});
|
|
|
|
it('no subscribers means setState still works without crash', () => {
|
|
const store = freshStore();
|
|
expect(() => {
|
|
store.setState((prev) => ({ ...prev, count: 100 }));
|
|
}).not.toThrow();
|
|
expect(store.getState().count).toBe(100);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// subscribeSelector
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('subscribeSelector', () => {
|
|
it('fires when selected slice changes', () => {
|
|
const store = freshStore();
|
|
const listener = vi.fn();
|
|
store.subscribeSelector((s) => s.count, listener);
|
|
|
|
store.setState((prev) => ({ ...prev, count: 5 }));
|
|
store.flush();
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(listener).toHaveBeenCalledWith(5);
|
|
});
|
|
|
|
it('does NOT fire when selected slice is unchanged', () => {
|
|
const store = freshStore();
|
|
const listener = vi.fn();
|
|
store.subscribeSelector((s) => s.count, listener);
|
|
|
|
// Change name but not count
|
|
store.setState((prev) => ({ ...prev, name: 'updated' }));
|
|
store.flush();
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fires only for the changed slice among multiple selectors', () => {
|
|
const store = freshStore();
|
|
const countListener = vi.fn();
|
|
const nameListener = vi.fn();
|
|
store.subscribeSelector((s) => s.count, countListener);
|
|
store.subscribeSelector((s) => s.name, nameListener);
|
|
|
|
store.setState((prev) => ({ ...prev, count: 10 }));
|
|
store.flush();
|
|
|
|
expect(countListener).toHaveBeenCalledTimes(1);
|
|
expect(nameListener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns unsubscribe function', () => {
|
|
const store = freshStore();
|
|
const listener = vi.fn();
|
|
const unsub = store.subscribeSelector((s) => s.count, listener);
|
|
|
|
store.setState((prev) => ({ ...prev, count: 1 }));
|
|
store.flush();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
|
|
unsub();
|
|
|
|
store.setState((prev) => ({ ...prev, count: 2 }));
|
|
store.flush();
|
|
expect(listener).toHaveBeenCalledTimes(1); // no new call
|
|
});
|
|
|
|
it('works with custom equality comparator', () => {
|
|
const store = freshStore();
|
|
const listener = vi.fn();
|
|
// Custom comparator: only fire when count changes by more than 5
|
|
store.subscribeSelector(
|
|
(s) => s.count,
|
|
listener,
|
|
(a, b) => Math.abs(a - b) <= 5,
|
|
);
|
|
|
|
store.setState((prev) => ({ ...prev, count: 3 })); // diff = 3, within threshold
|
|
store.flush();
|
|
expect(listener).not.toHaveBeenCalled();
|
|
|
|
store.setState((prev) => ({ ...prev, count: 10 })); // diff = 10, exceeds threshold
|
|
store.flush();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(listener).toHaveBeenCalledWith(10);
|
|
});
|
|
|
|
it('works with microtask batching', () => {
|
|
const store = freshStore();
|
|
const listener = vi.fn();
|
|
store.subscribeSelector((s) => s.count, listener);
|
|
|
|
// Multiple rapid updates — only final state matters
|
|
store.setState((prev) => ({ ...prev, count: 1 }));
|
|
store.setState((prev) => ({ ...prev, count: 2 }));
|
|
store.setState((prev) => ({ ...prev, count: 3 }));
|
|
store.flush();
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(listener).toHaveBeenCalledWith(3);
|
|
});
|
|
|
|
it('multiple selectors on the same store work independently', () => {
|
|
const store = freshStore();
|
|
const results: string[] = [];
|
|
store.subscribeSelector((s) => s.count, (c) => results.push(`count:${c}`));
|
|
store.subscribeSelector((s) => s.name, (n) => results.push(`name:${n}`));
|
|
|
|
store.setState((prev) => ({ ...prev, count: 1, name: 'updated' }));
|
|
store.flush();
|
|
|
|
expect(results).toEqual(['count:1', 'name:updated']);
|
|
});
|
|
|
|
it('shallow-equal default prevents firing for structurally identical selectors', () => {
|
|
const store = freshStore();
|
|
const listener = vi.fn();
|
|
// Selector creates a new object ref each time, but shallowEqual
|
|
// detects that the content is unchanged and skips the notification.
|
|
store.subscribeSelector(
|
|
(s) => ({ count: s.count }),
|
|
listener,
|
|
);
|
|
|
|
// Changing just name does NOT fire because { count: 0 } shallow-equals { count: 0 }
|
|
store.setState((prev) => ({ ...prev, name: 'changed' }));
|
|
store.flush();
|
|
expect(listener).toHaveBeenCalledTimes(0);
|
|
|
|
// Changing count DOES fire because { count: 1 } !== { count: 0 }
|
|
store.setState((prev) => ({ ...prev, count: 1 }));
|
|
store.flush();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('allows strict reference equality via custom comparator', () => {
|
|
const store = freshStore();
|
|
const listener = vi.fn();
|
|
// Opt in to strict === comparison to get the old behavior
|
|
store.subscribeSelector(
|
|
(s) => ({ count: s.count }),
|
|
listener,
|
|
(a, b) => a === b,
|
|
);
|
|
|
|
// New object ref with same content DOES fire with strict ===
|
|
store.setState((prev) => ({ ...prev, name: 'changed' }));
|
|
store.flush();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|