evOo-local/apps/web/src/hooks/useDualSurfaceLayout.test.ts
cynarAI ca869561ac
Voice-first streaming, non-blocking follow-ups and chat UX (#205)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:08:31 +02:00

402 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { act, createElement } from "react";
import type { Root } from "react-dom/client";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DualSurfaceLayoutMode, DualSurfaceOrientation } from "../lib/dual-surface-pref";
import {
DUAL_SURFACE_LAYOUT_MODE_KEY,
DUAL_SURFACE_ORIENTATION_KEY,
DUAL_SURFACE_SPLIT_RATIO_KEY,
readDualSurfacePreferences,
} from "../lib/dual-surface-pref";
import type { DualSurfaceLayoutState } from "./useDualSurfaceLayout";
import { useDualSurfaceLayout } from "./useDualSurfaceLayout";
describe("useDualSurfaceLayout", () => {
let container: HTMLDivElement;
let root: Root;
let mockStore: Map<string, string>;
let mediaListeners: Set<(e: MediaQueryListEvent) => void>;
let isDesktopMatch: boolean;
let latestState!: DualSurfaceLayoutState;
function TestHarness() {
latestState = useDualSurfaceLayout();
return null;
}
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
mockStore = new Map<string, string>();
mediaListeners = new Set();
isDesktopMatch = true;
// Mock localStorage
const storageMock = {
getItem: vi.fn((key: string) => mockStore.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
mockStore.set(key, value);
}),
removeItem: vi.fn((key: string) => {
mockStore.delete(key);
}),
clear: vi.fn(() => {
mockStore.clear();
}),
};
// Mock matchMedia
const matchMediaMock = vi.fn().mockImplementation((query: string) => ({
matches: isDesktopMatch,
media: query,
onchange: null,
addListener: vi.fn(), // legacy
removeListener: vi.fn(), // legacy
addEventListener: vi.fn((event: string, callback: (e: MediaQueryListEvent) => void) => {
if (event === "change") mediaListeners.add(callback);
}),
removeEventListener: vi.fn((event: string, callback: (e: MediaQueryListEvent) => void) => {
if (event === "change") mediaListeners.delete(callback);
}),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal("window", {
localStorage: storageMock,
matchMedia: matchMediaMock,
});
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function renderHook(): Promise<DualSurfaceLayoutState> {
await act(async () => {
root.render(createElement(TestHarness));
});
return latestState;
}
function simulateViewportResize(desktop: boolean) {
isDesktopMatch = desktop;
const event = { matches: desktop } as MediaQueryListEvent;
act(() => {
for (const listener of mediaListeners) {
listener(event);
}
});
}
describe("Tier 1 - Feature Coverage: Initial State & Preferences Sync", () => {
it("initializes with default layout values on desktop when storage is empty", async () => {
await renderHook();
const mode: DualSurfaceLayoutMode = latestState.layoutMode;
const orientation: DualSurfaceOrientation = latestState.orientation;
expect(latestState.splitRatio).toBe(0.5);
expect(mode).toBe("split");
expect(orientation).toBe("stream-first");
expect(latestState.isMobile).toBe(false);
});
it("initializes from pre-existing stored preferences", async () => {
mockStore.set(DUAL_SURFACE_SPLIT_RATIO_KEY, "0.65");
mockStore.set(DUAL_SURFACE_LAYOUT_MODE_KEY, "canvas");
mockStore.set(DUAL_SURFACE_ORIENTATION_KEY, "canvas-first");
await renderHook();
expect(latestState.splitRatio).toBe(0.65);
expect(latestState.layoutMode).toBe("canvas");
expect(latestState.orientation).toBe("canvas-first");
expect(latestState.isMobile).toBe(false);
});
it("updates splitRatio and synchronizes to preferences", async () => {
await renderHook();
await act(async () => {
latestState.setSplitRatio(0.35);
});
expect(latestState.splitRatio).toBe(0.35);
expect(mockStore.get(DUAL_SURFACE_SPLIT_RATIO_KEY)).toBe("0.35");
expect(readDualSurfacePreferences().splitRatio).toBe(0.35);
});
it("clamps splitRatio on setSplitRatio within [0.20, 0.80]", async () => {
await renderHook();
await act(async () => {
latestState.setSplitRatio(0.12);
});
expect(latestState.splitRatio).toBe(0.2);
expect(readDualSurfacePreferences().splitRatio).toBe(0.2);
await act(async () => {
latestState.setSplitRatio(0.95);
});
expect(latestState.splitRatio).toBe(0.8);
expect(readDualSurfacePreferences().splitRatio).toBe(0.8);
});
it("switches to Focus Stream mode and persists layoutMode", async () => {
await renderHook();
await act(async () => {
latestState.setLayoutMode("stream");
});
expect(latestState.layoutMode).toBe("stream");
expect(mockStore.get(DUAL_SURFACE_LAYOUT_MODE_KEY)).toBe("stream");
expect(readDualSurfacePreferences().layoutMode).toBe("stream");
});
it("switches to Focus Canvas mode and persists layoutMode", async () => {
await renderHook();
await act(async () => {
latestState.setLayoutMode("canvas");
});
expect(latestState.layoutMode).toBe("canvas");
expect(mockStore.get(DUAL_SURFACE_LAYOUT_MODE_KEY)).toBe("canvas");
expect(readDualSurfacePreferences().layoutMode).toBe("canvas");
});
it("switches back to Dual Split mode and persists layoutMode", async () => {
await renderHook();
await act(async () => {
latestState.setLayoutMode("canvas");
});
expect(latestState.layoutMode).toBe("canvas");
await act(async () => {
latestState.setLayoutMode("split");
});
expect(latestState.layoutMode).toBe("split");
expect(mockStore.get(DUAL_SURFACE_LAYOUT_MODE_KEY)).toBe("split");
});
it("toggles orientation back and forth and persists", async () => {
await renderHook();
expect(latestState.orientation).toBe("stream-first");
await act(async () => {
latestState.toggleOrientation();
});
expect(latestState.orientation).toBe("canvas-first");
expect(mockStore.get(DUAL_SURFACE_ORIENTATION_KEY)).toBe("canvas-first");
await act(async () => {
latestState.toggleOrientation();
});
expect(latestState.orientation).toBe("stream-first");
expect(mockStore.get(DUAL_SURFACE_ORIENTATION_KEY)).toBe("stream-first");
});
it("explicitly sets orientation via setOrientation", async () => {
await renderHook();
await act(async () => {
latestState.setOrientation("canvas-first");
});
expect(latestState.orientation).toBe("canvas-first");
await act(async () => {
latestState.setOrientation("stream-first");
});
expect(latestState.orientation).toBe("stream-first");
});
it("snaps splitRatio back to 0.50 via resetSplit", async () => {
await renderHook();
await act(async () => {
latestState.setSplitRatio(0.72);
});
expect(latestState.splitRatio).toBe(0.72);
await act(async () => {
latestState.resetSplit();
});
expect(latestState.splitRatio).toBe(0.5);
expect(readDualSurfacePreferences().splitRatio).toBe(0.5);
});
});
describe("Tier 2 - Boundary Cases & Responsive Degradation", () => {
it("detects mobile viewport on initial mount when media query does not match desktop", async () => {
isDesktopMatch = false; // < 768px
await renderHook();
expect(latestState.isMobile).toBe(true);
});
it("dynamically updates isMobile when viewport resizes across the 768px breakpoint", async () => {
await renderHook();
expect(latestState.isMobile).toBe(false);
simulateViewportResize(false); // resize to mobile
expect(latestState.isMobile).toBe(true);
simulateViewportResize(true); // resize back to desktop
expect(latestState.isMobile).toBe(false);
});
it("preserves desktop splitRatio untouched when transitioning to mobile", async () => {
await renderHook();
await act(async () => {
latestState.setSplitRatio(0.38);
});
expect(latestState.splitRatio).toBe(0.38);
// Viewport shrinks to mobile (< md)
simulateViewportResize(false);
expect(latestState.isMobile).toBe(true);
// Desktop splitRatio in state and storage must remain intact
expect(latestState.splitRatio).toBe(0.38);
expect(readDualSurfacePreferences().splitRatio).toBe(0.38);
// Viewport expands back to desktop
simulateViewportResize(true);
expect(latestState.isMobile).toBe(false);
expect(latestState.splitRatio).toBe(0.38);
});
it("cleans up media query event listeners on unmount", async () => {
await renderHook();
expect(mediaListeners.size).toBeGreaterThan(0);
await act(async () => {
root.unmount();
});
expect(mediaListeners.size).toBe(0);
});
it("is idempotent when setting the current layoutMode again", async () => {
await renderHook();
expect(latestState.layoutMode).toBe("split");
await act(async () => {
latestState.setLayoutMode("split");
});
expect(latestState.layoutMode).toBe("split");
});
it("is idempotent when setting the current orientation again", async () => {
await renderHook();
expect(latestState.orientation).toBe("stream-first");
await act(async () => {
latestState.setOrientation("stream-first");
});
expect(latestState.orientation).toBe("stream-first");
});
it("calling resetSplit when already at 0.50 maintains 0.50 cleanly", async () => {
await renderHook();
expect(latestState.splitRatio).toBe(0.5);
await act(async () => {
latestState.resetSplit();
});
expect(latestState.splitRatio).toBe(0.5);
});
});
describe("Tier 3 - Cross-Feature Combinations & Workflow Resilience", () => {
it("preserves custom splitRatio when switching between preset modes and back to split", async () => {
await renderHook();
// User customizes split ratio to 0.35
await act(async () => {
latestState.setSplitRatio(0.35);
});
expect(latestState.splitRatio).toBe(0.35);
// User switches to Focus Stream (100/0)
await act(async () => {
latestState.setLayoutMode("stream");
});
expect(latestState.layoutMode).toBe("stream");
// User switches to Focus Canvas (0/100)
await act(async () => {
latestState.setLayoutMode("canvas");
});
expect(latestState.layoutMode).toBe("canvas");
// User switches back to Dual Split
await act(async () => {
latestState.setLayoutMode("split");
});
expect(latestState.layoutMode).toBe("split");
// The 0.35 ratio must be preserved
expect(latestState.splitRatio).toBe(0.35);
});
it("restores customized ratio and orientation upon unmount and remount (page reload simulation)", async () => {
await renderHook();
await act(async () => {
latestState.setSplitRatio(0.68);
latestState.setOrientation("canvas-first");
latestState.setLayoutMode("canvas");
});
// Unmount hook
await act(async () => {
root.unmount();
});
// Re-mount hook (simulating page reload with persisted storage)
root = createRoot(container);
await renderHook();
expect(latestState.splitRatio).toBe(0.68);
expect(latestState.orientation).toBe("canvas-first");
expect(latestState.layoutMode).toBe("canvas");
});
it("handles rapid consecutive state changes deterministically", async () => {
await renderHook();
await act(async () => {
latestState.setSplitRatio(0.3);
latestState.toggleOrientation();
latestState.setLayoutMode("canvas");
latestState.setSplitRatio(0.7);
latestState.setLayoutMode("split");
});
expect(latestState.splitRatio).toBe(0.7);
expect(latestState.orientation).toBe("canvas-first");
expect(latestState.layoutMode).toBe("split");
expect(readDualSurfacePreferences()).toEqual({
splitRatio: 0.7,
orientation: "canvas-first",
layoutMode: "split",
});
});
});
});