mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
1530 lines
63 KiB
JavaScript
1530 lines
63 KiB
JavaScript
import { mount, config as VTUConfig } from "@vue/test-utils";
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import * as contexts from "options/contexts";
|
|
import { nextTick } from "vue";
|
|
import PLightbox from "component/lightbox.vue";
|
|
import Photo from "model/photo";
|
|
import Thumb from "model/thumb";
|
|
import Album from "model/album";
|
|
import $util from "common/util";
|
|
import { buildNamespace } from "common/storage";
|
|
import { FaceMarkerDisplay, FaceMarkerEdit } from "options/face-marker";
|
|
import clientConfig from "../config";
|
|
|
|
// makeFaceMarkers builds a minimal mock of the face-markers singleton.
|
|
// Lightbox methods read `this.faceMarkers.X` via the data() handle in
|
|
// real life; in tests the ctx object stands in for the component
|
|
// instance, so we attach a hand-built mock matching the singleton shape.
|
|
const makeFaceMarkers = (overrides = {}) => ({
|
|
mode: null,
|
|
busy: false,
|
|
pendingNameMarkerUid: "",
|
|
active: false,
|
|
isDisplay: false,
|
|
isEdit: false,
|
|
display: vi.fn(),
|
|
edit: vi.fn(),
|
|
exit: vi.fn(),
|
|
setMode: vi.fn(),
|
|
setBusy: vi.fn(),
|
|
setPendingNameMarkerUid: vi.fn(),
|
|
reset: vi.fn(),
|
|
...overrides,
|
|
});
|
|
|
|
const storagePrefix = buildNamespace(clientConfig.storageNamespace);
|
|
const sidebarKey = `${storagePrefix}lightbox.sidebar`;
|
|
const mutedKey = `${storagePrefix}lightbox.muted`;
|
|
const captionKey = `${storagePrefix}lightbox.caption`;
|
|
|
|
const mountLightbox = () =>
|
|
mount(PLightbox, {
|
|
global: {
|
|
stubs: {
|
|
"v-dialog": true,
|
|
"v-icon": true,
|
|
"v-slider": true,
|
|
"p-lightbox-menu": true,
|
|
"p-lightbox-sidebar": true,
|
|
},
|
|
mocks: {
|
|
$util,
|
|
},
|
|
},
|
|
});
|
|
|
|
// allowAllConfig stubs the ACL gates some lightbox methods read via
|
|
// this.$config.allow/deny. Vue's globalProperties (`$config`,
|
|
// `$session`) are not own properties of `wrapper.vm`, so a
|
|
// `{ ...wrapper.vm, ... }` spread does NOT carry them onto a synthetic
|
|
// test ctx — methods driven via $options.methods.<X>.call(ctx) that
|
|
// read $config must add it explicitly. This is the permissive default;
|
|
// suites that exercise a denied path override deny / allow inline.
|
|
const allowAllConfig = { allow: () => true, deny: () => false };
|
|
|
|
describe("PLightbox (low-mock, jsdom-friendly)", () => {
|
|
beforeEach(() => {
|
|
localStorage.removeItem(sidebarKey);
|
|
localStorage.removeItem(captionKey);
|
|
sessionStorage.removeItem(mutedKey);
|
|
});
|
|
|
|
it("toggleSidebar updates sidebar and localStorage when visible", async () => {
|
|
const wrapper = mountLightbox();
|
|
await wrapper.setData({ visible: true });
|
|
|
|
// Use exposed onShortCut to trigger sidebar toggle (KeyI)
|
|
await wrapper.vm.onShortCut({ code: "KeyI" });
|
|
await nextTick();
|
|
expect(localStorage.getItem(sidebarKey)).toBe("true");
|
|
|
|
await wrapper.vm.onShortCut({ code: "KeyI" });
|
|
await nextTick();
|
|
expect(localStorage.getItem(sidebarKey)).toBe("false");
|
|
});
|
|
|
|
it("toggleMute writes sessionStorage without requiring video or exposed state", async () => {
|
|
const wrapper = mountLightbox();
|
|
expect(sessionStorage.getItem(mutedKey)).toBeNull();
|
|
await wrapper.vm.onShortCut({ code: "KeyM" });
|
|
expect(sessionStorage.getItem(mutedKey)).toBe("true");
|
|
await wrapper.vm.onShortCut({ code: "KeyM" });
|
|
expect(sessionStorage.getItem(mutedKey)).toBe("false");
|
|
});
|
|
|
|
it("getPadding returns expected structure for large and small screens", async () => {
|
|
const wrapper = mountLightbox();
|
|
// Large viewport
|
|
const large = wrapper.vm.$options.methods.getPadding.call(wrapper.vm, { x: 1200, y: 800 }, { width: 4000, height: 3000 });
|
|
expect(large).toHaveProperty("top");
|
|
expect(large).toHaveProperty("bottom");
|
|
expect(large).toHaveProperty("left");
|
|
expect(large).toHaveProperty("right");
|
|
|
|
// Small viewport (<= mobileBreakpoint) should yield zeros
|
|
const small = wrapper.vm.$options.methods.getPadding.call(wrapper.vm, { x: 360, y: 640 }, { width: 1200, height: 800 });
|
|
expect(small).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
|
|
});
|
|
|
|
it("KeyI is ignored when dialog is not visible", async () => {
|
|
const wrapper = mountLightbox();
|
|
expect(localStorage.getItem(sidebarKey)).toBeNull();
|
|
await wrapper.vm.onShortCut({ code: "KeyI" });
|
|
expect(localStorage.getItem(sidebarKey)).toBeNull();
|
|
});
|
|
|
|
// Ctrl+H (#5580) toggles the PhotoSwipe Dynamic Caption overlay and
|
|
// persists the choice to localStorage. Default state is visible
|
|
// (hideCaption reads "lightbox.caption" with a strict !== "false"
|
|
// check, so missing-key resolves to true). Mirrors the toggleSidebar
|
|
// pattern so future shortcut additions can copy the same shape.
|
|
it("toggleCaption updates hideCaption and localStorage when visible", async () => {
|
|
const wrapper = mountLightbox();
|
|
await wrapper.setData({ visible: true });
|
|
|
|
// Default: missing localStorage entry → hideCaption defaults to true.
|
|
expect(localStorage.getItem(captionKey)).toBeNull();
|
|
|
|
await wrapper.vm.onShortCut({ code: "KeyH" });
|
|
await nextTick();
|
|
expect(localStorage.getItem(captionKey)).toBe("false");
|
|
|
|
await wrapper.vm.onShortCut({ code: "KeyH" });
|
|
await nextTick();
|
|
expect(localStorage.getItem(captionKey)).toBe("true");
|
|
});
|
|
|
|
it("KeyH is ignored when dialog is not visible", async () => {
|
|
const wrapper = mountLightbox();
|
|
expect(localStorage.getItem(captionKey)).toBeNull();
|
|
await wrapper.vm.onShortCut({ code: "KeyH" });
|
|
expect(localStorage.getItem(captionKey)).toBeNull();
|
|
});
|
|
|
|
// onShortCut focus guard. Mirrors onPswpKeyDown's pattern: when an
|
|
// input/textarea/contenteditable element holds focus, the global
|
|
// keydown forwarder must defer to the browser's native handling so
|
|
// text-editing shortcuts (Ctrl+A select-all, Ctrl+C copy, Ctrl+X
|
|
// cut, Ctrl+V paste, Ctrl+Z undo, …) work as expected instead of
|
|
// triggering global lightbox actions. Covers KeyX specifically
|
|
// (the current Archive/Restore binding — would silently delete the
|
|
// photo if the gate regressed) plus KeyA (the canonical select-all
|
|
// chord, also gated even though no longer bound, as defense in
|
|
// depth) plus the surrounding non-input case that proves the gate
|
|
// doesn't accidentally suppress legitimate shortcuts.
|
|
describe("onShortCut — input focus guard", () => {
|
|
let scratch;
|
|
beforeEach(() => {
|
|
scratch = document.createElement("div");
|
|
document.body.appendChild(scratch);
|
|
});
|
|
afterEach(() => {
|
|
if (scratch && scratch.parentNode) {
|
|
scratch.parentNode.removeChild(scratch);
|
|
}
|
|
});
|
|
|
|
it("returns false (no-op) when an INPUT is focused — KeyX must not archive", () => {
|
|
const wrapper = mountLightbox();
|
|
const onArchive = vi.spyOn(wrapper.vm.$options.methods, "onArchive").mockImplementation(() => {});
|
|
const input = document.createElement("input");
|
|
scratch.appendChild(input);
|
|
input.focus();
|
|
const ret = wrapper.vm.$options.methods.onShortCut.call(wrapper.vm, { code: "KeyX" });
|
|
expect(ret).toBe(false);
|
|
expect(onArchive).not.toHaveBeenCalled();
|
|
onArchive.mockRestore();
|
|
});
|
|
|
|
it("returns false (no-op) when a TEXTAREA is focused — KeyX must not archive", () => {
|
|
const wrapper = mountLightbox();
|
|
const onArchive = vi.spyOn(wrapper.vm.$options.methods, "onArchive").mockImplementation(() => {});
|
|
const ta = document.createElement("textarea");
|
|
scratch.appendChild(ta);
|
|
ta.focus();
|
|
const ret = wrapper.vm.$options.methods.onShortCut.call(wrapper.vm, { code: "KeyX" });
|
|
expect(ret).toBe(false);
|
|
expect(onArchive).not.toHaveBeenCalled();
|
|
onArchive.mockRestore();
|
|
});
|
|
|
|
it("returns false for unbound text-editing chords too (KeyA select-all)", () => {
|
|
const wrapper = mountLightbox();
|
|
const ta = document.createElement("textarea");
|
|
scratch.appendChild(ta);
|
|
ta.focus();
|
|
const ret = wrapper.vm.$options.methods.onShortCut.call(wrapper.vm, { code: "KeyA" });
|
|
expect(ret).toBe(false);
|
|
});
|
|
|
|
it("does NOT bail when focus is on a non-editable element (KeyM still mutes)", async () => {
|
|
const wrapper = mountLightbox();
|
|
const span = document.createElement("span");
|
|
span.tabIndex = 0;
|
|
scratch.appendChild(span);
|
|
span.focus();
|
|
expect(sessionStorage.getItem(mutedKey)).toBeNull();
|
|
await wrapper.vm.onShortCut({ code: "KeyM" });
|
|
expect(sessionStorage.getItem(mutedKey)).toBe("true");
|
|
});
|
|
});
|
|
|
|
describe("onPswpKeyDown — sidebar input focus guard", () => {
|
|
let scratch;
|
|
beforeEach(() => {
|
|
scratch = document.createElement("div");
|
|
document.body.appendChild(scratch);
|
|
});
|
|
afterEach(() => {
|
|
if (scratch && scratch.parentNode) {
|
|
scratch.parentNode.removeChild(scratch);
|
|
}
|
|
});
|
|
|
|
it("calls preventDefault when sidebar is open and an INPUT is focused", () => {
|
|
const wrapper = mountLightbox();
|
|
wrapper.vm.sidebarVisible = true;
|
|
const input = document.createElement("input");
|
|
scratch.appendChild(input);
|
|
input.focus();
|
|
const ev = { preventDefault: vi.fn() };
|
|
wrapper.vm.$options.methods.onPswpKeyDown.call(wrapper.vm, ev);
|
|
expect(ev.preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("calls preventDefault when sidebar is open and a TEXTAREA is focused", () => {
|
|
const wrapper = mountLightbox();
|
|
wrapper.vm.sidebarVisible = true;
|
|
const ta = document.createElement("textarea");
|
|
scratch.appendChild(ta);
|
|
ta.focus();
|
|
const ev = { preventDefault: vi.fn() };
|
|
wrapper.vm.$options.methods.onPswpKeyDown.call(wrapper.vm, ev);
|
|
expect(ev.preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Note: isContentEditable behavior isn't reliably simulated by jsdom, so
|
|
// the contenteditable branch of onPswpKeyDown is exercised in the browser
|
|
// ui-tester run. The INPUT / TEXTAREA / non-editable / no-sidebar / no-event
|
|
// cases here cover the predicate's two-class boundary in unit tests.
|
|
it("does NOT call preventDefault when sidebar is closed even with input focused", () => {
|
|
const wrapper = mountLightbox();
|
|
wrapper.vm.sidebarVisible = false;
|
|
const input = document.createElement("input");
|
|
scratch.appendChild(input);
|
|
input.focus();
|
|
const ev = { preventDefault: vi.fn() };
|
|
wrapper.vm.$options.methods.onPswpKeyDown.call(wrapper.vm, ev);
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT call preventDefault when focus is on a non-editable element", () => {
|
|
const wrapper = mountLightbox();
|
|
wrapper.vm.sidebarVisible = true;
|
|
const span = document.createElement("span");
|
|
span.tabIndex = 0;
|
|
scratch.appendChild(span);
|
|
span.focus();
|
|
const ev = { preventDefault: vi.fn() };
|
|
wrapper.vm.$options.methods.onPswpKeyDown.call(wrapper.vm, ev);
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tolerates a missing event argument", () => {
|
|
const wrapper = mountLightbox();
|
|
wrapper.vm.sidebarVisible = true;
|
|
expect(() => wrapper.vm.$options.methods.onPswpKeyDown.call(wrapper.vm, undefined)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("onTabKey — Tab focus suppression scoped to lightbox tree", () => {
|
|
let scratch;
|
|
beforeEach(() => {
|
|
scratch = document.createElement("div");
|
|
document.body.appendChild(scratch);
|
|
});
|
|
afterEach(() => {
|
|
if (scratch && scratch.parentNode) {
|
|
scratch.parentNode.removeChild(scratch);
|
|
}
|
|
});
|
|
|
|
it("calls stopPropagation when focus is inside the lightbox tree", () => {
|
|
const wrapper = mountLightbox();
|
|
const root = document.createElement("div");
|
|
const inner = document.createElement("input");
|
|
root.appendChild(inner);
|
|
scratch.appendChild(root);
|
|
inner.focus();
|
|
const ev = { stopPropagation: vi.fn() };
|
|
// Call with a fake `this` that supplies the container ref — the
|
|
// mountLightbox-built proxy doesn't expose a writable `$refs`.
|
|
wrapper.vm.$options.methods.onTabKey.call({ $refs: { container: root } }, ev);
|
|
expect(ev.stopPropagation).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("falls back to the content ref when container is missing", () => {
|
|
const wrapper = mountLightbox();
|
|
const content = document.createElement("div");
|
|
const inner = document.createElement("input");
|
|
content.appendChild(inner);
|
|
scratch.appendChild(content);
|
|
inner.focus();
|
|
const ev = { stopPropagation: vi.fn() };
|
|
wrapper.vm.$options.methods.onTabKey.call({ $refs: { container: null, content } }, ev);
|
|
expect(ev.stopPropagation).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does NOT call stopPropagation when focus is outside the lightbox tree", () => {
|
|
const wrapper = mountLightbox();
|
|
const root = document.createElement("div");
|
|
scratch.appendChild(root);
|
|
const outsider = document.createElement("input");
|
|
scratch.appendChild(outsider);
|
|
outsider.focus();
|
|
const ev = { stopPropagation: vi.fn() };
|
|
wrapper.vm.$options.methods.onTabKey.call({ $refs: { container: root } }, ev);
|
|
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT call stopPropagation when no container ref is available", () => {
|
|
const wrapper = mountLightbox();
|
|
const ev = { stopPropagation: vi.fn() };
|
|
wrapper.vm.$options.methods.onTabKey.call({ $refs: { container: null, content: null } }, ev);
|
|
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tolerates a missing event argument", () => {
|
|
const wrapper = mountLightbox();
|
|
expect(() =>
|
|
wrapper.vm.$options.methods.onTabKey.call({ $refs: { container: scratch } }, undefined)
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
it("getViewport falls back to window size without content ref", () => {
|
|
const wrapper = mountLightbox();
|
|
const vp = wrapper.vm.$options.methods.getViewport.call(wrapper.vm);
|
|
expect(vp.x).toBeGreaterThan(0);
|
|
expect(vp.y).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("menuActions marks Download action visible when allowed", () => {
|
|
const wrapper = mountLightbox();
|
|
const ctx = {
|
|
$gettext: VTUConfig.global.mocks.$gettext,
|
|
$pgettext: VTUConfig.global.mocks.$pgettext,
|
|
// minimal state needed by menuActions visibility checks
|
|
canManageAlbums: false,
|
|
canArchive: false,
|
|
canDownload: true,
|
|
collection: null,
|
|
context: contexts.Default,
|
|
model: {},
|
|
};
|
|
const actions = wrapper.vm.$options.methods.menuActions.call(ctx);
|
|
const download = actions.find((a) => a?.name === "download");
|
|
expect(download).toBeTruthy();
|
|
expect(download.visible).toBe(true);
|
|
});
|
|
|
|
// P1-10 — pause playable media (and any running slideshow) whenever
|
|
// face-marker mode is entered, whether display or draw. Drawing on a
|
|
// moving frame leads to wrong-rectangle saves and Live / Animated
|
|
// never expose video controls, so manual pause isn't an option.
|
|
// Playback is NOT resumed on exit; the user reopens it explicitly if
|
|
// they want it back.
|
|
describe("enterFaceMarkerMode — pauses media on entry into either face-marker mode", () => {
|
|
it("calls pauseLightbox so video and slideshow both stop", () => {
|
|
const ctx = {
|
|
pauseLightbox: vi.fn(),
|
|
$refs: {},
|
|
$nextTick: (cb) => Promise.resolve().then(cb),
|
|
};
|
|
const wrapper = mountLightbox();
|
|
wrapper.vm.$options.methods.enterFaceMarkerMode.call(ctx);
|
|
expect(ctx.pauseLightbox).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("schedules an overlay bounds recompute after the visibility swap", async () => {
|
|
const overlay = { scheduleUpdate: vi.fn() };
|
|
const ctx = {
|
|
pauseLightbox: vi.fn(),
|
|
$refs: { faceMarkerOverlay: overlay },
|
|
$nextTick: (cb) => Promise.resolve().then(cb),
|
|
};
|
|
const wrapper = mountLightbox();
|
|
wrapper.vm.$options.methods.enterFaceMarkerMode.call(ctx);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(overlay.scheduleUpdate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("is a safe no-op when the face-marker overlay is not mounted", () => {
|
|
const ctx = {
|
|
pauseLightbox: vi.fn(),
|
|
$refs: {},
|
|
$nextTick: (cb) => Promise.resolve().then(cb),
|
|
};
|
|
const wrapper = mountLightbox();
|
|
expect(() => wrapper.vm.$options.methods.enterFaceMarkerMode.call(ctx)).not.toThrow();
|
|
});
|
|
|
|
// P1-10 — closing the sidebar while face-marker UI is active fully
|
|
// exits it. The eye and ✓ Done controls live in the sidebar, so a
|
|
// closed sidebar would otherwise leave the overlay mounted with no
|
|
// way to disable it.
|
|
it("hideSidebar exits face-marker UI so the overlay tears down", async () => {
|
|
const wrapper = mountLightbox();
|
|
const exitFaceMarkerMode = vi.fn();
|
|
const ctx = {
|
|
visible: true,
|
|
sidebarVisible: true,
|
|
faceMarkers: makeFaceMarkers({ active: true, isEdit: true, mode: FaceMarkerEdit }),
|
|
exitFaceMarkerMode,
|
|
confirmDiscardSidebar: () => Promise.resolve(true),
|
|
$nextTick: (cb) => Promise.resolve().then(cb),
|
|
resize: vi.fn(),
|
|
focusContent: vi.fn(),
|
|
};
|
|
await wrapper.vm.$options.methods.hideSidebar.call(ctx);
|
|
expect(ctx.sidebarVisible).toBe(false);
|
|
expect(exitFaceMarkerMode).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Guard: hideSidebar does not exit face-marker UI if the user cancels
|
|
// out of the discard prompt. Sidebar (and overlay) stay open.
|
|
it("hideSidebar keeps face-marker UI when confirmDiscardSidebar resolves false", async () => {
|
|
const wrapper = mountLightbox();
|
|
const exitFaceMarkerMode = vi.fn();
|
|
const faceMarkers = makeFaceMarkers({ active: true, isEdit: true, mode: FaceMarkerEdit });
|
|
const ctx = {
|
|
visible: true,
|
|
sidebarVisible: true,
|
|
faceMarkers,
|
|
exitFaceMarkerMode,
|
|
confirmDiscardSidebar: () => Promise.resolve(false),
|
|
$nextTick: (cb) => Promise.resolve().then(cb),
|
|
resize: vi.fn(),
|
|
focusContent: vi.fn(),
|
|
};
|
|
await wrapper.vm.$options.methods.hideSidebar.call(ctx);
|
|
expect(ctx.sidebarVisible).toBe(true);
|
|
expect(faceMarkers.mode).toBe(FaceMarkerEdit);
|
|
expect(exitFaceMarkerMode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Wiring: the faceMarkerMode watcher routes through enterFaceMarkerMode
|
|
// on null → active transitions and exitFaceMarkerMode on active → null.
|
|
// Transitions between two truthy modes (display ↔ draw) are no-ops —
|
|
// playback is already paused and the markers stay on screen.
|
|
it("faceMarkers.mode watcher enters on null → active and exits on active → null", () => {
|
|
const wrapper = mountLightbox();
|
|
const watcher = wrapper.vm.$options.watch["faceMarkers.mode"];
|
|
const ctx = {
|
|
enterFaceMarkerMode: vi.fn(),
|
|
exitFaceMarkerMode: vi.fn(),
|
|
};
|
|
// null → display: enters.
|
|
watcher.call(ctx, FaceMarkerDisplay, null);
|
|
expect(ctx.enterFaceMarkerMode).toHaveBeenCalledTimes(1);
|
|
expect(ctx.exitFaceMarkerMode).not.toHaveBeenCalled();
|
|
// null → draw: enters.
|
|
ctx.enterFaceMarkerMode.mockClear();
|
|
watcher.call(ctx, FaceMarkerEdit, null);
|
|
expect(ctx.enterFaceMarkerMode).toHaveBeenCalledTimes(1);
|
|
// display → null: exits.
|
|
watcher.call(ctx, null, FaceMarkerDisplay);
|
|
expect(ctx.exitFaceMarkerMode).toHaveBeenCalledTimes(1);
|
|
// draw → null: exits.
|
|
ctx.exitFaceMarkerMode.mockClear();
|
|
watcher.call(ctx, null, FaceMarkerEdit);
|
|
expect(ctx.exitFaceMarkerMode).toHaveBeenCalledTimes(1);
|
|
// Truthy → truthy transitions are no-ops (✓ Done step-down + eye-from-draw).
|
|
ctx.enterFaceMarkerMode.mockClear();
|
|
ctx.exitFaceMarkerMode.mockClear();
|
|
watcher.call(ctx, FaceMarkerDisplay, FaceMarkerEdit);
|
|
watcher.call(ctx, FaceMarkerEdit, FaceMarkerDisplay);
|
|
expect(ctx.enterFaceMarkerMode).not.toHaveBeenCalled();
|
|
expect(ctx.exitFaceMarkerMode).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// Escape during face-draw mode must exit draw mode without closing the
|
|
// lightbox. The lightbox owns the routing via `onEscapeKey` (wired to
|
|
// both the v-dialog `@keydown.esc.exact` handler and `onShortCut`),
|
|
// delegating fine-grained draft / pending cleanup to the face-marker
|
|
// overlay first. See `frontend/src/common/README.md` for the documented
|
|
// keyboard pattern this follows.
|
|
describe("onEscapeKey — face-draw vs lightbox close routing", () => {
|
|
it("delegates to the face-marker overlay first when it has a pending rect", () => {
|
|
const wrapper = mountLightbox();
|
|
const handleEscape = vi.fn().mockReturnValue(true);
|
|
const exitFaceMarkerMode = vi.fn();
|
|
const close = vi.fn();
|
|
const ctx = {
|
|
$refs: { faceMarkerOverlay: { handleEscape } },
|
|
faceMarkers: makeFaceMarkers({ active: true, isEdit: true, mode: FaceMarkerEdit }),
|
|
exitFaceMarkerMode,
|
|
close,
|
|
};
|
|
wrapper.vm.$options.methods.onEscapeKey.call(ctx);
|
|
expect(handleEscape).toHaveBeenCalledTimes(1);
|
|
expect(exitFaceMarkerMode).not.toHaveBeenCalled();
|
|
expect(close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hides face-marker UI when the overlay had nothing to cancel and draw mode is active", () => {
|
|
const wrapper = mountLightbox();
|
|
const handleEscape = vi.fn().mockReturnValue(false);
|
|
const exitFaceMarkerMode = vi.fn();
|
|
const close = vi.fn();
|
|
const ctx = {
|
|
$refs: { faceMarkerOverlay: { handleEscape } },
|
|
faceMarkers: makeFaceMarkers({ active: true, isEdit: true, mode: FaceMarkerEdit }),
|
|
exitFaceMarkerMode,
|
|
close,
|
|
};
|
|
wrapper.vm.$options.methods.onEscapeKey.call(ctx);
|
|
expect(exitFaceMarkerMode).toHaveBeenCalledTimes(1);
|
|
expect(close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Display-only markers (eye toggle without Add Face) also exit on
|
|
// Escape — consistent with the Add Face path, and saves the user a
|
|
// dedicated "hide markers" gesture.
|
|
it("hides face-marker UI when only display mode is active", () => {
|
|
const wrapper = mountLightbox();
|
|
const exitFaceMarkerMode = vi.fn();
|
|
const close = vi.fn();
|
|
const ctx = {
|
|
$refs: {},
|
|
faceMarkers: makeFaceMarkers({ active: true, isDisplay: true, mode: FaceMarkerDisplay }),
|
|
exitFaceMarkerMode,
|
|
close,
|
|
};
|
|
wrapper.vm.$options.methods.onEscapeKey.call(ctx);
|
|
expect(exitFaceMarkerMode).toHaveBeenCalledTimes(1);
|
|
expect(close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes the lightbox when no face-marker UI is active", () => {
|
|
const wrapper = mountLightbox();
|
|
const exitFaceMarkerMode = vi.fn();
|
|
const close = vi.fn();
|
|
const ctx = { $refs: {}, faceMarkers: makeFaceMarkers(), exitFaceMarkerMode, close };
|
|
wrapper.vm.$options.methods.onEscapeKey.call(ctx);
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(exitFaceMarkerMode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// exitFaceMarkerMode delegates to the singleton's exit() — the
|
|
// singleton clears mode (and the marker array is now derived from
|
|
// the photo, so no local copy needs resetting).
|
|
it("exitFaceMarkerMode calls faceMarkers.exit()", () => {
|
|
const wrapper = mountLightbox();
|
|
const faceMarkers = makeFaceMarkers({ active: true, isEdit: true, mode: FaceMarkerEdit });
|
|
const ctx = { faceMarkers };
|
|
wrapper.vm.$options.methods.exitFaceMarkerMode.call(ctx);
|
|
expect(faceMarkers.exit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// The pencil toggle for editable users now fully exits face-marker
|
|
// mode on second click — landing on `null` instead of stepping
|
|
// down to FaceMarkerDisplay. The historical ✓ Done step-down made
|
|
// sense when the sidebar had both an eye toggle and a pencil
|
|
// toggle; with the per-role simplification (editable users see
|
|
// ONLY the pencil), exiting must land on `null` or the toggle
|
|
// gets stuck in the "on" state with no way to leave face-marker
|
|
// mode from the sidebar.
|
|
it("toggleFaceMarkerEdit exits to null when already active (pencil off)", () => {
|
|
const wrapper = mountLightbox();
|
|
const exitFaceMarkerMode = vi.fn();
|
|
const faceMarkers = makeFaceMarkers({ active: true, isEdit: true, mode: FaceMarkerEdit });
|
|
const ctx = {
|
|
faceMarkers,
|
|
shouldShowEditButton: () => true,
|
|
exitFaceMarkerMode,
|
|
$refs: {},
|
|
};
|
|
wrapper.vm.$options.methods.toggleFaceMarkerEdit.call(ctx);
|
|
expect(exitFaceMarkerMode).toHaveBeenCalledTimes(1);
|
|
expect(faceMarkers.edit).not.toHaveBeenCalled();
|
|
expect(faceMarkers.display).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// toggleFaceMarkerMode is the non-editable eye toggle. Active →
|
|
// exitFaceMarkerMode (lands on null); null → display. The gate
|
|
// dropped the `shouldShowEditButton()` check so non-editable users
|
|
// who reach this handler (via the eye toggle the template now
|
|
// gives them) can actually toggle display mode.
|
|
it("toggleFaceMarkerMode's exit path routes through exitFaceMarkerMode from display (eye toggle off)", () => {
|
|
const wrapper = mountLightbox();
|
|
const exitFaceMarkerMode = vi.fn();
|
|
const ctx = {
|
|
faceMarkers: makeFaceMarkers({ active: true, isDisplay: true, mode: FaceMarkerDisplay }),
|
|
featPeople: true,
|
|
exitFaceMarkerMode,
|
|
};
|
|
wrapper.vm.$options.methods.toggleFaceMarkerMode.call(ctx);
|
|
expect(exitFaceMarkerMode).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("toggleFaceMarkerMode enters display when null and featPeople is true", () => {
|
|
const wrapper = mountLightbox();
|
|
const faceMarkers = makeFaceMarkers();
|
|
const ctx = {
|
|
faceMarkers,
|
|
featPeople: true,
|
|
exitFaceMarkerMode: vi.fn(),
|
|
};
|
|
wrapper.vm.$options.methods.toggleFaceMarkerMode.call(ctx);
|
|
expect(faceMarkers.display).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("toggleFaceMarkerMode is a no-op when featPeople is false", () => {
|
|
const wrapper = mountLightbox();
|
|
const faceMarkers = makeFaceMarkers();
|
|
const ctx = { faceMarkers, featPeople: false, exitFaceMarkerMode: vi.fn() };
|
|
wrapper.vm.$options.methods.toggleFaceMarkerMode.call(ctx);
|
|
expect(faceMarkers.display).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("onShortCut Escape routes through onEscapeKey, not close directly", () => {
|
|
const wrapper = mountLightbox();
|
|
const onEscapeKey = vi.fn();
|
|
const ctx = { onEscapeKey };
|
|
const handled = wrapper.vm.$options.methods.onShortCut.call(ctx, { code: "Escape" });
|
|
expect(handled).toBe(true);
|
|
expect(onEscapeKey).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// While face-marker mode is active, keys that open hidden chrome
|
|
// (menus), stack a competing modal, fire silent destructive
|
|
// actions, or contradict the entry-only-pause / overlay-stays-
|
|
// mounted contracts are gated to a no-op. Escape / Tab / KeyI /
|
|
// KeyD / KeyF / KeyM stay enabled.
|
|
describe("face-marker mode shortcut gates", () => {
|
|
const disabled = ["Period", "KeyX", "KeyE", "KeyH", "KeyL", "KeyS", "ArrowLeft", "ArrowRight", "Space"];
|
|
const enabled = ["KeyD", "KeyF", "KeyI", "KeyM"];
|
|
|
|
it("isShortcutDisabledInFaceMarkerMode returns true for every conflicting key and false for the rest", () => {
|
|
const wrapper = mountLightbox();
|
|
const predicate = wrapper.vm.$options.methods.isShortcutDisabledInFaceMarkerMode;
|
|
for (const code of disabled) {
|
|
expect(predicate(code)).toBe(true);
|
|
}
|
|
for (const code of enabled) {
|
|
expect(predicate(code)).toBe(false);
|
|
}
|
|
// Escape + Tab stay enabled — Escape via the priority chain,
|
|
// Tab via the v-dialog-level focus handler.
|
|
expect(predicate("Escape")).toBe(false);
|
|
expect(predicate("Tab")).toBe(false);
|
|
});
|
|
|
|
// The gate keys on `faceMarkers.active` (truthy for BOTH display
|
|
// and draw modes), so the inert key set must apply identically
|
|
// in both modes — playback / slideshow / nav stay frozen in
|
|
// either case, so the corresponding shortcuts must stay inert in
|
|
// either case. Parametrized over both modes pins the contract.
|
|
const modes = [
|
|
["display", { isDisplay: true, mode: FaceMarkerDisplay }],
|
|
["edit", { isEdit: true, mode: FaceMarkerEdit }],
|
|
];
|
|
|
|
for (const [label, modeFlags] of modes) {
|
|
it(`onShortCut short-circuits gated keys in ${label} mode`, () => {
|
|
const wrapper = mountLightbox();
|
|
const onShowMenu = vi.fn();
|
|
const toggleSlideshow = vi.fn();
|
|
const onArchive = vi.fn();
|
|
const ctx = {
|
|
faceMarkers: makeFaceMarkers({ active: true, ...modeFlags }),
|
|
isShortcutDisabledInFaceMarkerMode: wrapper.vm.$options.methods.isShortcutDisabledInFaceMarkerMode,
|
|
onShowMenu,
|
|
toggleSlideshow,
|
|
onArchive,
|
|
canArchive: true,
|
|
context: contexts.Photos,
|
|
model: { Archived: false },
|
|
};
|
|
for (const code of disabled) {
|
|
const r = wrapper.vm.$options.methods.onShortCut.call(ctx, { code });
|
|
expect(r).toBe(false);
|
|
}
|
|
expect(onShowMenu).not.toHaveBeenCalled();
|
|
expect(toggleSlideshow).not.toHaveBeenCalled();
|
|
expect(onArchive).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it(`onKeyDown short-circuits Space + Arrow keys in ${label} mode`, () => {
|
|
const wrapper = mountLightbox();
|
|
const pswpStub = { prev: vi.fn(), next: vi.fn() };
|
|
const toggleVideo = vi.fn();
|
|
const ctx = {
|
|
visible: true,
|
|
sidebarVisible: false,
|
|
faceMarkers: makeFaceMarkers({ active: true, ...modeFlags }),
|
|
isShortcutDisabledInFaceMarkerMode: wrapper.vm.$options.methods.isShortcutDisabledInFaceMarkerMode,
|
|
$view: { isActive: () => true },
|
|
pauseSlideshow: vi.fn(),
|
|
pswp: () => pswpStub,
|
|
toggleVideo,
|
|
toggleControls: vi.fn(),
|
|
getContent: () => ({ video: null }),
|
|
model: {},
|
|
video: { controls: false, playing: false },
|
|
models: [{}, {}],
|
|
index: 0,
|
|
$isRtl: false,
|
|
};
|
|
for (const code of ["ArrowLeft", "ArrowRight", "Space"]) {
|
|
wrapper.vm.$options.methods.onKeyDown.call(ctx, {
|
|
code,
|
|
preventDefault: () => {},
|
|
stopPropagation: () => {},
|
|
});
|
|
}
|
|
expect(pswpStub.prev).not.toHaveBeenCalled();
|
|
expect(pswpStub.next).not.toHaveBeenCalled();
|
|
expect(toggleVideo).not.toHaveBeenCalled();
|
|
});
|
|
}
|
|
|
|
it("onShortCut still routes Escape + KeyI even when face-marker mode is active", () => {
|
|
const wrapper = mountLightbox();
|
|
const onEscapeKey = vi.fn();
|
|
const toggleSidebar = vi.fn();
|
|
const ctx = {
|
|
faceMarkers: makeFaceMarkers({ active: true, isDisplay: true, mode: FaceMarkerDisplay }),
|
|
isShortcutDisabledInFaceMarkerMode: wrapper.vm.$options.methods.isShortcutDisabledInFaceMarkerMode,
|
|
onEscapeKey,
|
|
toggleSidebar,
|
|
};
|
|
wrapper.vm.$options.methods.onShortCut.call(ctx, { code: "Escape" });
|
|
wrapper.vm.$options.methods.onShortCut.call(ctx, { code: "KeyI" });
|
|
expect(onEscapeKey).toHaveBeenCalledTimes(1);
|
|
expect(toggleSidebar).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("captionPlugin.formatCaption returns sanitized caption html", async () => {
|
|
const Captions = (await import("common/captions")).default;
|
|
const plugin = new Captions({ on() {} }, {});
|
|
const caption = plugin.formatCaption({
|
|
Title: `Title <img src=x onerror="alert(1)">`,
|
|
Caption: `Visit https://example.com/?q=1&x=2`,
|
|
});
|
|
|
|
expect(caption).toContain('<h4>Title <img src=x onerror="alert(1)"></h4>');
|
|
expect(caption).toContain(`<p>Visit <a href="https://example.com/" target="_blank" rel="noopener noreferrer">https://example.com/</a></p>`);
|
|
expect(caption).not.toContain("<img");
|
|
});
|
|
|
|
it("fetchPhoto loads reduced metadata even when ACL denies photos:access_library", async () => {
|
|
// The /photos/:uid endpoint now reduces detail server-side for
|
|
// shared-only sessions, so the sidebar preloads for every session and
|
|
// the previous slide's (stale) photo IS replaced by the loaded metadata;
|
|
// the sidebar's per-section ACL gates decide what renders.
|
|
const loaded = new Photo({ UID: "ps6sg6be2lvl0yh7", Title: "Shared" });
|
|
const spy = vi.spyOn(Photo, "findCached").mockResolvedValue(loaded);
|
|
const wrapper = mountLightbox();
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
photo: new Photo({ UID: "stale" }),
|
|
model: new Thumb({ UID: "ps6sg6be2lvl0yh7" }),
|
|
$config: { ...wrapper.vm.$config, deny: () => true, allow: () => false },
|
|
};
|
|
|
|
wrapper.vm.$options.methods.fetchPhoto.call(ctx, "ps6sg6be2lvl0yh7");
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(spy).toHaveBeenCalledWith("ps6sg6be2lvl0yh7");
|
|
expect(ctx.photo).toBe(loaded);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("fetchPhoto resets to an empty Photo when the uid is missing", () => {
|
|
const spy = vi.spyOn(Photo, "findCached");
|
|
const wrapper = mountLightbox();
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
photo: new Photo({ UID: "stale" }),
|
|
model: new Thumb({ UID: "" }),
|
|
$config: allowAllConfig,
|
|
};
|
|
|
|
wrapper.vm.$options.methods.fetchPhoto.call(ctx, "");
|
|
|
|
// No uid → empty Photo (not null) so the sidebar can read
|
|
// this.view.photo.X without nullable chains, and no fetch is issued.
|
|
expect(ctx.photo).toBeInstanceOf(Photo);
|
|
expect(ctx.photo.UID).toBe("");
|
|
expect(spy).not.toHaveBeenCalled();
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("fetchPhoto calls Photo.findCached when ACL allows photos:access_library", () => {
|
|
const spy = vi.spyOn(Photo, "findCached").mockResolvedValue({});
|
|
const wrapper = mountLightbox();
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
photo: null,
|
|
model: new Thumb({ UID: "ps6sg6be2lvl0yh7" }),
|
|
$config: { ...wrapper.vm.$config, deny: () => false, allow: () => true },
|
|
};
|
|
|
|
wrapper.vm.$options.methods.fetchPhoto.call(ctx, "ps6sg6be2lvl0yh7");
|
|
|
|
expect(spy).toHaveBeenCalledWith("ps6sg6be2lvl0yh7");
|
|
spy.mockRestore();
|
|
});
|
|
|
|
// Symmetric to fetchPhoto: prefetch also runs for shared-only sessions
|
|
// now that /photos/:uid reduces detail server-side. Gating remains on
|
|
// sidebar visibility, not on library access.
|
|
it("preloadNextPhoto prefetches even when ACL denies photos:access_library", () => {
|
|
const spy = vi.spyOn(Photo, "prefetchAround").mockReturnValue(Promise.resolve([]));
|
|
const wrapper = mountLightbox();
|
|
const models = [{ UID: "uid-curr" }, { UID: "uid-next" }];
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
sidebarVisible: true,
|
|
models,
|
|
index: 0,
|
|
$config: { ...wrapper.vm.$config, deny: () => true, allow: () => false },
|
|
};
|
|
|
|
wrapper.vm.$options.methods.preloadNextPhoto.call(ctx);
|
|
|
|
expect(spy).toHaveBeenCalledWith(models, 0, { before: 0, after: 1 });
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("preloadNextPhoto skips Photo.prefetchAround when the sidebar is hidden", () => {
|
|
const spy = vi.spyOn(Photo, "prefetchAround");
|
|
const wrapper = mountLightbox();
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
sidebarVisible: false,
|
|
models: [{ UID: "uid-curr" }, { UID: "uid-next" }],
|
|
index: 0,
|
|
};
|
|
|
|
wrapper.vm.$options.methods.preloadNextPhoto.call(ctx);
|
|
|
|
expect(spy).not.toHaveBeenCalled();
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("preloadNextPhoto delegates to Photo.prefetchAround when sidebar is visible and unrestricted", () => {
|
|
const spy = vi.spyOn(Photo, "prefetchAround").mockReturnValue(Promise.resolve([]));
|
|
const wrapper = mountLightbox();
|
|
const models = [{ UID: "uid-curr" }, { UID: "uid-next" }];
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
sidebarVisible: true,
|
|
models,
|
|
index: 0,
|
|
$config: allowAllConfig,
|
|
};
|
|
|
|
wrapper.vm.$options.methods.preloadNextPhoto.call(ctx);
|
|
|
|
expect(spy).toHaveBeenCalledWith(models, 0, { before: 0, after: 1 });
|
|
spy.mockRestore();
|
|
});
|
|
|
|
// The race guard inside fetchPhoto is the last line of defense against
|
|
// a slow /photos/:uid response landing after the user has already
|
|
// swiped to the next slide. Without the `this.model.UID === uid` check,
|
|
// an out-of-order resolution would overwrite `this.photo` with the
|
|
// previous slide's metadata and the sidebar would silently flip to
|
|
// editing the wrong photo. Pin the contract with a deterministic test.
|
|
describe("fetchPhoto race guard", () => {
|
|
it("does NOT overwrite this.photo when the user has navigated away before the fetch resolves", async () => {
|
|
let resolveSlideN;
|
|
const findSpy = vi.spyOn(Photo, "findCached").mockImplementation(
|
|
(uid) =>
|
|
new Promise((res) => {
|
|
// Only the slide-N fetch is pending; slide-N+1 isn't issued in this test.
|
|
if (uid === "uid-slide-n") {
|
|
resolveSlideN = () => res(new Photo({ UID: "uid-slide-n", Title: "Slide N" }));
|
|
}
|
|
})
|
|
);
|
|
|
|
const wrapper = mountLightbox();
|
|
const placeholder = new Photo();
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
photo: placeholder,
|
|
// Start with the user viewing slide N.
|
|
model: new Thumb({ UID: "uid-slide-n" }),
|
|
$config: allowAllConfig,
|
|
};
|
|
|
|
// Sidebar fetch issued for slide N.
|
|
wrapper.vm.$options.methods.fetchPhoto.call(ctx, "uid-slide-n");
|
|
|
|
// User swipes to slide N+1 BEFORE slide N's response arrives.
|
|
ctx.model = new Thumb({ UID: "uid-slide-n-plus-1" });
|
|
|
|
// Slide N's response finally lands.
|
|
resolveSlideN();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
// The race guard MUST keep ctx.photo on the placeholder — slide N's
|
|
// payload is dropped because this.model.UID has already moved on.
|
|
expect(ctx.photo).toBe(placeholder);
|
|
findSpy.mockRestore();
|
|
});
|
|
|
|
it("applies the response when this.model.UID still matches the fetched uid", async () => {
|
|
const slideNPhoto = new Photo({ UID: "uid-slide-n", Title: "Slide N" });
|
|
const findSpy = vi.spyOn(Photo, "findCached").mockResolvedValue(slideNPhoto);
|
|
|
|
const wrapper = mountLightbox();
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
photo: new Photo(),
|
|
model: new Thumb({ UID: "uid-slide-n" }),
|
|
$config: allowAllConfig,
|
|
};
|
|
|
|
wrapper.vm.$options.methods.fetchPhoto.call(ctx, "uid-slide-n");
|
|
// Drain the resolved Promise + race-guard .then.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(ctx.photo).toBe(slideNPhoto);
|
|
findSpy.mockRestore();
|
|
});
|
|
|
|
it("absorbs a rejected findCached without throwing or mutating this.photo", async () => {
|
|
const findSpy = vi.spyOn(Photo, "findCached").mockRejectedValue(new Error("offline"));
|
|
|
|
const wrapper = mountLightbox();
|
|
const placeholder = new Photo();
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
photo: placeholder,
|
|
model: new Thumb({ UID: "uid-slide-n" }),
|
|
$config: allowAllConfig,
|
|
};
|
|
|
|
// Calling fetchPhoto must not throw even when findCached rejects.
|
|
expect(() => wrapper.vm.$options.methods.fetchPhoto.call(ctx, "uid-slide-n")).not.toThrow();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
// The placeholder stays in place — the sidebar continues to read
|
|
// from this.view.photo.X without nullable chains.
|
|
expect(ctx.photo).toBe(placeholder);
|
|
findSpy.mockRestore();
|
|
});
|
|
|
|
// Companion to the ModelCache epoch-rejection test: when the cache
|
|
// rejects an in-flight fetch after Photo.clearCache() (logout race),
|
|
// the lightbox's existing .catch handler must absorb it cleanly
|
|
// and leave this.photo untouched — even when this.model.UID still
|
|
// matches the requested uid (i.e. the race-guard isn't what's
|
|
// saving us; the rejection is). Without this, a logout-then-relogin
|
|
// window could route role-A data into role-B UI before unmount.
|
|
it("absorbs a ModelCacheStaleFetchError from findCached without mutating this.photo", async () => {
|
|
const findSpy = vi.spyOn(Photo, "findCached").mockImplementation(() => {
|
|
const err = new Error("ModelCache: discarded stale fetch after clear()");
|
|
err.name = "ModelCacheStaleFetchError";
|
|
return Promise.reject(err);
|
|
});
|
|
|
|
const wrapper = mountLightbox();
|
|
const placeholder = new Photo();
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
photo: placeholder,
|
|
// model.UID intentionally STILL matches — to prove the rejection
|
|
// (not the race-guard) is what protects this.photo here.
|
|
model: new Thumb({ UID: "uid-slide-n" }),
|
|
$config: allowAllConfig,
|
|
};
|
|
|
|
expect(() => wrapper.vm.$options.methods.fetchPhoto.call(ctx, "uid-slide-n")).not.toThrow();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(ctx.photo).toBe(placeholder);
|
|
findSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// preloadNextPhoto is fire-and-forget. The realistic contract under
|
|
// test is that a slow prefetch that resolves AFTER the user has
|
|
// moved on doesn't block or interfere — the actual Photo.prefetchAround
|
|
// wraps tasks in Promise.allSettled, so it can't reject in practice.
|
|
describe("preloadNextPhoto async resilience", () => {
|
|
it("forwards the call when the prefetch resolves late", async () => {
|
|
let resolvePrefetch;
|
|
const spy = vi.spyOn(Photo, "prefetchAround").mockImplementation(() => new Promise((res) => (resolvePrefetch = res)));
|
|
|
|
const wrapper = mountLightbox();
|
|
const models = [{ UID: "uid-curr" }, { UID: "uid-next" }];
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
sidebarVisible: true,
|
|
models,
|
|
index: 0,
|
|
$config: allowAllConfig,
|
|
};
|
|
|
|
wrapper.vm.$options.methods.preloadNextPhoto.call(ctx);
|
|
|
|
// Even after a later resolve, the call site must still have been
|
|
// invoked exactly once with the documented args.
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
expect(spy).toHaveBeenCalledWith(models, 0, { before: 0, after: 1 });
|
|
resolvePrefetch([]);
|
|
await Promise.resolve();
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// Wiring tests for the lightbox archive / restore / album-remove
|
|
// delegations. These pin the component-to-Thumb boundary so a
|
|
// future refactor that breaks the call edge (e.g., reverting back
|
|
// to inline $api.post or skipping the model methods) fails here
|
|
// instead of silently surviving the unit-test layer.
|
|
describe("onArchive wiring", () => {
|
|
it("calls this.model.archive() and notifies on success when canArchive is true", async () => {
|
|
const wrapper = mountLightbox();
|
|
const model = new Thumb({ UID: "uid-archive" });
|
|
const archiveSpy = vi.spyOn(model, "archive").mockResolvedValue({ status: 200, data: {} });
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
model,
|
|
canArchive: true,
|
|
pauseSlideshow: vi.fn(),
|
|
$notify: { ...wrapper.vm.$notify, success: vi.fn() },
|
|
$gettext: VTUConfig.global.mocks.$gettext,
|
|
};
|
|
|
|
await wrapper.vm.$options.methods.onArchive.call(ctx);
|
|
|
|
expect(ctx.pauseSlideshow).toHaveBeenCalledTimes(1);
|
|
expect(archiveSpy).toHaveBeenCalledTimes(1);
|
|
expect(ctx.$notify.success).toHaveBeenCalledTimes(1);
|
|
archiveSpy.mockRestore();
|
|
});
|
|
|
|
it("does NOT call archive() when canArchive is false", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = new Thumb({ UID: "uid-archive" });
|
|
const archiveSpy = vi.spyOn(model, "archive");
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
model,
|
|
canArchive: false,
|
|
pauseSlideshow: vi.fn(),
|
|
};
|
|
|
|
wrapper.vm.$options.methods.onArchive.call(ctx);
|
|
|
|
expect(archiveSpy).not.toHaveBeenCalled();
|
|
expect(ctx.pauseSlideshow).not.toHaveBeenCalled();
|
|
archiveSpy.mockRestore();
|
|
});
|
|
|
|
it("does NOT call archive() when model.UID is empty", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = new Thumb({ UID: "" });
|
|
const archiveSpy = vi.spyOn(model, "archive");
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
model,
|
|
canArchive: true,
|
|
pauseSlideshow: vi.fn(),
|
|
log: vi.fn(),
|
|
};
|
|
|
|
wrapper.vm.$options.methods.onArchive.call(ctx);
|
|
|
|
expect(archiveSpy).not.toHaveBeenCalled();
|
|
archiveSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("onRestore wiring", () => {
|
|
it("calls this.model.restore() and notifies on success when canArchive is true", async () => {
|
|
const wrapper = mountLightbox();
|
|
const model = new Thumb({ UID: "uid-restore", Archived: true });
|
|
const restoreSpy = vi.spyOn(model, "restore").mockResolvedValue({ status: 200, data: {} });
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
model,
|
|
canArchive: true,
|
|
pauseSlideshow: vi.fn(),
|
|
$notify: { ...wrapper.vm.$notify, success: vi.fn() },
|
|
$gettext: VTUConfig.global.mocks.$gettext,
|
|
};
|
|
|
|
wrapper.vm.$options.methods.onRestore.call(ctx);
|
|
// Drain the .then chain.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(ctx.pauseSlideshow).toHaveBeenCalledTimes(1);
|
|
expect(restoreSpy).toHaveBeenCalledTimes(1);
|
|
expect(ctx.$notify.success).toHaveBeenCalledTimes(1);
|
|
restoreSpy.mockRestore();
|
|
});
|
|
|
|
it("does NOT call restore() when canArchive is false", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = new Thumb({ UID: "uid-restore", Archived: true });
|
|
const restoreSpy = vi.spyOn(model, "restore");
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
model,
|
|
canArchive: false,
|
|
pauseSlideshow: vi.fn(),
|
|
};
|
|
|
|
wrapper.vm.$options.methods.onRestore.call(ctx);
|
|
|
|
expect(restoreSpy).not.toHaveBeenCalled();
|
|
restoreSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("onRemoveFromAlbum wiring", () => {
|
|
it("calls this.model.removeFromAlbum(collection.UID) then evicts the photo on success", async () => {
|
|
const wrapper = mountLightbox();
|
|
const model = new Thumb({ UID: "uid-remove" });
|
|
const removeSpy = vi.spyOn(model, "removeFromAlbum").mockResolvedValue({ status: 200, data: {} });
|
|
const evictSpy = vi.spyOn(model, "evictPhoto");
|
|
const collection = new Album({ UID: "album-1" });
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
model,
|
|
collection,
|
|
canManageAlbums: true,
|
|
pauseSlideshow: vi.fn(),
|
|
};
|
|
|
|
wrapper.vm.$options.methods.onRemoveFromAlbum.call(ctx);
|
|
// Drain the .then chain.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(ctx.pauseSlideshow).toHaveBeenCalledTimes(1);
|
|
expect(removeSpy).toHaveBeenCalledWith("album-1");
|
|
// Album-remove publishes only albums.updated, so the manual
|
|
// evictPhoto() in onRemoveFromAlbum.then is what drops the
|
|
// sidebar's stale Photo.Albums view.
|
|
expect(evictSpy).toHaveBeenCalledTimes(1);
|
|
removeSpy.mockRestore();
|
|
evictSpy.mockRestore();
|
|
});
|
|
|
|
it("does NOT call removeFromAlbum when canManageAlbums is false", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = new Thumb({ UID: "uid-remove" });
|
|
const removeSpy = vi.spyOn(model, "removeFromAlbum");
|
|
const collection = new Album({ UID: "album-1" });
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
model,
|
|
collection,
|
|
canManageAlbums: false,
|
|
pauseSlideshow: vi.fn(),
|
|
};
|
|
|
|
wrapper.vm.$options.methods.onRemoveFromAlbum.call(ctx);
|
|
|
|
expect(removeSpy).not.toHaveBeenCalled();
|
|
removeSpy.mockRestore();
|
|
});
|
|
|
|
it("does NOT call removeFromAlbum when collection isn't an Album", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = new Thumb({ UID: "uid-remove" });
|
|
const removeSpy = vi.spyOn(model, "removeFromAlbum");
|
|
// A plain object (or a non-Album collection) must short-circuit.
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
model,
|
|
collection: { UID: "not-an-album-instance" },
|
|
canManageAlbums: true,
|
|
pauseSlideshow: vi.fn(),
|
|
};
|
|
|
|
wrapper.vm.$options.methods.onRemoveFromAlbum.call(ctx);
|
|
|
|
expect(removeSpy).not.toHaveBeenCalled();
|
|
removeSpy.mockRestore();
|
|
});
|
|
|
|
it("does NOT call evictPhoto when removeFromAlbum rejects", async () => {
|
|
// The optimistic Removed flip and its rollback live inside
|
|
// Thumb.removeFromAlbum (covered in thumb.test.js); here we
|
|
// pin that the lightbox does NOT evict the cache on failure
|
|
// — otherwise the sidebar would lose its (still-correct)
|
|
// Photo.Albums view after a no-op failed remove.
|
|
const wrapper = mountLightbox();
|
|
const model = new Thumb({ UID: "uid-remove" });
|
|
const removeSpy = vi.spyOn(model, "removeFromAlbum").mockRejectedValue(new Error("offline"));
|
|
const evictSpy = vi.spyOn(model, "evictPhoto");
|
|
const collection = new Album({ UID: "album-1" });
|
|
const ctx = {
|
|
...wrapper.vm,
|
|
model,
|
|
collection,
|
|
canManageAlbums: true,
|
|
pauseSlideshow: vi.fn(),
|
|
};
|
|
|
|
wrapper.vm.$options.methods.onRemoveFromAlbum.call(ctx);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(removeSpy).toHaveBeenCalledTimes(1);
|
|
expect(evictSpy).not.toHaveBeenCalled();
|
|
removeSpy.mockRestore();
|
|
evictSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// getItemData dispatch: equirectangular media must route to the sphere viewer,
|
|
// while a regular photo with no projection must stay on the flat lightbox path.
|
|
describe("getItemData sphere dispatch", () => {
|
|
const baseThumbs = {
|
|
fit_720: { src: "/thumb-720.jpg", w: 720, h: 360 },
|
|
fit_1280: { src: "/thumb-1280.jpg", w: 1280, h: 640 },
|
|
fit_2048: { src: "/thumb-2048.jpg", w: 2048, h: 1024 },
|
|
fit_2560: { src: "/thumb-2560.jpg", w: 2560, h: 1280 },
|
|
fit_3840: { src: "/thumb-3840.jpg", w: 3840, h: 1920 },
|
|
fit_4096: { src: "/thumb-4096.jpg", w: 4096, h: 2048 },
|
|
fit_7680: { src: "/thumb-7680.jpg", w: 7680, h: 3840 },
|
|
};
|
|
|
|
it("returns sphere data for an equirectangular photo", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = { Hash: "abc", Projection: "equirectangular", Type: "image", Thumbs: baseThumbs, Title: "Pano" };
|
|
const ctx = { ...wrapper.vm, models: [model], $util, getSlidePixels: () => ({ width: 2048, height: 1024 }) };
|
|
|
|
const slide = wrapper.vm.$options.methods.getItemData.call(ctx, null, 0);
|
|
expect(slide.type).toBe("html");
|
|
expect(slide.isSphere).toBe(true);
|
|
expect(slide.isVideo).toBe(false);
|
|
expect(slide.src).toBeTruthy();
|
|
});
|
|
|
|
it("returns sphere data with isVideo=true for an equirectangular video", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = { Hash: "vid", Projection: "equirectangular", Type: "video", Thumbs: baseThumbs, Codec: "avc1", Mime: "video/mp4", Playable: true };
|
|
const ctx = { ...wrapper.vm, models: [model], $util, getSlidePixels: () => ({ width: 2048, height: 1024 }) };
|
|
|
|
const slide = wrapper.vm.$options.methods.getItemData.call(ctx, null, 0);
|
|
expect(slide.type).toBe("html");
|
|
expect(slide.isSphere).toBe(true);
|
|
expect(slide.isVideo).toBe(true);
|
|
});
|
|
|
|
it("returns flat image data for a non-equirectangular photo (regression)", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = { Hash: "xyz", Projection: "", Type: "image", Thumbs: baseThumbs, Title: "Regular" };
|
|
const ctx = { ...wrapper.vm, models: [model], $util, getSlidePixels: () => ({ width: 2048, height: 1024 }) };
|
|
|
|
const slide = wrapper.vm.$options.methods.getItemData.call(ctx, null, 0);
|
|
expect(slide.type).toBeUndefined();
|
|
expect(slide.isSphere).toBeUndefined();
|
|
expect(slide.src).toBeTruthy();
|
|
});
|
|
it("does NOT mount the sphere for an equirectangular-tagged photo that is not 2:1 (partial panorama)", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = { Hash: "strip", Projection: "equirectangular", Type: "image", Width: 8192, Height: 1024, Thumbs: baseThumbs, Title: "Wide" };
|
|
const ctx = { ...wrapper.vm, models: [model], $util, getSlidePixels: () => ({ width: 2048, height: 1024 }) };
|
|
|
|
const slide = wrapper.vm.$options.methods.getItemData.call(ctx, null, 0);
|
|
expect(slide.isSphere).toBeUndefined();
|
|
});
|
|
it("does NOT mount the sphere for a cubemap video even when Panorama is flagged", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = { Hash: "cube", Projection: "cubemap", Panorama: true, Type: "video", Thumbs: baseThumbs, Codec: "avc1", Mime: "video/mp4", Playable: true };
|
|
const ctx = { ...wrapper.vm, models: [model], $util, getSlidePixels: () => ({ width: 2048, height: 1024 }) };
|
|
|
|
const slide = wrapper.vm.$options.methods.getItemData.call(ctx, null, 0);
|
|
expect(slide.isSphere).toBeUndefined();
|
|
});
|
|
it("does NOT mount the sphere for an ultrawide (non-2:1) video flagged as panorama", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = { Hash: "wide", Projection: "", Panorama: true, Type: "video", Width: 3840, Height: 1632, Thumbs: baseThumbs, Codec: "avc1", Mime: "video/mp4", Playable: true };
|
|
const ctx = { ...wrapper.vm, models: [model], $util, getSlidePixels: () => ({ width: 2048, height: 1024 }) };
|
|
|
|
const slide = wrapper.vm.$options.methods.getItemData.call(ctx, null, 0);
|
|
expect(slide.isSphere).toBeUndefined();
|
|
});
|
|
it("mounts the sphere for a 2:1 panorama video that carries no projection tag", () => {
|
|
const wrapper = mountLightbox();
|
|
const model = { Hash: "vr", Projection: "", Panorama: true, Type: "video", Width: 3840, Height: 1920, Thumbs: baseThumbs, Codec: "avc1", Mime: "video/mp4", Playable: true };
|
|
const ctx = { ...wrapper.vm, models: [model], $util, getSlidePixels: () => ({ width: 2048, height: 1024 }) };
|
|
|
|
const slide = wrapper.vm.$options.methods.getItemData.call(ctx, null, 0);
|
|
expect(slide.isSphere).toBe(true);
|
|
expect(slide.isVideo).toBe(true);
|
|
});
|
|
});
|
|
|
|
// bindSphereVideoControls writes to the single shared `video` reactive state.
|
|
// PhotoSwipe preloads neighbors and sphere binding is async, so the controls
|
|
// bar must only turn on for the active slide — otherwise a preloaded 360° video
|
|
// flips the bar on while a plain photo is on screen.
|
|
describe("bindSphereVideoControls active-slide gating", () => {
|
|
const makeVideoState = () => ({
|
|
controls: false,
|
|
error: "",
|
|
errorCode: 0,
|
|
duration: 0,
|
|
time: 0,
|
|
seekable: false,
|
|
playing: false,
|
|
paused: false,
|
|
ended: false,
|
|
});
|
|
|
|
it("turns controls on when its slide is the active one", () => {
|
|
const wrapper = mountLightbox();
|
|
const videoEl = document.createElement("video");
|
|
const content = { data: {} };
|
|
const ctx = { video: makeVideoState(), videoEventListener: () => {}, pswp: () => ({ currSlide: { content } }) };
|
|
|
|
wrapper.vm.$options.methods.bindSphereVideoControls.call(ctx, content, videoEl);
|
|
expect(ctx.video.controls).toBe(true);
|
|
expect(content.data.events).toBeInstanceOf(AbortController);
|
|
});
|
|
|
|
it("leaves controls off for a preloaded (non-active) neighbor but still binds listeners", () => {
|
|
const wrapper = mountLightbox();
|
|
const videoEl = document.createElement("video");
|
|
const content = { data: {} };
|
|
const activeContent = { data: {} };
|
|
const ctx = { video: makeVideoState(), videoEventListener: () => {}, pswp: () => ({ currSlide: { content: activeContent } }) };
|
|
|
|
wrapper.vm.$options.methods.bindSphereVideoControls.call(ctx, content, videoEl);
|
|
expect(ctx.video.controls).toBe(false);
|
|
expect(content.data.events).toBeInstanceOf(AbortController);
|
|
});
|
|
|
|
it("leaves controls off when there is no PhotoSwipe instance", () => {
|
|
const wrapper = mountLightbox();
|
|
const videoEl = document.createElement("video");
|
|
const content = { data: {} };
|
|
const ctx = { video: makeVideoState(), videoEventListener: () => {}, pswp: () => null };
|
|
|
|
wrapper.vm.$options.methods.bindSphereVideoControls.call(ctx, content, videoEl);
|
|
expect(ctx.video.controls).toBe(false);
|
|
});
|
|
});
|
|
|
|
// slideZoomable drives the `.is-zoomable` class that shows/hides the zoom
|
|
// button. 360° slides must report false so the flat zoom button is hidden.
|
|
describe("slideZoomable", () => {
|
|
const slideZoomable = (data) => {
|
|
const wrapper = mountLightbox();
|
|
return wrapper.vm.$options.methods.slideZoomable.call(wrapper.vm, data);
|
|
};
|
|
|
|
it("returns false for a 360° sphere slide", () => {
|
|
expect(slideZoomable({ isSphere: true, model: { Type: "image" } })).toBe(false);
|
|
});
|
|
it("returns false for video and animation slides", () => {
|
|
expect(slideZoomable({ model: { Type: "video" } })).toBe(false);
|
|
expect(slideZoomable({ model: { Type: "animated" } })).toBe(false);
|
|
});
|
|
it("returns true for a regular image and a live photo", () => {
|
|
expect(slideZoomable({ model: { Type: "image" } })).toBe(true);
|
|
expect(slideZoomable({ model: { Type: "live" } })).toBe(true);
|
|
});
|
|
it("defaults to true when data or model is missing", () => {
|
|
expect(slideZoomable({})).toBe(true);
|
|
expect(slideZoomable(undefined)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// trapSphereGestures must swallow PhotoSwipe's pointer + wheel gestures on the
|
|
// sphere container, but must NOT trap touch events — Photo Sphere Viewer pans
|
|
// from touchmove/touchend listeners bound on `window`, so trapping touch here
|
|
// would break 360° panning on touch devices. A short press without a pan must
|
|
// toggle the lightbox controls so the prev/next arrows stay reachable on mobile.
|
|
describe("trapSphereGestures", () => {
|
|
const trap = () => {
|
|
const wrapper = mountLightbox();
|
|
wrapper.vm.toggleControls = vi.fn();
|
|
const registered = [];
|
|
const el = { addEventListener: (type, handler, opts) => registered.push({ type, handler, opts }) };
|
|
wrapper.vm.$options.methods.trapSphereGestures.call(wrapper.vm, el);
|
|
const fire = (type, props = {}) => registered.filter((r) => r.type === type).forEach((r) => r.handler({ stopPropagation: () => {}, ...props }));
|
|
return { vm: wrapper.vm, registered, fire };
|
|
};
|
|
|
|
it("traps pointer and wheel events", () => {
|
|
const types = trap().registered.map((r) => r.type);
|
|
expect(types).toEqual(expect.arrayContaining(["pointerdown", "pointermove", "pointerup", "pointercancel", "wheel"]));
|
|
});
|
|
it("does NOT trap touch events so PSV panning works on mobile", () => {
|
|
const types = trap().registered.map((r) => r.type);
|
|
expect(types).not.toContain("touchstart");
|
|
expect(types).not.toContain("touchmove");
|
|
expect(types).not.toContain("touchend");
|
|
});
|
|
it("registers listeners in the bubble phase and stops propagation", () => {
|
|
const { registered } = trap();
|
|
expect(registered.every((r) => r.opts && r.opts.capture === false)).toBe(true);
|
|
const ev = { stopPropagation: vi.fn() };
|
|
registered[0].handler(ev);
|
|
expect(ev.stopPropagation).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("toggles controls on a touch tap without a pan", () => {
|
|
const { vm, fire } = trap();
|
|
fire("pointerdown", { pointerType: "touch", clientX: 100, clientY: 100 });
|
|
fire("pointerup", { pointerType: "touch", clientX: 103, clientY: 102 });
|
|
expect(vm.toggleControls).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("does NOT toggle controls when the touch is a pan", () => {
|
|
const { vm, fire } = trap();
|
|
fire("pointerdown", { pointerType: "touch", clientX: 100, clientY: 100 });
|
|
fire("pointerup", { pointerType: "touch", clientX: 180, clientY: 100 });
|
|
expect(vm.toggleControls).not.toHaveBeenCalled();
|
|
});
|
|
it("does NOT toggle controls for a mouse press so desktop is unchanged", () => {
|
|
const { vm, fire } = trap();
|
|
fire("pointerdown", { pointerType: "mouse", clientX: 100, clientY: 100 });
|
|
fire("pointerup", { pointerType: "mouse", clientX: 100, clientY: 100 });
|
|
expect(vm.toggleControls).not.toHaveBeenCalled();
|
|
});
|
|
it("does NOT toggle controls when a tap is canceled", () => {
|
|
const { vm, fire } = trap();
|
|
fire("pointerdown", { pointerType: "touch", clientX: 100, clientY: 100 });
|
|
fire("pointercancel", { pointerType: "touch" });
|
|
fire("pointerup", { pointerType: "touch", clientX: 100, clientY: 100 });
|
|
expect(vm.toggleControls).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// setSphereClass marks the PhotoSwipe root so CSS can keep the prev/next arrows
|
|
// reachable on touch devices for 360° slides (PhotoSwipe hides them on touch).
|
|
describe("setSphereClass", () => {
|
|
const run = (enabled, hasEl = true) => {
|
|
const wrapper = mountLightbox();
|
|
const el = document.createElement("div");
|
|
wrapper.vm.pswp = () => (hasEl ? { element: el } : {});
|
|
wrapper.vm.$options.methods.setSphereClass.call(wrapper.vm, enabled);
|
|
return el;
|
|
};
|
|
|
|
it("adds pswp--sphere for a sphere slide", () => {
|
|
expect(run(true).classList.contains("pswp--sphere")).toBe(true);
|
|
});
|
|
it("removes pswp--sphere for a non-sphere slide", () => {
|
|
const wrapper = mountLightbox();
|
|
const el = document.createElement("div");
|
|
el.classList.add("pswp--sphere");
|
|
wrapper.vm.pswp = () => ({ element: el });
|
|
wrapper.vm.$options.methods.setSphereClass.call(wrapper.vm, false);
|
|
expect(el.classList.contains("pswp--sphere")).toBe(false);
|
|
});
|
|
it("is a no-op when the PhotoSwipe element is missing", () => {
|
|
expect(() => run(true, false)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// A 360° sphere slide owns horizontal drag for panning, so PhotoSwipe's swipe-to-
|
|
// navigate must be suppressed at its source. The per-element gesture trap can be
|
|
// outrun by a fast swipe that leaves the sphere container (seen on touch-capable
|
|
// Windows), so onLightboxPointerEvent also default-prevents the dispatched pointer
|
|
// event whenever the active slide is a sphere. UI controls stay excluded.
|
|
describe("sphere swipe navigation block", () => {
|
|
const methods = () => mountLightbox().vm.$options.methods;
|
|
const makeCtx = (isSphere) => {
|
|
const m = methods();
|
|
const ctx = { debug: false, pswp: () => ({ currSlide: { content: { data: { isSphere } } } }) };
|
|
ctx.pswpControl = (ev) => m.pswpControl.call(ctx, ev);
|
|
ctx.activeSlideIsSphere = () => m.activeSlideIsSphere.call(ctx);
|
|
ctx.onLightboxPointerEvent = (ev) => m.onLightboxPointerEvent.call(ctx, ev);
|
|
return ctx;
|
|
};
|
|
const makeEv = (target = document.createElement("div")) => ({
|
|
type: "pointerDown",
|
|
originalEvent: { target },
|
|
preventDefault: vi.fn(),
|
|
});
|
|
|
|
it("activeSlideIsSphere reflects the current slide content flag", () => {
|
|
const m = methods();
|
|
expect(m.activeSlideIsSphere.call({ pswp: () => ({ currSlide: { content: { data: { isSphere: true } } } }) })).toBe(true);
|
|
expect(m.activeSlideIsSphere.call({ pswp: () => ({ currSlide: { content: { data: {} } } }) })).toBe(false);
|
|
expect(m.activeSlideIsSphere.call({ pswp: () => null })).toBe(false);
|
|
});
|
|
it("prevents PhotoSwipe navigation for a pointer on a 360° sphere slide", () => {
|
|
const ev = makeEv();
|
|
makeCtx(true).onLightboxPointerEvent(ev);
|
|
expect(ev.preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
it("does NOT prevent navigation on a regular slide", () => {
|
|
const ev = makeEv();
|
|
makeCtx(false).onLightboxPointerEvent(ev);
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
it("does NOT block a press on a UI control so buttons and arrows still navigate", () => {
|
|
const arrow = document.createElement("button");
|
|
arrow.className = "pswp__button pswp__button--arrow";
|
|
const ev = makeEv(arrow);
|
|
makeCtx(true).onLightboxPointerEvent(ev);
|
|
expect(ev.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|