import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import { scrollLocks } from "/js/scrollLocks.js";
import "/js/components/lightbox-image-group.js";
describe("lightbox-image-group", () => {
function createTestImage(src, alt) {
const img = document.createElement("img");
img.src = src;
img.alt = alt;
return img;
}
class MockImageLoader {
constructor() {
this.loadedSet = new Set();
this.failedSet = new Set();
this.loadCalls = [];
this.abortCalls = 0;
}
isLoaded(src) {
return this.loadedSet.has(src);
}
hasFailed(src) {
return this.failedSet.has(src);
}
load(src) {
this.loadCalls.push(src);
return new Promise(() => {}); // never resolves; tests drive state directly
}
abort() {
this.abortCalls += 1;
}
}
beforeEach(() => {
document.body.innerHTML = "";
});
describe("LightboxDialog - rendering", () => {
it("should render empty when not open", () => {
const element = document.createElement("lightbox-dialog");
document.body.appendChild(element);
assert.deepEqual(element.innerHTML, "");
});
it("should render lightbox when open", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
const lightbox = element.querySelector(".lightbox");
assert(lightbox !== null);
assert.deepEqual(lightbox.tagName, "DIALOG");
});
it("should set data-dialog-wrapper on the host element", () => {
const element = document.createElement("lightbox-dialog");
document.body.appendChild(element);
assert(element.hasAttribute("data-dialog-wrapper"));
});
it("should render close button", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
const closeBtn = element.querySelector(".lightbox-close");
assert(closeBtn !== null);
});
it("should render image with correct src", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test image")];
document.body.appendChild(element);
element.open();
const img = element.querySelector("img");
assert(img !== null);
assert(img.src.includes("test.jpg"));
});
it("should show thumb as placeholder while fullsize loads, then swap", () => {
const image = createTestImage("thumbnail.jpg", "Test image");
image.dataset.lightboxSrc = "fullsize.jpg";
const element = document.createElement("lightbox-dialog");
element._imageLoader = new MockImageLoader();
element.images = [image];
document.body.appendChild(element);
element.open();
const initialImg = element.querySelector("img");
assert(initialImg.src.includes("thumbnail.jpg"));
assert.deepEqual(element._imageLoader.loadCalls, ["fullsize.jpg"]);
element._imageLoader.loadedSet.add("fullsize.jpg");
element.render();
const swappedImg = element.querySelector("img");
assert(swappedImg.src.includes("fullsize.jpg"));
});
it("should use src directly when fullsize equals thumb (no preload needed)", () => {
const image = createTestImage("http://localhost/same.jpg", "Test image");
image.dataset.lightboxSrc = "http://localhost/same.jpg";
const element = document.createElement("lightbox-dialog");
element._imageLoader = new MockImageLoader();
element.images = [image];
document.body.appendChild(element);
element.open();
const img = element.querySelector("img");
assert(img.src.includes("same.jpg"));
assert.deepEqual(element._imageLoader.loadCalls, []);
});
it("aborts in-flight image loads on close", () => {
const image = createTestImage("thumbnail.jpg", "Test image");
image.dataset.lightboxSrc = "fullsize.jpg";
const element = document.createElement("lightbox-dialog");
element._imageLoader = new MockImageLoader();
element.images = [image];
document.body.appendChild(element);
element.open();
assert.deepEqual(element._imageLoader.loadCalls, ["fullsize.jpg"]);
element.close();
assert.deepEqual(element._imageLoader.abortCalls, 1);
});
it("does not retry a fullsize that previously failed", () => {
const image = createTestImage("thumbnail.jpg", "Test image");
image.dataset.lightboxSrc = "fullsize.jpg";
const element = document.createElement("lightbox-dialog");
element._imageLoader = new MockImageLoader();
element._imageLoader.failedSet.add("fullsize.jpg");
element.images = [image];
document.body.appendChild(element);
element.open();
const img = element.querySelector("img");
assert(img.src.includes("thumbnail.jpg"));
assert.deepEqual(element._imageLoader.loadCalls, []);
});
it("should fall back to src when data-lightbox-src is not set", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("thumbnail.jpg", "Test image")];
document.body.appendChild(element);
element.open();
const img = element.querySelector("img");
assert(img.src.includes("thumbnail.jpg"));
});
it("should render alt text when available", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test alt text")];
document.body.appendChild(element);
element.open();
const altText = element.querySelector(".lightbox-alt-text");
assert(altText !== null);
assert.deepEqual(altText.textContent, "Test alt text");
});
it("should hide alt text when hide-alt-text attribute is set", () => {
const element = document.createElement("lightbox-dialog");
element.setAttribute("hide-alt-text", "true");
element.images = [createTestImage("test.jpg", "Test alt text")];
document.body.appendChild(element);
element.open();
const altText = element.querySelector(".lightbox-alt-text");
assert.deepEqual(altText, null);
});
it("should apply circle class when image-shape is circle", () => {
const element = document.createElement("lightbox-dialog");
element.setAttribute("image-shape", "circle");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
const img = element.querySelector("img");
assert(img.classList.contains("lightbox-image-circle"));
});
it("should not apply circle class when image-shape is not set", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
const img = element.querySelector("img");
assert(!img.classList.contains("lightbox-image-circle"));
});
});
describe("LightboxDialog - navigation", () => {
it("should not show nav buttons with single image", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
const navButtons = element.querySelectorAll(".lightbox-nav");
assert.deepEqual(navButtons.length, 0);
});
it("should show nav buttons with multiple images", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
document.body.appendChild(element);
element.open();
const navButtons = element.querySelectorAll(".lightbox-nav");
assert.deepEqual(navButtons.length, 2);
});
it("should disable prev button on first image", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
element.currentIndex = 0;
document.body.appendChild(element);
element.open();
const prevButton = element.querySelector(".lightbox-nav-prev");
assert(prevButton.disabled);
});
it("should disable next button on last image", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
element.currentIndex = 1;
document.body.appendChild(element);
element.open();
const nextButton = element.querySelector(".lightbox-nav-next");
assert(nextButton.disabled);
});
it("should navigate to next image", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
element.currentIndex = 0;
document.body.appendChild(element);
element.open();
element.navigate(1);
assert.deepEqual(element.currentIndex, 1);
});
it("should navigate to previous image", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
element.currentIndex = 1;
document.body.appendChild(element);
element.open();
element.navigate(-1);
assert.deepEqual(element.currentIndex, 0);
});
it("should not navigate past first image", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
element.currentIndex = 0;
document.body.appendChild(element);
element.open();
element.navigate(-1);
assert.deepEqual(element.currentIndex, 0);
});
it("should not navigate past last image", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
element.currentIndex = 1;
document.body.appendChild(element);
element.open();
element.navigate(1);
assert.deepEqual(element.currentIndex, 1);
});
});
describe("LightboxDialog - open/close", () => {
it("should set isOpen to true when open() is called", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
assert.deepEqual(element.isOpen, true);
});
it("should set isOpen to false when close() is called", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
element.close();
assert.deepEqual(element.isOpen, false);
});
it("should dispatch close event when close() is called", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
let eventFired = false;
element.addEventListener("close", () => {
eventFired = true;
});
element.close();
assert(eventFired);
});
describe("scroll locking", () => {
let releaseCalls;
beforeEach(() => {
releaseCalls = 0;
mock.method(scrollLocks, "acquire", () => ({
release: () => {
releaseCalls += 1;
},
}));
});
afterEach(() => {
mock.restoreAll();
});
function openLightbox() {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
return element;
}
it("should acquire a scroll lock when opened", () => {
openLightbox();
assert.deepEqual(scrollLocks.acquire.mock.callCount(), 1);
});
it("should release the scroll lock when closed", () => {
const element = openLightbox();
element.close();
assert.deepEqual(releaseCalls, 1);
assert.deepEqual(element.scrollLock, null);
});
it("should release the scroll lock when disconnected while open", () => {
const element = openLightbox();
element.remove();
assert.deepEqual(releaseCalls, 1);
});
});
});
describe("LightboxDialog - zoom", () => {
it("should enable pinch zoom when opened", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
assert(element._zoomControl !== null);
assert.deepEqual(typeof element._zoomControl.cleanup, "function");
assert.deepEqual(typeof element._zoomControl.reset, "function");
assert.deepEqual(typeof element._zoomControl.getState, "function");
});
it("should reset zoom when navigating to a different image", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
document.body.appendChild(element);
element.open();
let resetCalled = false;
const originalReset = element._zoomControl.reset;
element._zoomControl.reset = (...args) => {
resetCalled = true;
return originalReset(...args);
};
element.navigate(1);
assert(resetCalled);
});
it("should clean up pinch zoom when closed", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
let cleanupCalled = false;
const originalCleanup = element._zoomControl.cleanup;
element._zoomControl.cleanup = (...args) => {
cleanupCalled = true;
return originalCleanup(...args);
};
element.close();
assert(cleanupCalled);
assert.deepEqual(element._zoomControl, null);
});
it("should not throw on a stray event fired at the image after close", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
const img = element.querySelector("img");
element.close();
assert.doesNotThrow(() => {
img.dispatchEvent(
new Event("wheel", { bubbles: true, cancelable: true }),
);
});
});
});
describe("LightboxDialog - keyboard navigation", () => {
it("should close on Escape key (native dialog cancel event)", () => {
const element = document.createElement("lightbox-dialog");
element.images = [createTestImage("test.jpg", "Test")];
document.body.appendChild(element);
element.open();
const dialog = element.querySelector(".lightbox");
dialog.dispatchEvent(new Event("cancel", { cancelable: true }));
assert.deepEqual(element.isOpen, false);
});
it("should navigate left on ArrowLeft key", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
element.currentIndex = 1;
document.body.appendChild(element);
element.open();
element.handleKeyDown({ key: "ArrowLeft" });
assert.deepEqual(element.currentIndex, 0);
});
it("should navigate right on ArrowRight key", () => {
const element = document.createElement("lightbox-dialog");
element.images = [
createTestImage("test1.jpg", "Test 1"),
createTestImage("test2.jpg", "Test 2"),
];
element.currentIndex = 0;
document.body.appendChild(element);
element.open();
element.handleKeyDown({ key: "ArrowRight" });
assert.deepEqual(element.currentIndex, 1);
});
});
describe("LightboxImageGroup - rendering", () => {
it("should preserve children", () => {
const element = document.createElement("lightbox-image-group");
element.innerHTML = '
';
document.body.appendChild(element);
const img = element.querySelector("img");
assert(img !== null);
});
});
describe("LightboxImageGroup - click to open lightbox", () => {
it("should create lightbox dialog when image is clicked", () => {
const element = document.createElement("lightbox-image-group");
element.innerHTML = '
';
document.body.appendChild(element);
const img = element.querySelector("img");
img.click();
const lightboxDialog = document.querySelector("lightbox-dialog");
assert(lightboxDialog !== null);
// Clean up
lightboxDialog.close();
lightboxDialog.remove();
});
it("should pass hide-alt-text attribute to lightbox dialog", () => {
const element = document.createElement("lightbox-image-group");
element.setAttribute("hide-alt-text", "true");
element.innerHTML = '
';
document.body.appendChild(element);
const img = element.querySelector("img");
img.click();
const lightboxDialog = document.querySelector("lightbox-dialog");
// The hideAltText is set as an attribute before appending to DOM
assert(lightboxDialog.getAttribute("hide-alt-text") !== null);
// Clean up
lightboxDialog.close();
lightboxDialog.remove();
});
it("should pass image-shape attribute to lightbox dialog", () => {
const element = document.createElement("lightbox-image-group");
element.setAttribute("image-shape", "circle");
element.innerHTML = '
';
document.body.appendChild(element);
const img = element.querySelector("img");
img.click();
const lightboxDialog = document.querySelector("lightbox-dialog");
assert.deepEqual(lightboxDialog.getAttribute("image-shape"), "circle");
lightboxDialog.close();
lightboxDialog.remove();
});
it("should not set image-shape attribute on dialog when unset on group", () => {
const element = document.createElement("lightbox-image-group");
element.innerHTML = '
';
document.body.appendChild(element);
const img = element.querySelector("img");
img.click();
const lightboxDialog = document.querySelector("lightbox-dialog");
assert.deepEqual(lightboxDialog.getAttribute("image-shape"), null);
lightboxDialog.close();
lightboxDialog.remove();
});
});
describe("LightboxImageGroup - reinitialization protection", () => {
it("should not reinitialize when connectedCallback is called multiple times", () => {
const element = document.createElement("lightbox-image-group");
element.innerHTML = '
';
document.body.appendChild(element);
element.connectedCallback();
const images = element.querySelectorAll("img");
assert.deepEqual(images.length, 1);
});
});
});