Files
buzz-sheet/components/admin/save-status.test.ts
T

222 lines
13 KiB
TypeScript

// @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<Parameters<typeof SaveStatus>[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[data-save-panel]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, bubbles: true })));
expect(container.querySelector<HTMLElement>("[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);
});
});