diff --git a/ui/src/components/shared/DeleteConfirmModal.test.tsx b/ui/src/components/shared/DeleteConfirmModal.test.tsx
new file mode 100644
index 0000000..aa466ea
--- /dev/null
+++ b/ui/src/components/shared/DeleteConfirmModal.test.tsx
@@ -0,0 +1,109 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ createMockGalleryContext,
+ type MockGalleryContext,
+} from "../../test/helpers/renderWithGallery";
+import { DeleteConfirmModal } from "./DeleteConfirmModal";
+
+// Mock useGallery
+vi.mock("../../store/GalleryContext", () => ({
+ useGallery: vi.fn(),
+}));
+
+// Mock useEscapeToClose
+vi.mock("../../hooks/useEscapeToClose", () => ({
+ useEscapeToClose: vi.fn(),
+}));
+
+// Mock useLightTableStore
+vi.mock("../../features/light-table/store", () => ({
+ useLightTableStore: Object.assign(
+ vi.fn(() => ({})),
+ {
+ getState: vi.fn(() => ({ buckets: {} })),
+ },
+ ),
+}));
+
+// Mock delete helpers
+vi.mock("../../features/images/hooks/deleteHelpers", () => ({
+ deleteImagesAndSyncLightTable: vi.fn().mockResolvedValue(undefined),
+}));
+
+// Mock images API
+vi.mock("../../features/images/api/imagesApi", () => ({
+ fetchLineage: vi.fn().mockResolvedValue([]),
+}));
+
+import { useGallery } from "../../store/GalleryContext";
+
+describe("DeleteConfirmModal", () => {
+ let ctx: MockGalleryContext;
+
+ beforeEach(() => {
+ ctx = createMockGalleryContext({
+ activeModal: { type: "delete_confirm", imageIds: [1, 2], hasLineage: false },
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+ });
+
+ it("renders 'Move to Trash' header by default", () => {
+ render();
+ const heading = document.querySelector(".meld-modal-header h2");
+ expect(heading?.textContent).toContain("Move to Trash");
+ });
+
+ it("renders 'Permanent Deletion' header when isPermanent is true", () => {
+ render();
+ const heading = document.querySelector(".meld-modal-header h2");
+ expect(heading?.textContent).toContain("Permanent Deletion");
+ });
+
+ it("displays the number of selected items", () => {
+ render();
+ expect(screen.getByText("3")).toBeInTheDocument();
+ });
+
+ it("dispatches CLOSE_MODAL when Cancel button is clicked", async () => {
+ render();
+ await userEvent.click(screen.getByText("Cancel"));
+ expect(ctx.dispatch).toHaveBeenCalledWith({ type: "CLOSE_MODAL" });
+ });
+
+ it("shows lineage buttons when hasLineage is true", () => {
+ render();
+ expect(screen.getByText("Move All Related")).toBeInTheDocument();
+ });
+
+ it("does not show lineage buttons when hasLineage is false", () => {
+ render();
+ expect(screen.queryByText("Move All Related")).not.toBeInTheDocument();
+ });
+
+ it("shows permanent delete labels when isPermanent is true with lineage", () => {
+ render();
+ expect(screen.getByText("Delete Permanently")).toBeInTheDocument();
+ expect(screen.getByText("Delete All Related")).toBeInTheDocument();
+ });
+
+ it("shows warning message for permanent deletion", () => {
+ render();
+ expect(screen.getByText(/permanently deleted/)).toBeInTheDocument();
+ });
+
+ it("shows info message for trash mode", () => {
+ render();
+ expect(screen.getByText(/moved to the trash bin/)).toBeInTheDocument();
+ });
+
+ it("dispatches CLOSE_MODAL when close (X) button is clicked", async () => {
+ render();
+ // Close button is the one with the X icon
+ const closeButton = document.querySelector(".meld-modal-close");
+ expect(closeButton).toBeTruthy();
+ await userEvent.click(closeButton as HTMLElement);
+ expect(ctx.dispatch).toHaveBeenCalledWith({ type: "CLOSE_MODAL" });
+ });
+});
diff --git a/ui/src/components/shared/GalleryModals.test.tsx b/ui/src/components/shared/GalleryModals.test.tsx
new file mode 100644
index 0000000..648e7ef
--- /dev/null
+++ b/ui/src/components/shared/GalleryModals.test.tsx
@@ -0,0 +1,157 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import {
+ createMockGalleryContext,
+ type MockGalleryContext,
+} from "../../test/helpers/renderWithGallery";
+import { GalleryModals } from "./GalleryModals";
+
+// Mock useGallery
+vi.mock("../../store/GalleryContext", () => ({
+ useGallery: vi.fn(),
+}));
+
+// Mock useWorkflowExecution
+vi.mock("../../features/workflows/hooks/useWorkflowExecution", () => ({
+ useWorkflowExecution: vi.fn(() => ({
+ executeWorkflow: vi.fn(),
+ })),
+}));
+
+// Mock child modals to keep tests focused on GalleryModals logic
+vi.mock("../../features/importer/components/ImportModal", () => ({
+ ImportModal: () =>
ImportModal
,
+}));
+vi.mock("../../features/light-table/components/DownloadModal", () => ({
+ DownloadModal: () => DownloadModal
,
+}));
+vi.mock("../../features/mask-editor/components/MaskEditorModal", () => ({
+ MaskEditorModal: () => MaskEditorModal
,
+}));
+vi.mock("../../features/mask-editor/components/MaskSequenceModal", () => ({
+ MaskSequenceModal: () => MaskSequenceModal
,
+}));
+vi.mock("../../features/settings/components/SettingsModal", () => ({
+ SettingsModal: () => SettingsModal
,
+}));
+vi.mock("../../features/tags/components/TagEditModal", () => ({
+ TagEditModal: () => TagEditModal
,
+}));
+vi.mock("../../features/viewer/components/NoteEditModal", () => ({
+ NoteEditModal: () => NoteEditModal
,
+}));
+vi.mock("../../features/workflows/components/NodeSelectionModal", () => ({
+ NodeSelectionModal: () => NodeSelectionModal
,
+}));
+vi.mock("../../features/workflows/components/WorkflowSelectionModal", () => ({
+ WorkflowSelectionModal: () => (
+ WorkflowSelectionModal
+ ),
+}));
+vi.mock("../../features/workflows/utils/injectImageToGraph", () => ({
+ injectImageToGraph: vi.fn(),
+}));
+vi.mock("./ConfirmModal", () => ({
+ ConfirmModal: ({ message }: { message: string }) => (
+ {message}
+ ),
+}));
+vi.mock("./DeleteConfirmModal", () => ({
+ DeleteConfirmModal: () => DeleteConfirmModal
,
+}));
+vi.mock("./ErrorModal", () => ({
+ ErrorModal: ({ message }: { message: string }) => {message}
,
+}));
+vi.mock("./ParentSelectionModal", () => ({
+ ParentSelectionModal: () => ParentSelectionModal
,
+}));
+vi.mock("./Toast", () => ({
+ Toast: () => Toast
,
+}));
+
+import { useGallery } from "../../store/GalleryContext";
+
+describe("GalleryModals", () => {
+ let ctx: MockGalleryContext;
+
+ it("returns null when viewer is open (viewerImageId is not null)", () => {
+ ctx = createMockGalleryContext({ viewerImageId: 42 });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders nothing extra when activeModal is none and no toast", () => {
+ ctx = createMockGalleryContext({
+ viewerImageId: null,
+ activeModal: { type: "none" },
+ confirmModal: null,
+ toastMessage: null,
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+
+ render();
+ expect(screen.queryByTestId("confirm-modal")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("toast")).not.toBeInTheDocument();
+ });
+
+ it("renders import modal when activeModal type is import", () => {
+ ctx = createMockGalleryContext({
+ viewerImageId: null,
+ activeModal: { type: "import" },
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+
+ render();
+ expect(screen.getByTestId("import-modal")).toBeInTheDocument();
+ });
+
+ it("renders settings modal when activeModal type is settings", () => {
+ ctx = createMockGalleryContext({
+ viewerImageId: null,
+ activeModal: { type: "settings" },
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+
+ render();
+ expect(screen.getByTestId("settings-modal")).toBeInTheDocument();
+ });
+
+ it("renders error modal via portal when activeModal type is error", () => {
+ ctx = createMockGalleryContext({
+ viewerImageId: null,
+ activeModal: { type: "error", message: "Something went wrong" },
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+
+ render();
+ expect(screen.getByTestId("error-modal")).toBeInTheDocument();
+ });
+
+ it("renders toast when toastMessage is present", () => {
+ ctx = createMockGalleryContext({
+ viewerImageId: null,
+ toastMessage: "Success!",
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+
+ render();
+ expect(screen.getByTestId("toast")).toBeInTheDocument();
+ });
+
+ it("renders confirm modal when confirmModal state is present", () => {
+ ctx = createMockGalleryContext({
+ viewerImageId: null,
+ confirmModal: {
+ message: "Confirm this?",
+ onConfirm: vi.fn(),
+ onCancel: vi.fn(),
+ },
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+
+ render();
+ expect(screen.getByTestId("confirm-modal")).toBeInTheDocument();
+ });
+});
diff --git a/ui/src/features/gallery/components/GalleryPanel.test.tsx b/ui/src/features/gallery/components/GalleryPanel.test.tsx
new file mode 100644
index 0000000..0579dcc
--- /dev/null
+++ b/ui/src/features/gallery/components/GalleryPanel.test.tsx
@@ -0,0 +1,221 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createTestImage, resetImageIdCounter } from "../../../test/factories/image";
+import { GalleryPanel } from "./GalleryPanel";
+
+// Mock useGalleryLogic
+const mockUseGalleryLogic = vi.fn();
+vi.mock("../hooks/useGalleryLogic", () => ({
+ useGalleryLogic: (...args: unknown[]) => mockUseGalleryLogic(...args),
+}));
+
+// Mock useLightTableStore
+vi.mock("../../light-table/store", () => ({
+ useLightTableStore: vi.fn(() => false),
+}));
+
+// Mock child components to keep tests focused on GalleryPanel logic
+vi.mock("../../../components/shared/GalleryModals", () => ({
+ GalleryModals: () => ,
+}));
+vi.mock("../../importer/components/ImportProgress", () => ({
+ ImportProgress: () => null,
+}));
+vi.mock("../../light-table/components/LightTable", () => ({
+ LightTable: () => null,
+}));
+vi.mock("../../search/components/FavoritesContextMenu", () => ({
+ FavoritesContextMenu: () => null,
+}));
+vi.mock("../../search/components/SearchBar", () => ({
+ SearchBar: () => ,
+}));
+vi.mock("../../tags/components/TagManagerView", () => ({
+ TagManagerView: () => ,
+}));
+vi.mock("../../viewer/components/ImageViewer", () => ({
+ ImageViewer: () => ,
+}));
+vi.mock("./BulkActionBar", () => ({
+ BulkActionBar: () => ,
+}));
+vi.mock("./VirtualizedGalleryList", () => ({
+ VirtualizedGalleryList: () => ,
+}));
+vi.mock("../../../styles/Gallery.css", () => ({}));
+vi.mock("../../../logger", () => ({
+ logger: { log: vi.fn(), error: vi.fn(), warn: vi.fn() },
+}));
+
+// Create default mock return value for useGalleryLogic
+function createMockLogic(overrides = {}) {
+ const loadMoreRef = { current: null };
+ return {
+ state: {
+ images: [],
+ isLoading: false,
+ error: null,
+ viewerImageId: null,
+ viewScope: "default",
+ searchQuery: "",
+ selectedIds: new Set(),
+ settings: {
+ "gallery.view_mode": "grid_details",
+ },
+ activeModal: { type: "none" },
+ pagination: { hasMore: false, limit: 50 },
+ favorites: [],
+ toastMessage: null,
+ },
+ dispatch: vi.fn(),
+ refreshImages: vi.fn(),
+ loadMoreImages: vi.fn(),
+ updateSetting: vi.fn(),
+ viewMode: "gallery",
+ setViewMode: vi.fn(),
+ lastSearchQuery: "",
+ setLastSearchQuery: vi.fn(),
+ displayedImages: [],
+ visibleImages: [],
+ isSearchActive: false,
+ loadMoreRef,
+ ...overrides,
+ };
+}
+
+describe("GalleryPanel", () => {
+ beforeEach(() => {
+ resetImageIdCounter();
+ mockUseGalleryLogic.mockReturnValue(createMockLogic());
+ });
+
+ it("shows loading message when loading with no images", () => {
+ mockUseGalleryLogic.mockReturnValue(
+ createMockLogic({
+ state: {
+ images: [],
+ isLoading: true,
+ error: null,
+ viewerImageId: null,
+ viewScope: "default",
+ searchQuery: "",
+ selectedIds: new Set(),
+ settings: { "gallery.view_mode": "grid_details" },
+ activeModal: { type: "none" },
+ pagination: { hasMore: false, limit: 50 },
+ favorites: [],
+ toastMessage: null,
+ },
+ displayedImages: [],
+ visibleImages: [],
+ }),
+ );
+
+ render();
+ expect(screen.getByText("Loading images...")).toBeInTheDocument();
+ });
+
+ it("shows empty message when no images are found", () => {
+ render();
+ expect(screen.getByText("No images found.")).toBeInTheDocument();
+ });
+
+ it("renders VirtualizedGalleryList when images are present", () => {
+ const img = createTestImage();
+ mockUseGalleryLogic.mockReturnValue(
+ createMockLogic({
+ state: {
+ images: [img],
+ isLoading: false,
+ error: null,
+ viewerImageId: null,
+ viewScope: "default",
+ searchQuery: "",
+ selectedIds: new Set(),
+ settings: { "gallery.view_mode": "grid_details" },
+ activeModal: { type: "none" },
+ pagination: { hasMore: false, limit: 50 },
+ favorites: [],
+ toastMessage: null,
+ },
+ displayedImages: [img],
+ visibleImages: [img],
+ }),
+ );
+
+ render();
+ expect(screen.getByTestId("virtualized-gallery-list")).toBeInTheDocument();
+ });
+
+ it("shows trash indicator when viewScope is trash", () => {
+ mockUseGalleryLogic.mockReturnValue(
+ createMockLogic({
+ state: {
+ images: [],
+ isLoading: false,
+ error: null,
+ viewerImageId: null,
+ viewScope: "trash",
+ searchQuery: "",
+ selectedIds: new Set(),
+ settings: { "gallery.view_mode": "grid_details" },
+ activeModal: { type: "none" },
+ pagination: { hasMore: false, limit: 50 },
+ favorites: [],
+ toastMessage: null,
+ },
+ }),
+ );
+
+ render();
+ expect(screen.getByText("Trash Bin")).toBeInTheDocument();
+ });
+
+ it("shows error message when error is present", () => {
+ mockUseGalleryLogic.mockReturnValue(
+ createMockLogic({
+ state: {
+ images: [],
+ isLoading: false,
+ error: "Something went wrong",
+ viewerImageId: null,
+ viewScope: "default",
+ searchQuery: "",
+ selectedIds: new Set(),
+ settings: { "gallery.view_mode": "grid_details" },
+ activeModal: { type: "none" },
+ pagination: { hasMore: false, limit: 50 },
+ favorites: [],
+ toastMessage: null,
+ },
+ }),
+ );
+
+ render();
+ expect(screen.getByText("Something went wrong")).toBeInTheDocument();
+ });
+
+ it("renders ImageViewer when viewerImageId is set", () => {
+ mockUseGalleryLogic.mockReturnValue(
+ createMockLogic({
+ state: {
+ images: [],
+ isLoading: false,
+ error: null,
+ viewerImageId: 1,
+ viewScope: "default",
+ searchQuery: "",
+ selectedIds: new Set(),
+ settings: { "gallery.view_mode": "grid_details" },
+ activeModal: { type: "none" },
+ pagination: { hasMore: false, limit: 50 },
+ favorites: [],
+ toastMessage: null,
+ },
+ }),
+ );
+
+ render();
+ expect(screen.getByTestId("image-viewer")).toBeInTheDocument();
+ });
+});
diff --git a/ui/src/features/gallery/components/VirtualizedGalleryList.test.tsx b/ui/src/features/gallery/components/VirtualizedGalleryList.test.tsx
new file mode 100644
index 0000000..f9b98ec
--- /dev/null
+++ b/ui/src/features/gallery/components/VirtualizedGalleryList.test.tsx
@@ -0,0 +1,118 @@
+import { render, screen } from "@testing-library/react";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { createTestImage, resetImageIdCounter } from "../../../test/factories/image";
+import type { MeldImage, Settings } from "../../../types";
+import { VirtualizedGalleryList } from "./VirtualizedGalleryList";
+
+// jsdom does not provide ResizeObserver
+beforeAll(() => {
+ globalThis.ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ } as unknown as typeof ResizeObserver;
+});
+
+// Mock @tanstack/react-virtual
+vi.mock("@tanstack/react-virtual", () => ({
+ useVirtualizer: vi.fn(() => ({
+ getVirtualItems: () => [],
+ getTotalSize: () => 0,
+ scrollToIndex: vi.fn(),
+ measureElement: vi.fn(),
+ })),
+}));
+
+// Mock ImageCard
+vi.mock("../../../components/shared/ImageCard", () => ({
+ ImageCard: ({ image }: { image: MeldImage }) => (
+ {image.filename}
+ ),
+}));
+
+const defaultSettings: Settings = {
+ "sidebar.thumbnail_size": 100,
+ "gallery.view_mode": "grid_details",
+} as Settings;
+
+describe("VirtualizedGalleryList", () => {
+ const loadMoreRef = { current: null } as React.RefObject;
+
+ beforeEach(() => {
+ resetImageIdCounter();
+ });
+
+ it("shows loading indicator when isLoading is true", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Loading more...")).toBeInTheDocument();
+ });
+
+ it("shows end of gallery when not loading and hasMore is false", () => {
+ const img = createTestImage();
+ render(
+ ,
+ );
+ expect(screen.getByText("End of gallery")).toBeInTheDocument();
+ });
+
+ it("renders the scroll container", () => {
+ render(
+ ,
+ );
+ const scrollEl = document.querySelector(".meld-gallery__list-scroll");
+ expect(scrollEl).toBeTruthy();
+ });
+
+ it("sets thumbnail size CSS variable", () => {
+ const settings = { ...defaultSettings, "sidebar.thumbnail_size": 150 } as Settings;
+ render(
+ ,
+ );
+ const scrollEl = document.querySelector(".meld-gallery__list-scroll") as HTMLElement;
+ expect(scrollEl?.style.getPropertyValue("--meld-thumbnail-size")).toBe("150px");
+ });
+
+ it("does not show end message when images list is empty", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText("End of gallery")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/src/features/viewer/components/ImageViewer.test.tsx b/ui/src/features/viewer/components/ImageViewer.test.tsx
new file mode 100644
index 0000000..64a6235
--- /dev/null
+++ b/ui/src/features/viewer/components/ImageViewer.test.tsx
@@ -0,0 +1,241 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createTestImage, resetImageIdCounter } from "../../../test/factories/image";
+import {
+ createMockGalleryContext,
+ type MockGalleryContext,
+} from "../../../test/helpers/renderWithGallery";
+import type { Settings } from "../../../types";
+import { ImageViewer } from "./ImageViewer";
+
+// Mock useGallery
+vi.mock("../../../store/GalleryContext", () => ({
+ useGallery: vi.fn(),
+}));
+
+// Mock useImageViewerLogic
+const mockUseImageViewerLogic = vi.fn();
+vi.mock("../hooks/useImageViewerLogic", () => ({
+ useImageViewerLogic: (...args: unknown[]) => mockUseImageViewerLogic(...args),
+}));
+
+// Mock useWorkflowExecution
+vi.mock("../../workflows/hooks/useWorkflowExecution", () => ({
+ useWorkflowExecution: vi.fn(() => ({
+ executeWorkflow: vi.fn(),
+ })),
+}));
+
+// Mock child components
+vi.mock("./ImageCardMenu", () => ({
+ ImageCardMenu: () => ,
+}));
+vi.mock("./ViewerInfoPanel", () => ({
+ ViewerInfoPanel: () => ,
+}));
+vi.mock("./ViewerThumbnailStrip", () => ({
+ ViewerThumbnailStrip: () => ,
+}));
+vi.mock("./ViewerCheatSheet", () => ({
+ ViewerCheatSheet: () => ,
+}));
+vi.mock("./NoteEditModal", () => ({
+ NoteEditModal: () => ,
+}));
+
+// Mock modal components
+vi.mock("../../../components/shared/DeleteConfirmModal", () => ({
+ DeleteConfirmModal: () => ,
+}));
+vi.mock("../../../components/shared/ErrorModal", () => ({
+ ErrorModal: () => ,
+}));
+vi.mock("../../../components/shared/ParentSelectionModal", () => ({
+ ParentSelectionModal: () => ,
+}));
+vi.mock("../../importer/components/ImportModal", () => ({
+ ImportModal: () => ,
+}));
+vi.mock("../../settings/components/SettingsModal", () => ({
+ SettingsModal: () => ,
+}));
+vi.mock("../../tags/components/TagEditModal", () => ({
+ TagEditModal: () => ,
+}));
+vi.mock("../../mask-editor/components/MaskEditorModal", () => ({
+ MaskEditorModal: () => ,
+}));
+vi.mock("../../mask-editor/components/MaskSequenceModal", () => ({
+ MaskSequenceModal: () => ,
+}));
+vi.mock("../../workflows/components/NodeSelectionModal", () => ({
+ NodeSelectionModal: () => ,
+}));
+vi.mock("../../workflows/components/WorkflowSelectionModal", () => ({
+ WorkflowSelectionModal: () => ,
+}));
+vi.mock("../../workflows/utils/injectImageToGraph", () => ({
+ injectImageToGraph: vi.fn(),
+}));
+vi.mock("../../../utils/url", () => ({
+ getImageViewUrl: (img: { filename: string }) => `/api/view/${img.filename}`,
+}));
+
+import { useGallery } from "../../../store/GalleryContext";
+import { initialState } from "../../../store/galleryReducer";
+
+function createMockViewerLogic(image: ReturnType | null, overrides = {}) {
+ return {
+ isFullscreen: false,
+ showDetails: false,
+ setShowDetails: vi.fn(),
+ showThumbnails: false,
+ setShowThumbnailsOverride: vi.fn(),
+ isLoadingLineage: false,
+ isJumping: false,
+ isMenuOpen: false,
+ setIsMenuOpen: vi.fn(),
+ activeShortcutKey: null,
+ setLastDeletedImages: vi.fn(),
+ overlayRef: { current: null },
+ handleNext: vi.fn(),
+ handlePrevious: vi.fn(),
+ handleTagEdit: vi.fn(),
+ handleEditNotes: vi.fn(),
+ handleRestore: vi.fn(),
+ handleRestoreWorkflow: vi.fn(),
+ handleAddUnifiedLoader: vi.fn(),
+ handleSendToWorkflow: vi.fn(),
+ handleRunWithWorkflow: vi.fn(),
+ handleRunWithMask: vi.fn(),
+ handleEditSource: vi.fn(),
+ handleDelete: vi.fn(),
+ toggleFullscreen: vi.fn(),
+ image,
+ windowedThumbnails: [],
+ parentChain: [],
+ ...overrides,
+ };
+}
+
+// Settings with icons visible
+const iconsVisibleSettings: Settings = {
+ ...initialState.settings,
+ "viewer.show_icons": true,
+ "viewer.small_image_mode": "fit",
+ "viewer.thumbnail_window_size": 5,
+};
+
+// Settings with icons hidden
+const iconsHiddenSettings: Settings = {
+ ...initialState.settings,
+ "viewer.show_icons": false,
+ "viewer.small_image_mode": "fit",
+ "viewer.thumbnail_window_size": 5,
+};
+
+describe("ImageViewer", () => {
+ let ctx: MockGalleryContext;
+
+ beforeEach(() => {
+ resetImageIdCounter();
+ });
+
+ it("renders nothing when image is null", () => {
+ ctx = createMockGalleryContext({
+ viewerImageId: 1,
+ activeModal: { type: "none" },
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+ mockUseImageViewerLogic.mockReturnValue(createMockViewerLogic(null));
+
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders the viewer overlay when image is present", () => {
+ const img = createTestImage();
+ ctx = createMockGalleryContext({
+ viewerImageId: img.id,
+ viewerMode: "gallery",
+ activeModal: { type: "none" },
+ viewScope: "default",
+ settings: iconsVisibleSettings,
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+ mockUseImageViewerLogic.mockReturnValue(createMockViewerLogic(img));
+
+ render();
+ const overlay = document.querySelector(".meld-viewer-overlay");
+ expect(overlay).toBeTruthy();
+ });
+
+ it("renders the image element with correct src", () => {
+ const img = createTestImage();
+ ctx = createMockGalleryContext({
+ viewerImageId: img.id,
+ viewerMode: "gallery",
+ activeModal: { type: "none" },
+ viewScope: "default",
+ settings: iconsVisibleSettings,
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+ mockUseImageViewerLogic.mockReturnValue(createMockViewerLogic(img));
+
+ render();
+ const imgEl = document.querySelector(".meld-viewer-image") as HTMLImageElement;
+ expect(imgEl).toBeTruthy();
+ expect(imgEl.src).toContain(img.filename);
+ });
+
+ it("renders navigation buttons when icons are shown", () => {
+ const img = createTestImage();
+ ctx = createMockGalleryContext({
+ viewerImageId: img.id,
+ viewerMode: "gallery",
+ activeModal: { type: "none" },
+ viewScope: "default",
+ settings: iconsVisibleSettings,
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+ mockUseImageViewerLogic.mockReturnValue(createMockViewerLogic(img));
+
+ render();
+ expect(document.querySelector(".meld-viewer-nav--prev")).toBeTruthy();
+ expect(document.querySelector(".meld-viewer-nav--next")).toBeTruthy();
+ });
+
+ it("renders close button with correct title", () => {
+ const img = createTestImage();
+ ctx = createMockGalleryContext({
+ viewerImageId: img.id,
+ viewerMode: "gallery",
+ activeModal: { type: "none" },
+ viewScope: "default",
+ settings: iconsVisibleSettings,
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+ mockUseImageViewerLogic.mockReturnValue(createMockViewerLogic(img));
+
+ render();
+ const closeBtn = screen.getByTitle("Close (Esc)");
+ expect(closeBtn).toBeInTheDocument();
+ });
+
+ it("does not render navigation buttons when icons are hidden", () => {
+ const img = createTestImage();
+ ctx = createMockGalleryContext({
+ viewerImageId: img.id,
+ viewerMode: "gallery",
+ activeModal: { type: "none" },
+ viewScope: "default",
+ settings: iconsHiddenSettings,
+ });
+ vi.mocked(useGallery).mockReturnValue(ctx);
+ mockUseImageViewerLogic.mockReturnValue(createMockViewerLogic(img));
+
+ render();
+ expect(document.querySelector(".meld-viewer-nav--prev")).toBeNull();
+ expect(document.querySelector(".meld-viewer-nav--next")).toBeNull();
+ });
+});