// @vitest-environment happy-dom import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SaveStatus } from "./save-status"; import { SAVE_LAYOUT_KEY, clampSaveLayout, parseSaveLayout } from "./floating-save-panel"; let root: Root; let container: HTMLDivElement; const onSave = vi.fn(); let startingWidth = 250; let startingHeight = 200; function button(name: string) { const result = Array.from(container.querySelectorAll("button")).find((item) => (item.getAttribute("aria-label") ?? item.textContent?.trim()) === name); if (!result) throw new Error(`Missing button: ${name}`); return result; } beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.clearAllMocks(); startingWidth = 250; startingHeight = 200; window.localStorage.clear(); vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800); vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600); vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) { const isPanel = this.hasAttribute("data-save-panel"); const x = isPanel ? Number.parseFloat(this.style.left || "550") : 0; const y = isPanel ? Number.parseFloat(this.style.top || "400") : 0; const width = isPanel ? Number.parseFloat(this.style.width || String(startingWidth)) : 800; const height = isPanel ? Number.parseFloat(this.style.height || String(startingHeight)) : 600; return { x, y, left: x, top: y, right: x + width, bottom: y + height, width, height, toJSON: () => ({}) }; }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); async function render(props: Partial[0]> = {}) { await act(async () => root.render(createElement(SaveStatus, { state: "saved", canSave: false, onSave, ...props }))); } describe("floating SaveStatus", () => { it("can grow and shrink back to the measured starting size", async () => { startingWidth = 160; startingHeight = 96; await render(); const panel = container.querySelector("[data-save-panel]")!; panel.setPointerCapture = vi.fn(); panel.releasePointerCapture = vi.fn(); const resize = async (fromX: number, fromY: number, toX: number, toY: number) => { await act(async () => panel.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, button: 0, clientX: fromX, clientY: fromY }))); await act(async () => panel.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: toX, clientY: toY }))); await act(async () => panel.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }))); }; await resize(710, 496, 790, 580); expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 550, y: 400, width: 240, height: 180 }); await resize(790, 580, 550, 400); expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 550, y: 400, width: 160, height: 96 }); }); it("uses the original minimum after restoring a larger layout and resizing the top-left corner", async () => { startingWidth = 160; startingHeight = 96; window.localStorage.setItem(SAVE_LAYOUT_KEY, JSON.stringify({ version: 1, x: 40, y: 60, width: 350, height: 280 })); await render(); const panel = container.querySelector("[data-save-panel]")!; panel.setPointerCapture = vi.fn(); panel.releasePointerCapture = vi.fn(); await act(async () => panel.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, button: 0, clientX: 40, clientY: 60 }))); await act(async () => panel.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 500, clientY: 500 }))); await act(async () => panel.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }))); expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 230, y: 244, width: 160, height: 96 }); }); it("restores a saved layout", async () => { window.localStorage.setItem(SAVE_LAYOUT_KEY, JSON.stringify({ version: 1, x: 40, y: 60, width: 300, height: 220 })); await render(); const panel = container.querySelector("[data-save-panel]")!; expect(panel.style.left).toBe("40px"); expect(panel.style.height).toBe("220px"); }); it("clamps a restored layout without overwriting the preference", async () => { const stored = JSON.stringify({ version: 1, x: 900, y: 700, width: 300, height: 220 }); window.localStorage.setItem(SAVE_LAYOUT_KEY, stored); await render(); const panel = container.querySelector("[data-save-panel]")!; expect(panel.style.left).toBe("500px"); expect(panel.style.top).toBe("380px"); expect(window.localStorage.getItem(SAVE_LAYOUT_KEY)).toBe(stored); }); it("resizes with the keyboard and persists without saving editor data", async () => { await render({ state: "unsaved", canSave: true }); await act(async () => container.querySelector("[data-save-panel]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, bubbles: true }))); expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 550, y: 400, width: 250, height: 200 }); expect(onSave).not.toHaveBeenCalled(); }); it("persists pointer resizing only after releasing the handle", async () => { await render(); const handle = container.querySelector("[data-save-panel]")!; handle.setPointerCapture = vi.fn(); handle.releasePointerCapture = vi.fn(); await act(async () => handle.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, button: 0, clientX: 800, clientY: 600 }))); await act(async () => handle.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 750, clientY: 580 }))); expect(container.querySelector("[data-save-panel]")!.style.width).toBe("250px"); expect(window.localStorage.getItem(SAVE_LAYOUT_KEY)).toBeNull(); await act(async () => handle.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }))); expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 550, y: 400, width: 250, height: 200 }); }); it.each([ [550, 500, 570, 500, { x: 550, y: 400, width: 250, height: 200 }], [800, 500, 780, 500, { x: 550, y: 400, width: 250, height: 200 }], [650, 400, 650, 420, { x: 550, y: 400, width: 250, height: 200 }], [650, 600, 650, 580, { x: 550, y: 400, width: 250, height: 200 }], ])("resizes from the edge at %i, %i", async (x, y, endX, endY, expected) => { await render(); const panel = container.querySelector("[data-save-panel]")!; panel.setPointerCapture = vi.fn(); panel.releasePointerCapture = vi.fn(); await act(async () => panel.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, button: 0, clientX: x, clientY: y }))); await act(async () => panel.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: endX, clientY: endY }))); await act(async () => panel.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }))); expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual(expected); }); it("moves by dragging the status content without a move icon", async () => { await render(); const status = container.querySelector('[role="status"]')!; await act(async () => status.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, isPrimary: true, pointerId: 1, button: 0, clientX: 650, clientY: 450 }))); await act(async () => document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 630, clientY: 430 }))); await act(async () => document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 630, clientY: 430 }))); await act(async () => document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }))); expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 530, y: 380, width: 250, height: 200 }); expect(container.querySelectorAll("button")).toHaveLength(1); expect(onSave).not.toHaveBeenCalled(); // dnd-kit briefly retains its click blocker after a pointer drag ends. await act(async () => { await new Promise((resolve) => setTimeout(resolve, 60)); }); }); it("keeps a normal Save click working without moving the panel", async () => { await render({ state: "unsaved", canSave: true }); await act(async () => button("บันทึก").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, isPrimary: true, pointerId: 1, button: 0, clientX: 650, clientY: 500 }))); await act(async () => button("บันทึก").dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, button: 0, clientX: 650, clientY: 500 }))); await act(async () => button("บันทึก").click()); expect(onSave).toHaveBeenCalledTimes(1); expect(window.localStorage.getItem(SAVE_LAYOUT_KEY)).toBeNull(); }); it("moves with the keyboard and persists the position", async () => { await render(); const handle = container.querySelector("[data-save-panel]")!; await act(async () => { handle.focus(); handle.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: " ", code: "Space" })); await new Promise((resolve) => setTimeout(resolve, 0)); }); await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowLeft", code: "ArrowLeft" }))); await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: " ", code: "Space" }))); expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 525, y: 400, width: 250, height: 200 }); expect(onSave).not.toHaveBeenCalled(); }); it("continues to resize when storage is unavailable", async () => { vi.spyOn(window.localStorage, "getItem").mockImplementation(() => { throw new Error("blocked"); }); vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { throw new Error("blocked"); }); await render(); await act(async () => container.querySelector("[data-save-panel]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, bubbles: true }))); expect(container.querySelector("[data-save-panel]")!.style.width).toBe("250px"); }); it("rejects malformed storage and keeps minimum sizes within tiny viewports", () => { expect(parseSaveLayout("not json")).toBeNull(); expect(parseSaveLayout(JSON.stringify({ version: 1, x: "40", y: 0, width: 200, height: 144 }))).toBeNull(); expect(clampSaveLayout({ x: -10, y: 500, width: 50, height: 50 }, 180, 120, { width: 250, height: 200 })).toEqual({ x: 0, y: 0, width: 180, height: 120 }); }); it("keeps Save visible when saved and enables it for unsaved changes", async () => { await render(); expect(button("บันทึก").textContent).toContain("บันทึก"); expect(button("บันทึก").disabled).toBe(true); expect(container.querySelector(".fixed")).not.toBeNull(); await render({ state: "unsaved", canSave: true }); await act(async () => button("บันทึก").click()); expect(onSave).toHaveBeenCalledTimes(1); }); it("shows saving feedback and blocks more save requests", async () => { await render({ state: "saving", canSave: false }); expect(button("กำลังบันทึก…").textContent).toContain("กำลังบันทึก"); await act(async () => button("กำลังบันทึก…").click()); expect(onSave).not.toHaveBeenCalled(); }); it("shows validation issues while keeping Save disabled", async () => { await render({ state: "incomplete", issues: ["Missing title"] }); expect(button("บันทึก").disabled).toBe(true); await act(async () => button("ตรวจสอบข้อมูลที่ยังไม่ครบ").click()); expect(container.textContent).toContain("Missing title"); expect(button("ซ่อนรายการ").getAttribute("aria-expanded")).toBe("true"); }); it("keeps conflict actions available and blocks ordinary Save", async () => { const onReloadLatest = vi.fn(); const onOverwrite = vi.fn(); await render({ state: "conflict", conflictVersion: 8, canOverwrite: true, onReloadLatest, onOverwrite }); await act(async () => { button("โหลดข้อมูลล่าสุด").click(); button("เขียนทับด้วย Draft นี้").click(); }); expect(onReloadLatest).toHaveBeenCalledTimes(1); expect(onOverwrite).toHaveBeenCalledTimes(1); expect(button("บันทึก").disabled).toBe(true); }); });