mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Implements the next four gaps from the client UX spec (docs/architecture/ux).
Connection status as single source of truth (spec §3):
- main.ts registers the one writer: ws.onStateChange → toConnectionStatus
(new 5→3 state mapper exported from ws.ts) → ui.store.connectionStatus.
- Consumers now subscribe to the store instead of ad-hoc ws wirings: the
MainPage reconnect banner (which also gains a "Disconnected" state via
ServerBanner.showDisconnected instead of going stale), ChannelController
composer gating + per-click send guard, and the UserBar presence picker.
- Fixes a latent production bug: SidebarArea never passed ws to UserBar, so
the status picker was permanently disabled and its presence_update path
dead. It now gates on the store and receives the ws send path.
- The one-shot connected-overlay wiring stays on ws.onStateChange by design
(it needs the exact internal transition); LiveKit voice reconnection stays
independent ("retrying underneath").
Transport backpressure surfaced (spec §5):
- ws.ts sendRaw no longer drops local send failures silently: send() passes
the envelope id, and failures notify a new onSendFailure(id, code)
listener — channel full → NETWORK, closed/not-open → OFFLINE (deferred a
microtask on the not-open path so the optimistic row registers first).
- The dispatcher fails the matching pending row via markSendFailed, exactly
like a server error reply; id-less sends (heartbeat) and fire-and-forget
sends (typing, presence) stay silent by design. MessageList renders the
new NETWORK reason ("Connection problem — message not sent").
uploadFile honors global 401 handling (spec §5):
- api.uploadFile now calls onUnauthorized and throws ApiClientError(401)
like every other REST call; main.ts sets the "Your session expired — sign
in again." transient error so the connect page shows the reason.
History fetch loading/error states (messaging.md §1):
- messages.store gains per-channel historyLoadState (loading/error, absent
= idle) with setChannelLoading/setChannelLoadError; setMessages and
clearChannelMessages clear it.
- MessageController.loadMessages sets loading synchronously before the
fetch and marks error inline instead of a toast; MessageList renders an
in-region spinner placeholder or an inline error + Retry (onRetryLoad
re-invokes loadMessages via ChannelController).
Also fixes two pre-existing eslint errors in api.ts (redundant assertions).
Docs: the corresponding gap callouts in docs/architecture/ux are updated
(README §3/§5, messaging.md §1/§3/§6, channels-members-dms.md block-gating
note no longer claims the composer lacks a read-only mode).
Verified: tsc + full client unit suite (3225 tests) + oxlint/eslint +
prettier all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
234 lines
6.7 KiB
TypeScript
234 lines
6.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { authStore } from "@stores/auth.store";
|
|
import { openSettings } from "@stores/ui.store";
|
|
import { createUserBar } from "@components/UserBar";
|
|
|
|
vi.mock("@stores/ui.store", () => ({
|
|
openSettings: vi.fn(),
|
|
uiStore: {
|
|
getState: () => ({ connectionStatus: "connected" }),
|
|
subscribe: () => () => {},
|
|
subscribeSelector: () => () => {},
|
|
},
|
|
}));
|
|
|
|
function setAuthState(user: { username: string } | null, isAuthenticated: boolean): void {
|
|
authStore.setState(() => ({
|
|
token: isAuthenticated ? "tok" : null,
|
|
user: user !== null ? { id: 1, username: user.username, avatar: null, role: "member" } : null,
|
|
serverName: "TestServer",
|
|
motd: null,
|
|
isAuthenticated,
|
|
}));
|
|
}
|
|
|
|
describe("UserBar", () => {
|
|
let container: HTMLDivElement;
|
|
let comp: ReturnType<typeof createUserBar>;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
comp?.destroy?.();
|
|
container.remove();
|
|
// Reset auth store
|
|
authStore.setState(() => ({
|
|
token: null,
|
|
user: null,
|
|
serverName: null,
|
|
motd: null,
|
|
isAuthenticated: false,
|
|
}));
|
|
});
|
|
|
|
it("mounts with user-bar class", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
expect(container.querySelector(".user-bar")).not.toBeNull();
|
|
});
|
|
|
|
it("shows username from authStore", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
const name = container.querySelector(".ub-name");
|
|
expect(name?.textContent).toBe("alice");
|
|
});
|
|
|
|
it("shows first letter as avatar", () => {
|
|
setAuthState({ username: "bob" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
const avatar = container.querySelector(".ub-avatar span");
|
|
expect(avatar?.textContent).toBe("B");
|
|
});
|
|
|
|
it('shows "Online" when authenticated', () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
const status = container.querySelector(".ub-status");
|
|
expect(status?.textContent).toBe("Online");
|
|
});
|
|
|
|
it('shows "Offline" when not authenticated', () => {
|
|
setAuthState(null, false);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
const status = container.querySelector(".ub-status");
|
|
expect(status?.textContent).toBe("Offline");
|
|
});
|
|
|
|
it("settings button calls openSettings", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
const settingsBtn = container.querySelector('[title="Settings"]') as HTMLButtonElement;
|
|
settingsBtn.click();
|
|
|
|
expect(openSettings).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not render mute or deafen buttons", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
expect(container.querySelector('[title="Mute"]')).toBeNull();
|
|
expect(container.querySelector('[title="Deafen"]')).toBeNull();
|
|
});
|
|
|
|
it("destroy removes DOM and unsubscribes", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
expect(container.querySelector(".user-bar")).not.toBeNull();
|
|
|
|
comp.destroy?.();
|
|
|
|
expect(container.querySelector(".user-bar")).toBeNull();
|
|
});
|
|
|
|
it("renders disconnect button when onDisconnect is provided", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
const onDisconnect = vi.fn();
|
|
comp = createUserBar({ onDisconnect });
|
|
comp.mount(container);
|
|
|
|
const disconnectBtn = container.querySelector(
|
|
'[data-testid="disconnect-btn"]',
|
|
) as HTMLButtonElement;
|
|
expect(disconnectBtn).not.toBeNull();
|
|
expect(disconnectBtn.getAttribute("aria-label")).toBe("Switch server");
|
|
});
|
|
|
|
it("calls onDisconnect when disconnect button is clicked", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
const onDisconnect = vi.fn();
|
|
comp = createUserBar({ onDisconnect });
|
|
comp.mount(container);
|
|
|
|
const disconnectBtn = container.querySelector(
|
|
'[data-testid="disconnect-btn"]',
|
|
) as HTMLButtonElement;
|
|
disconnectBtn.click();
|
|
expect(onDisconnect).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not render disconnect button when onDisconnect is not provided", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
const disconnectBtn = container.querySelector('[data-testid="disconnect-btn"]');
|
|
expect(disconnectBtn).toBeNull();
|
|
});
|
|
|
|
it("updates username reactively when auth store changes", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
expect(container.querySelector(".ub-name")?.textContent).toBe("alice");
|
|
|
|
// Update auth store with new username
|
|
authStore.setState((prev) => ({
|
|
...prev,
|
|
user: prev.user ? { ...prev.user, username: "bob" } : null,
|
|
}));
|
|
authStore.flush();
|
|
|
|
expect(container.querySelector(".ub-name")?.textContent).toBe("bob");
|
|
});
|
|
|
|
it('shows "Unknown" and "U" avatar when user is null', () => {
|
|
setAuthState(null, false);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
const name = container.querySelector(".ub-name");
|
|
expect(name?.textContent).toBe("Unknown");
|
|
|
|
// "Unknown".charAt(0).toUpperCase() = "U"
|
|
const avatarSpan = container.querySelector(".ub-avatar span");
|
|
expect(avatarSpan?.textContent).toBe("U");
|
|
});
|
|
|
|
it("has data-testid on root element", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
const root = container.querySelector('[data-testid="user-bar"]');
|
|
expect(root).not.toBeNull();
|
|
});
|
|
|
|
it("status changes from Online to Offline when logged out", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
expect(container.querySelector(".ub-status")?.textContent).toBe("Online");
|
|
|
|
// Simulate logout
|
|
authStore.setState(() => ({
|
|
token: null,
|
|
user: null,
|
|
serverName: null,
|
|
motd: null,
|
|
isAuthenticated: false,
|
|
}));
|
|
authStore.flush();
|
|
|
|
expect(container.querySelector(".ub-status")?.textContent).toBe("Offline");
|
|
});
|
|
|
|
it("avatar initial updates when username changes", () => {
|
|
setAuthState({ username: "alice" }, true);
|
|
comp = createUserBar();
|
|
comp.mount(container);
|
|
|
|
expect(container.querySelector(".ub-avatar span")?.textContent).toBe("A");
|
|
|
|
authStore.setState((prev) => ({
|
|
...prev,
|
|
user: prev.user ? { ...prev.user, username: "zara" } : null,
|
|
}));
|
|
authStore.flush();
|
|
|
|
expect(container.querySelector(".ub-avatar span")?.textContent).toBe("Z");
|
|
});
|
|
});
|