Files
prowler/ui/store/__tests__/side-panel.test.ts
2026-07-16 16:02:22 +02:00

347 lines
11 KiB
TypeScript

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("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);
});
});