import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SIDE_PANEL_DEFAULT_WIDTH, SIDE_PANEL_DETAIL_MIN_WIDTH, SIDE_PANEL_MAX_WIDTH, SIDE_PANEL_MIN_WIDTH, } from "@/lib/ui-layout"; import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; describe("useSidePanelStore", () => { afterEach(() => { vi.unstubAllGlobals(); }); beforeEach(() => { localStorage.clear(); useSidePanelStore.setState({ isOpen: false, selectedTab: SIDE_PANEL_TAB.AI_CHAT, hasBeenOpened: false, width: SIDE_PANEL_DEFAULT_WIDTH, isResizing: false, contextTab: null, contextOwnerToken: 0, contextOutlet: null, hasSeenAiTriggerHint: false, }); }); it("opens the panel on the requested tab and latches hasBeenOpened", () => { // When useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT); // Then const state = useSidePanelStore.getState(); expect(state.isOpen).toBe(true); expect(state.selectedTab).toBe(SIDE_PANEL_TAB.AI_CHAT); expect(state.hasBeenOpened).toBe(true); }); it("reopens on the last selected tab when no tab is given", () => { // Given useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT); useSidePanelStore.getState().closePanel(); // When useSidePanelStore.getState().openPanel(); // Then expect(useSidePanelStore.getState().selectedTab).toBe( SIDE_PANEL_TAB.AI_CHAT, ); expect(useSidePanelStore.getState().isOpen).toBe(true); }); it("keeps content latched as mounted after closing", () => { // Given useSidePanelStore.getState().openPanel(); // When useSidePanelStore.getState().closePanel(); // Then expect(useSidePanelStore.getState().isOpen).toBe(false); expect(useSidePanelStore.getState().hasBeenOpened).toBe(true); }); it("ignores a tab-scoped close for a tab that is not showing", () => { // Given useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT); // When: a stale closer targets a different tab id useSidePanelStore.getState().closePanel(SIDE_PANEL_TAB.CONTEXT); // Then expect(useSidePanelStore.getState().isOpen).toBe(true); // When: the close targets the showing tab useSidePanelStore.getState().closePanel(SIDE_PANEL_TAB.AI_CHAT); // Then expect(useSidePanelStore.getState().isOpen).toBe(false); }); it("toggles open and closed", () => { // When / Then useSidePanelStore.getState().togglePanel(); expect(useSidePanelStore.getState().isOpen).toBe(true); expect(useSidePanelStore.getState().hasBeenOpened).toBe(true); useSidePanelStore.getState().togglePanel(); expect(useSidePanelStore.getState().isOpen).toBe(false); }); it("registering a context tab opens the panel on it", () => { // When useSidePanelStore.getState().registerContextTab({ label: "Details", onRequestClose: vi.fn(), }); // Then const state = useSidePanelStore.getState(); expect(state.isOpen).toBe(true); expect(state.selectedTab).toBe(SIDE_PANEL_TAB.CONTEXT); expect(state.contextTab?.label).toBe("Details"); }); it("registering with select: false keeps the current tab in front", () => { // Given: a skill launch just fronted the AI tab useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT); // When: the finding drawer registers its Details tab in the background useSidePanelStore .getState() .registerContextTab( { label: "Details", onRequestClose: vi.fn() }, { select: false }, ); // Then: the panel hosts the Details tab but the AI tab stays selected const state = useSidePanelStore.getState(); expect(state.isOpen).toBe(true); expect(state.selectedTab).toBe(SIDE_PANEL_TAB.AI_CHAT); expect(state.contextTab?.label).toBe("Details"); }); it("closing the panel asks the context owner to close its detail view", () => { // Given const onRequestClose = vi.fn(); useSidePanelStore .getState() .registerContextTab({ label: "Details", onRequestClose }); // When: the user dismisses the panel (X button / Escape) useSidePanelStore.getState().closePanel(); // Then: the owning table is told to clear its selection expect(onRequestClose).toHaveBeenCalledTimes(1); expect(useSidePanelStore.getState().isOpen).toBe(false); }); it("asks the context owner to close even while the AI tab is showing", () => { // Given: details registered, user switched to the AI tab const onRequestClose = vi.fn(); useSidePanelStore .getState() .registerContextTab({ label: "Details", onRequestClose }); useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT); // When useSidePanelStore.getState().closePanel(); // Then: dismissing the panel dismisses the detail view it hosts expect(onRequestClose).toHaveBeenCalledTimes(1); }); it("unregistering the showing context tab closes the panel and falls back to AI", () => { // Given const token = useSidePanelStore.getState().registerContextTab({ label: "Details", onRequestClose: vi.fn(), }); // When useSidePanelStore.getState().unregisterContextTab(token); // Then const state = useSidePanelStore.getState(); expect(state.contextTab).toBeNull(); expect(state.isOpen).toBe(false); expect(state.selectedTab).toBe(SIDE_PANEL_TAB.AI_CHAT); }); it("unregistering while the AI tab is showing keeps the panel open", () => { // Given: details registered but the user is chatting const token = useSidePanelStore.getState().registerContextTab({ label: "Details", onRequestClose: vi.fn(), }); useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT); // When useSidePanelStore.getState().unregisterContextTab(token); // Then: the chat survives; only the Details tab disappears const state = useSidePanelStore.getState(); expect(state.contextTab).toBeNull(); expect(state.isOpen).toBe(true); expect(state.selectedTab).toBe(SIDE_PANEL_TAB.AI_CHAT); }); it("replacing the context tab asks the previous owner to close itself", () => { // Given: finding A's detail view owns the context tab const closeA = vi.fn(); useSidePanelStore .getState() .registerContextTab({ label: "Details", onRequestClose: closeA }); // When: finding B's detail view registers const closeB = vi.fn(); useSidePanelStore .getState() .registerContextTab({ label: "Details", onRequestClose: closeB }); // Then: A is told to close; B stays untouched as the new owner expect(closeA).toHaveBeenCalledTimes(1); expect(closeB).not.toHaveBeenCalled(); expect(useSidePanelStore.getState().contextTab?.onRequestClose).toBe( closeB, ); }); it("ignores an unregister from a replaced (stale) owner", () => { // Given: A registered, then B took over the context tab const tokenA = useSidePanelStore.getState().registerContextTab({ label: "Details", onRequestClose: vi.fn(), }); const tokenB = useSidePanelStore.getState().registerContextTab({ label: "Details", onRequestClose: vi.fn(), }); // When: A unmounts late and unregisters with its stale token useSidePanelStore.getState().unregisterContextTab(tokenA); // Then: B's registration survives const state = useSidePanelStore.getState(); expect(state.contextTab).not.toBeNull(); expect(state.contextOwnerToken).toBe(tokenB); expect(state.isOpen).toBe(true); // When: the current owner unregisters useSidePanelStore.getState().unregisterContextTab(tokenB); // Then expect(useSidePanelStore.getState().contextTab).toBeNull(); expect(useSidePanelStore.getState().isOpen).toBe(false); }); it("clamps resize widths to the allowed range", () => { // When: dragging far past both limits (jsdom viewport is 1024px wide) useSidePanelStore.getState().setWidth(100); expect(useSidePanelStore.getState().width).toBe(SIDE_PANEL_MIN_WIDTH); useSidePanelStore.getState().setWidth(5000); // Then: capped at 85% of the viewport expect(useSidePanelStore.getState().width).toBe( Math.floor(window.innerWidth * 0.85), ); }); it("allows the panel to grow to half the viewport on ultra-wide screens", () => { // Given: a viewport wider than the standard 1920px maximum threshold vi.stubGlobal("innerWidth", 2560); // When useSidePanelStore.getState().setWidth(5000); // Then expect(useSidePanelStore.getState().width).toBe(1280); }); it("keeps the standard maximum on screens up to 1920px wide", () => { // Given vi.stubGlobal("innerWidth", 1920); // When useSidePanelStore.getState().setWidth(5000); // Then expect(useSidePanelStore.getState().width).toBe(SIDE_PANEL_MAX_WIDTH); }); it("widens to detail room when a context tab registers, keeping wider user choices", () => { // When: registering with the default (chat) width useSidePanelStore.getState().registerContextTab({ label: "Details", onRequestClose: vi.fn(), }); // Then expect(useSidePanelStore.getState().width).toBe( SIDE_PANEL_DETAIL_MIN_WIDTH, ); // Given: the user resized wider than the detail minimum useSidePanelStore.getState().setWidth(800); // When useSidePanelStore.getState().registerContextTab({ label: "Details", onRequestClose: vi.fn(), }); // Then: the wider choice is respected expect(useSidePanelStore.getState().width).toBe(800); }); it("persists only the selected tab and width, never open state nor the context tab", () => { // When: the context tab is the one selected useSidePanelStore.getState().registerContextTab({ label: "Details", onRequestClose: vi.fn(), }); // Then: a transient context selection is persisted as the AI tab const persisted = JSON.parse( localStorage.getItem("side-panel-store") ?? "{}", ); expect(persisted.state).toEqual({ selectedTab: SIDE_PANEL_TAB.AI_CHAT, width: SIDE_PANEL_DETAIL_MIN_WIDTH, hasSeenAiTriggerHint: false, }); expect(persisted.state.isOpen).toBeUndefined(); expect(persisted.state.contextTab).toBeUndefined(); }); it("retires the AI trigger hint when the AI chat opens by any entry point", () => { // When: the trigger (or the Overview banner) opens the AI chat useSidePanelStore.getState().openPanel(SIDE_PANEL_TAB.AI_CHAT); // Then expect(useSidePanelStore.getState().hasSeenAiTriggerHint).toBe(true); }); it("retires the AI trigger hint when ⌘. toggles the panel open on the AI tab", () => { // Given: the AI tab is the remembered selection expect(useSidePanelStore.getState().selectedTab).toBe( SIDE_PANEL_TAB.AI_CHAT, ); // When useSidePanelStore.getState().togglePanel(); // Then expect(useSidePanelStore.getState().hasSeenAiTriggerHint).toBe(true); }); it("keeps the AI trigger hint while only a detail view opens the panel", () => { // When: a detail view registers (panel opens on the context tab) useSidePanelStore.getState().registerContextTab({ label: "Details", onRequestClose: vi.fn(), }); // Then: the user has not discovered the AI entry point yet expect(useSidePanelStore.getState().hasSeenAiTriggerHint).toBe(false); }); it("marks and persists the AI trigger hint as seen on dismissal", () => { // When useSidePanelStore.getState().markAiTriggerHintSeen(); // Then expect(useSidePanelStore.getState().hasSeenAiTriggerHint).toBe(true); const persisted = JSON.parse( localStorage.getItem("side-panel-store") ?? "{}", ); expect(persisted.state.hasSeenAiTriggerHint).toBe(true); }); });