import { afterEach, describe, expect, it, vi } from "vitest"; import { applyVariationImage, findPrimaryImage, initGalleryImageSync, isWooManagedImage, restoreVariationImage, type WooVariationImage, } from "./galleryImageSync"; afterEach(() => { document.body.innerHTML = ""; delete window.jQuery; }); type DelegatedHandler = ( this: HTMLElement, event: unknown, payload?: unknown, ) => void; /** * Faithful port of WooCommerce's wc_set_variation_attr / wc_reset_variation_attr * (add-to-cart-variation.js): the pre-swap value is stored in data-o_ * once, and reset copies it back. */ const wcSetAttr = (el: Element, attr: string, value: string | undefined) => { if (!el.hasAttribute(`data-o_${attr}`)) { el.setAttribute(`data-o_${attr}`, el.getAttribute(attr) || ""); } if (value !== undefined) el.setAttribute(attr, value); }; const wcResetAttr = (el: Element, attr: string) => { if (el.hasAttribute(`data-o_${attr}`)) { el.setAttribute(attr, el.getAttribute(`data-o_${attr}`) || ""); } }; const CORE_IMG_ATTRS = [ "src", "srcset", "sizes", "title", "alt", "data-src", "data-large_image", ] as const; /** Core's form-bound found_variation handler (wc_variations_image_update). */ const coreImageUpdate = ( img: HTMLImageElement, link: HTMLAnchorElement | null, image: WooVariationImage | undefined, ) => { if (!image || !image.src) { coreImageReset(img, link); return; } wcSetAttr(img, "src", image.src); wcSetAttr(img, "srcset", image.srcset); wcSetAttr(img, "sizes", image.sizes); wcSetAttr(img, "title", image.title); wcSetAttr(img, "alt", image.alt); wcSetAttr(img, "data-src", image.full_src); wcSetAttr(img, "data-large_image", image.full_src); if (link) wcSetAttr(link, "href", image.full_src); }; /** Core's form-bound reset_data handler (wc_variations_image_reset). */ const coreImageReset = ( img: HTMLImageElement, link: HTMLAnchorElement | null, ) => { CORE_IMG_ATTRS.forEach((attr) => wcResetAttr(img, attr)); if (link) wcResetAttr(link, "href"); }; /** * Bind the helper against a stub jQuery and return event drivers that mirror * real event order: handlers bound directly on the form (core) run before * delegated body handlers (Swatches) for the same event. */ async function bindWithCore( form: HTMLElement, img: HTMLImageElement, link: HTMLAnchorElement | null, { coreSwaps }: { coreSwaps: boolean }, ) { const handlers = new Map(); const on = vi.fn( (event: string, _selector: string, handler: DelegatedHandler) => { handlers.set(event, handler); }, ); window.jQuery = vi.fn(() => ({ on })) as unknown as JQueryStatic; // initGalleryImageSync binds once per module instance; use a fresh instance // so each test gets its own listeners and snapshot store. vi.resetModules(); const fresh = await import("./galleryImageSync"); fresh.initGalleryImageSync(); expect(handlers.has("found_variation")).toBe(true); expect(handlers.has("reset_data")).toBe(true); return { select(variation: { image?: WooVariationImage } | undefined) { if (coreSwaps) coreImageUpdate(img, link, variation?.image); handlers .get("found_variation") ?.call(form, { type: "found_variation" }, variation); }, reset() { if (coreSwaps) coreImageReset(img, link); handlers.get("reset_data")?.call(form, { type: "reset_data" }); }, }; } const BLUE: WooVariationImage = { src: "/product-blue-2.png", srcset: "/product-blue-2.png 1x", sizes: "300px", full_src: "/product-blue-2-full.png", alt: "Blue", title: "Blue", }; const GREEN: WooVariationImage = { src: "/product-green-3.png", full_src: "/product-green-3-full.png", alt: "Green", }; /** Simulate a lazy loader re-hydrating srcset from its data-srcset. */ const lazyRehydrate = (img: HTMLImageElement) => { const lazy = img.getAttribute("data-srcset"); if (lazy !== null) img.setAttribute("srcset", lazy); }; const expectOriginal = (img: HTMLImageElement, link: HTMLAnchorElement) => { expect(img.getAttribute("src")).toBe("/orig.jpg"); expect(img.getAttribute("srcset")).toBe("/orig.jpg 1x"); expect(img.getAttribute("sizes")).toBe("100px"); expect(img.getAttribute("alt")).toBe("orig"); expect(link.getAttribute("href")).toBe("/orig-full.jpg"); }; function makeProduct(): { form: HTMLElement; img: HTMLImageElement; link: HTMLAnchorElement; } { document.body.innerHTML = `
`; return { form: document.querySelector("form.variations_form") as HTMLElement, img: document.querySelector("img.wp-post-image") as HTMLImageElement, link: document.querySelector("a") as HTMLAnchorElement, }; } describe("findPrimaryImage", () => { it("finds the primary gallery image within the product", () => { const { form, img } = makeProduct(); expect(findPrimaryImage(form)).toBe(img); }); }); describe("applyVariationImage / restoreVariationImage", () => { it("swaps the image + link, then restores exactly", () => { const { img, link } = makeProduct(); applyVariationImage(img, { src: "/var.jpg", srcset: "/var.jpg 1x", sizes: "200px", full_src: "/var-full.jpg", alt: "variant", }); expect(img.getAttribute("src")).toBe("/var.jpg"); expect(img.getAttribute("srcset")).toBe("/var.jpg 1x"); expect(img.getAttribute("alt")).toBe("variant"); expect(link.getAttribute("href")).toBe("/var-full.jpg"); expect(img.getAttribute("data-large_image")).toBe("/var-full.jpg"); restoreVariationImage(img); expect(img.getAttribute("src")).toBe("/orig.jpg"); expect(img.getAttribute("srcset")).toBe("/orig.jpg 1x"); expect(img.getAttribute("alt")).toBe("orig"); expect(link.getAttribute("href")).toBe("/orig-full.jpg"); expect(img.hasAttribute("data-large_image")).toBe(false); }); it("is a no-op when the variation has no usable image", () => { const { img } = makeProduct(); applyVariationImage(img, undefined); applyVariationImage(img, { src: "" }); expect(img.getAttribute("src")).toBe("/orig.jpg"); }); it("preserves a descriptive product alt when the variation alt is empty", () => { const { img } = makeProduct(); applyVariationImage(img, { src: "/var.jpg", alt: "" }); expect(img.getAttribute("alt")).toBe("orig"); }); it("restore is a no-op for an image that was never swapped", () => { const { img } = makeProduct(); restoreVariationImage(img); expect(img.getAttribute("src")).toBe("/orig.jpg"); }); it("keeps per-image snapshots isolated across two products", () => { const { img: imgA } = makeProduct(); const wrap = document.createElement("div"); wrap.innerHTML = `
`; document.body.append(wrap); const imgB = wrap.querySelector("img") as HTMLImageElement; applyVariationImage(imgA, { src: "/a2.jpg" }); applyVariationImage(imgB, { src: "/b2.jpg" }); restoreVariationImage(imgA); expect(imgA.getAttribute("src")).toBe("/orig.jpg"); expect(imgB.getAttribute("src")).toBe("/b2.jpg"); }); }); describe("initGalleryImageSync", () => { it("uses WooCommerce's variation payload after the jQuery event argument", () => { const handlers = new Map< string, (this: HTMLElement, event: unknown, payload?: unknown) => void >(); const on = vi.fn( ( event: string, _selector: string, handler: (this: HTMLElement, event: unknown, payload?: unknown) => void, ) => { handlers.set(event, handler); }, ); window.jQuery = vi.fn(() => ({ on })) as unknown as JQueryStatic; const { form, img } = makeProduct(); initGalleryImageSync(); handlers.get("found_variation")?.call( form, { type: "found_variation" }, { image: { src: "/listener-variation.jpg" }, }, ); expect(img.getAttribute("src")).toBe("/listener-variation.jpg"); }); }); describe("interaction with WooCommerce core's own image swap", () => { it("does not snapshot an image core already swapped, so Clear restores the product image", async () => { const { form, img, link } = makeProduct(); const woo = await bindWithCore(form, img, link, { coreSwaps: true }); woo.select({ image: BLUE }); expect(img.getAttribute("src")).toBe("/product-blue-2.png"); expect(isWooManagedImage(img)).toBe(true); woo.reset(); expectOriginal(img, link); // Core's originals stay intact for future resets. expect(img.getAttribute("data-o_src")).toBe("/orig.jpg"); expect(link.getAttribute("data-o_href")).toBe("/orig-full.jpg"); }); it("survives repeated select / Clear cycles and switching variations", async () => { const { form, img, link } = makeProduct(); const woo = await bindWithCore(form, img, link, { coreSwaps: true }); woo.select({ image: BLUE }); woo.select({ image: GREEN }); expect(img.getAttribute("src")).toBe("/product-green-3.png"); woo.reset(); expectOriginal(img, link); woo.select({ image: GREEN }); woo.select({ image: BLUE }); woo.reset(); expectOriginal(img, link); }); it("does not leave a stale variation image when a later variation has no image", async () => { const { form, img, link } = makeProduct(); const woo = await bindWithCore(form, img, link, { coreSwaps: true }); woo.select({ image: BLUE }); woo.select({ image: undefined }); expectOriginal(img, link); woo.select({ image: BLUE }); woo.select({ image: { src: "" } }); expectOriginal(img, link); }); it("does not re-apply a pre-core snapshot once core claims the image", async () => { const { form, img, link } = makeProduct(); // Helper swapped alone once (e.g. core not yet initialised), then core // takes over for subsequent events. const alone = await bindWithCore(form, img, link, { coreSwaps: false }); alone.select({ image: BLUE }); expect(img.getAttribute("src")).toBe("/product-blue-2.png"); alone.reset(); expectOriginal(img, link); // Now core handles the same form; the helper must defer to it. coreImageUpdate(img, link, GREEN); alone.select({ image: GREEN }); coreImageReset(img, link); alone.reset(); expectOriginal(img, link); }); it("keeps a lazy-loader data-srcset in step on a core-managed image, without claiming core's attributes", async () => { const { form, img, link } = makeProduct(); img.setAttribute("data-srcset", "/orig.jpg 1x"); const woo = await bindWithCore(form, img, link, { coreSwaps: true }); woo.select({ image: BLUE }); // Core owns src/srcset/data-src; we only mirror srcset into data-srcset. expect(img.getAttribute("srcset")).toBe("/product-blue-2.png 1x"); expect(img.getAttribute("data-srcset")).toBe("/product-blue-2.png 1x"); expect(img.getAttribute("data-src")).toBe("/product-blue-2-full.png"); expect(img.hasAttribute("data-o_data-srcset")).toBe(false); // A variation without its own srcset must not leave Blue's responsive set // behind: the lazy attribute falls back to Green's src as the only // candidate, so a loader re-hydrating srcset shows Green, not Blue. woo.select({ image: GREEN }); expect(img.getAttribute("data-srcset")).toBe("/product-green-3.png"); lazyRehydrate(img); expect(img.getAttribute("srcset")).toBe("/product-green-3.png"); expect(img.getAttribute("srcset")).not.toContain("blue"); woo.select({ image: BLUE }); woo.reset(); expectOriginal(img, link); expect(img.getAttribute("data-srcset")).toBe("/orig.jpg 1x"); // Repeat cycle: original stays the genuine original. woo.select({ image: BLUE }); expect(img.getAttribute("data-srcset")).toBe("/product-blue-2.png 1x"); woo.reset(); expect(img.getAttribute("data-srcset")).toBe("/orig.jpg 1x"); }); it("does not add data-srcset to a core-managed image that never had one", async () => { const { form, img, link } = makeProduct(); const woo = await bindWithCore(form, img, link, { coreSwaps: true }); woo.select({ image: BLUE }); expect(img.hasAttribute("data-srcset")).toBe(false); woo.reset(); expect(img.hasAttribute("data-srcset")).toBe(false); expectOriginal(img, link); }); it("does not snapshot when the image already shows the variation and nobody recorded an original", () => { const { img } = makeProduct(); // A theme swapped the image without core's data-o_* markers. img.setAttribute("src", "/product-blue-2.png"); applyVariationImage(img, BLUE); restoreVariationImage(img); // Nothing to restore to: the helper must not invent an "original". expect(img.getAttribute("src")).toBe("/product-blue-2.png"); }); }); describe("fallback mode (core does not swap this gallery)", () => { it("swaps and restores itself, including lazy-load attributes and the link", async () => { const { form, img, link } = makeProduct(); img.setAttribute("data-src", "/orig.jpg"); img.setAttribute("data-srcset", "/orig.jpg 1x"); const woo = await bindWithCore(form, img, link, { coreSwaps: false }); woo.select({ image: BLUE }); expect(img.getAttribute("src")).toBe("/product-blue-2.png"); expect(img.getAttribute("data-src")).toBe("/product-blue-2.png"); expect(img.getAttribute("data-srcset")).toBe("/product-blue-2.png 1x"); expect(img.getAttribute("alt")).toBe("Blue"); expect(img.getAttribute("title")).toBe("Blue"); expect(link.getAttribute("href")).toBe("/product-blue-2-full.png"); expect(isWooManagedImage(img)).toBe(false); woo.reset(); expectOriginal(img, link); expect(img.getAttribute("data-src")).toBe("/orig.jpg"); expect(img.getAttribute("data-srcset")).toBe("/orig.jpg 1x"); expect(img.hasAttribute("title")).toBe(false); expect(img.hasAttribute("data-large_image")).toBe(false); }); it("does not keep a prior variation's srcset/sizes/alt/title when the next variation lacks them", async () => { const { form, img, link } = makeProduct(); img.setAttribute("data-srcset", "/orig.jpg 1x"); const woo = await bindWithCore(form, img, link, { coreSwaps: false }); woo.select({ image: BLUE }); woo.select({ image: GREEN }); // no srcset, sizes or title; alt "Green" expect(img.getAttribute("src")).toBe("/product-green-3.png"); expect(img.getAttribute("srcset")).toBe("/product-green-3.png"); expect(img.hasAttribute("sizes")).toBe(false); expect(img.getAttribute("alt")).toBe("Green"); // Title reverts to the product's (none here), not Blue's. expect(img.hasAttribute("title")).toBe(false); expect(img.getAttribute("data-srcset")).toBe("/product-green-3.png"); lazyRehydrate(img); expect(img.getAttribute("srcset")).toBe("/product-green-3.png"); // Empty variation alt falls back to the product alt, not Blue's alt. woo.select({ image: BLUE }); woo.select({ image: { src: "/product-grey-4.png", alt: "" } }); expect(img.getAttribute("alt")).toBe("orig"); expect(img.getAttribute("srcset")).toBe("/product-grey-4.png"); woo.reset(); expectOriginal(img, link); expect(img.getAttribute("data-srcset")).toBe("/orig.jpg 1x"); expect(img.hasAttribute("title")).toBe(false); }); it("restores the product image when switching to a variation without an image", async () => { const { form, img, link } = makeProduct(); const woo = await bindWithCore(form, img, link, { coreSwaps: false }); woo.select({ image: BLUE }); woo.select({}); expectOriginal(img, link); woo.select({ image: GREEN }); woo.select(undefined); expectOriginal(img, link); }); it("keeps the first original across repeated selections and resets", async () => { const { form, img, link } = makeProduct(); const woo = await bindWithCore(form, img, link, { coreSwaps: false }); woo.select({ image: BLUE }); woo.select({ image: GREEN }); woo.select({ image: BLUE }); woo.reset(); expectOriginal(img, link); woo.reset(); expectOriginal(img, link); }); });