diff --git a/frontend/src/admin.test.ts b/frontend/src/admin.test.ts
index 63b52ed..c8e57de 100644
--- a/frontend/src/admin.test.ts
+++ b/frontend/src/admin.test.ts
@@ -326,3 +326,53 @@ describe("admin responsive layout", () => {
expect(tabs).toContain("Dashboard");
});
});
+
+describe("admin data refresh failures", () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+ `;
+ });
+
+ it("sets error status when overview fails", async () => {
+ mockFetchResponse = { ok: false, status: 500, payload: {} };
+ await refreshOverview();
+ expect(document.getElementById("overview-status")?.textContent).toContain("Failed");
+ });
+
+ it("sets error status when analytics fails", async () => {
+ mockFetchResponse = { ok: false, status: 500, payload: {} };
+ await refreshAnalytics();
+ expect(document.getElementById("analytics-status")?.textContent).toContain("Failed");
+ });
+});
+
+describe("playlist status badges", () => {
+ beforeEach(() => {
+ document.body.innerHTML = ``;
+ });
+
+ it("renders badges for every media status", () => {
+ const statusMap = new Map([
+ ["/app/media/ready.mp4", "ready"],
+ ["/app/media/needs.mp4", "needs_normalization"],
+ ["/app/media/normalizing.mp4", "normalizing"],
+ ["/app/media/failed.mp4", "normalization_failed"],
+ ["/app/media/other.mp4", "weird"],
+ ]);
+
+ renderPlaylistSortable(
+ "file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n",
+ statusMap,
+ );
+
+ const list = document.getElementById("playlist-sortable")!;
+ expect(list.innerHTML).toContain("badge-ok");
+ expect(list.innerHTML).toContain("badge-warn");
+ expect(list.innerHTML).toContain("badge-info");
+ expect(list.innerHTML).toContain("badge-danger");
+ expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted
+ expect(list.children).toHaveLength(6);
+ });
+});
diff --git a/frontend/src/counter.test.ts b/frontend/src/counter.test.ts
new file mode 100644
index 0000000..074ec19
--- /dev/null
+++ b/frontend/src/counter.test.ts
@@ -0,0 +1,23 @@
+///
+import { describe, it, expect } from "vitest";
+import { setupCounter } from "../src/counter";
+
+describe("setupCounter", () => {
+ it("initializes the button with count 0", () => {
+ const btn = document.createElement("button");
+ setupCounter(btn);
+ expect(btn.innerHTML).toBe("Count is 0");
+ });
+
+ it("increments the counter on each click", () => {
+ const btn = document.createElement("button");
+ setupCounter(btn);
+
+ btn.click();
+ expect(btn.innerHTML).toBe("Count is 1");
+
+ btn.click();
+ btn.click();
+ expect(btn.innerHTML).toBe("Count is 3");
+ });
+});
diff --git a/frontend/src/main.test.ts b/frontend/src/main.test.ts
index 3356c14..5c38ed8 100644
--- a/frontend/src/main.test.ts
+++ b/frontend/src/main.test.ts
@@ -1,6 +1,12 @@
///
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main";
+import {
+ resetViewerStateForTests,
+ resolveManifestUrl,
+ resolveWebSocketBaseUrl,
+ showReconnecting,
+ hideReconnecting,
+} from "../src/main";
const hlsInstance = {
loadSource: vi.fn(),
@@ -114,6 +120,22 @@ beforeEach(() => {
Reconnecting...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
// Stub prompt for DM
@@ -359,6 +381,119 @@ describe("message rendering", () => {
});
});
+describe("reconnecting overlay", () => {
+ it("shows and hides the overlay with a custom message", () => {
+ showReconnecting("Custom message");
+ const overlay = document.getElementById("reconnecting-overlay")!;
+ expect(overlay.classList.contains("hidden")).toBe(false);
+ expect(document.getElementById("reconnecting-text")!.textContent).toBe("Custom message");
+
+ hideReconnecting();
+ expect(overlay.classList.contains("hidden")).toBe(true);
+ });
+});
+
+describe("typing indicator", () => {
+ it("shows typing on a typing event and clears on the next message", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "alice" }) });
+
+ const indicator = document.getElementById("typing-indicator")!;
+ expect(indicator.textContent).toBe("alice is typing...");
+ expect(indicator.hidden).toBe(false);
+
+ ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hi", username: "alice", timestamp: Date.now() }) });
+ expect(indicator.hidden).toBe(true);
+ expect(indicator.textContent).toBe("");
+ });
+
+ it("ignores typing events from the current user", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const ws = websocketInstances[0];
+ ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "viewer" }) });
+
+ const indicator = document.getElementById("typing-indicator")!;
+ expect(indicator.textContent).toBe("");
+ expect(indicator.hidden).toBe(true);
+ });
+});
+
+describe("sendMessage over websocket", () => {
+ it("sends a JSON message and clears the input", async () => {
+ await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
+
+ const input = document.getElementById("message-input") as HTMLInputElement;
+ input.value = "hello world";
+ window.sendMessage();
+
+ const ws = websocketInstances[0];
+ expect(ws.send).toHaveBeenCalledTimes(1);
+ expect(ws.send).toHaveBeenCalledWith(expect.stringContaining("hello world"));
+ expect(input.value).toBe("");
+ });
+});
+
+describe("auth and user list wiring", () => {
+ it("logs in and updates the auth UI", async () => {
+ await import("../src/main");
+
+ globalThis.fetch = vi.fn(async (url: any, opts?: any) => {
+ const u = String(url);
+ if (u.includes("/api/auth/login")) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ username: "alice", authenticated: true }),
+ text: async () => "",
+ };
+ }
+ return { ok: true, status: 200, json: async () => ({ authenticated: false }), text: async () => "" };
+ }) as any;
+
+ document.dispatchEvent(new Event("DOMContentLoaded"));
+
+ const usernameInput = document.getElementById("auth-username") as HTMLInputElement;
+ const passwordInput = document.getElementById("auth-password") as HTMLInputElement;
+ usernameInput.value = "alice";
+ passwordInput.value = "secret";
+
+ document.getElementById("auth-login-btn")!.click();
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
+ expect(document.getElementById("chat-user-label")!.textContent).toBe("alice");
+ });
+
+ it("toggles the user list visibility", async () => {
+ await import("../src/main");
+
+ globalThis.fetch = vi
+ .fn()
+ .mockResolvedValue({
+ ok: true,
+ json: async () => ({ authenticated: false, entries: [] }),
+ text: async () => "",
+ }) as any;
+
+ document.dispatchEvent(new Event("DOMContentLoaded"));
+
+ const panel = document.getElementById("presence-panel")!;
+ const btn = document.getElementById("users-toggle-btn")!;
+ expect(panel.hidden).toBe(false);
+
+ btn.click();
+ expect(panel.hidden).toBe(true);
+ expect(btn.getAttribute("aria-expanded")).toBe("false");
+
+ btn.click();
+ expect(panel.hidden).toBe(false);
+ expect(btn.getAttribute("aria-expanded")).toBe("true");
+ });
+});
+
describe("player error recovery", () => {
it("shows reconnecting overlay on fatal network error", async () => {
const main = await import("../src/main");