// @vitest-environment happy-dom import { act, useEffect, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ExtraSheetEditor } from "./extra-sheet-editor"; import { createExtraSheet, mergeExtraSheetRange, type ExtraSheet } from "@/lib/guides/extra-sheet"; let root: Root; let container: HTMLDivElement; let latest: ExtraSheet; let sequence = 0; const createId = (prefix: string) => `${prefix}-${++sequence}`; function Harness({ initial }: { initial: ExtraSheet }) { const [sheet, setSheet] = useState(initial); useEffect(() => { latest = sheet; }, [sheet]); return {}} />; } async function mouseDown(element: Element, shiftKey = false) { await act(async () => { element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, shiftKey })); }); } async function click(element: Element) { await mouseDown(element); await act(async () => { element.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); } function cell(row: number, column: number) { return container.querySelector(`[data-sheet-row="${row}"][data-sheet-column="${column}"]`)!; } function button(label: string) { return Array.from(container.querySelectorAll("button")).find((item) => item.getAttribute("aria-label") === label || item.textContent?.trim() === label)!; } async function typeText(element: HTMLTextAreaElement, text: string) { await act(async () => { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(element, text); element.dispatchEvent(new Event("input", { bubbles: true })); }); } async function mount(initial = createExtraSheet(createId)) { await act(async () => { root.render(); }); } beforeEach(() => { sequence = 0; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); // Supply grid measurements; happy-dom does not run browser layout. vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (this: HTMLElement) { return this.tagName === "TD" ? 140 : 1000; }); vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (this: HTMLElement) { return this.tagName === "TD" ? 36 : 180; }); vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(function (this: HTMLElement) { return (Number(this.dataset.sheetRow ?? -1) + 1) * 36; }); vi.spyOn(HTMLElement.prototype, "offsetLeft", "get").mockImplementation(function (this: HTMLElement) { return (Number(this.dataset.sheetColumn ?? -1) + 1) * 140; }); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => { root.unmount(); }); container.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("extra spreadsheet interactions", () => { it("saves inline text when another cell is clicked without Enter", async () => { await mount(); await mouseDown(cell(0, 0)); await click(container.querySelector(".Spreadsheet__active-cell--view")!); const input = container.querySelector(".Spreadsheet__data-editor textarea")!; expect(input).not.toBeNull(); await typeText(input, "Keep this text"); await mouseDown(cell(1, 1)); expect(latest.rows[0].cells[latest.columns[0].id]).toBe("

Keep this text

"); expect(latest.rows[1].cells[latest.columns[1].id]).toBe(""); }); it("saves inline text on blur and cancels Escape", async () => { await mount(); await mouseDown(cell(0, 0)); await click(container.querySelector(".Spreadsheet__active-cell--view")!); let input = container.querySelector(".Spreadsheet__data-editor textarea")!; await typeText(input, "Saved on blur"); await act(async () => { input.blur(); }); expect(latest.rows[0].cells[latest.columns[0].id]).toBe("

Saved on blur

"); await mouseDown(cell(0, 1)); await click(container.querySelector(".Spreadsheet__active-cell--view")!); input = container.querySelector(".Spreadsheet__data-editor textarea")!; await typeText(input, "Discard"); await act(async () => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); }); expect(latest.rows[0].cells[latest.columns[1].id]).toBe(""); }); it.each(["เลือกสีพื้นหลังเซลล์", "เลือกสีตัวอักษร"])("keeps %s on the selection where its picker opened", async (label) => { const sheet = createExtraSheet(createId); sheet.rows[0].cells[sheet.columns[0].id] = "

First

"; sheet.rows[1].cells[sheet.columns[1].id] = "

Second

"; await mount(sheet); await mouseDown(cell(0, 0)); const picker = container.querySelector(`input[aria-label="${label}"]`)!; await act(async () => { picker.dispatchEvent(new Event("pointerdown", { bubbles: true })); }); await mouseDown(cell(1, 1)); await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(picker, "#123456"); picker.dispatchEvent(new Event("change", { bubbles: true })); }); if (label === "เลือกสีพื้นหลังเซลล์") { expect(latest.rows[0].backgrounds[latest.columns[0].id]).toBe("#123456"); expect(latest.rows[1].backgrounds).toEqual({}); } else { expect(latest.rows[0].cells[latest.columns[0].id]).toContain("#123456"); expect(latest.rows[1].cells[latest.columns[1].id]).toBe("

Second

"); } }); it("merges a selection, supports undo/redo, and unmerges", async () => { const sheet = createExtraSheet(createId); sheet.rows[0].cells[sheet.columns[0].id] = "

First

"; sheet.rows[1].cells[sheet.columns[1].id] = "

Last

"; await mount(sheet); await mouseDown(cell(0, 0)); await mouseDown(cell(1, 1), true); expect(button("ผสานเซลล์").disabled).toBe(false); await click(button("ผสานเซลล์")); expect(latest.merges).toHaveLength(1); expect(latest.rows[0].cells[latest.columns[0].id]).toBe("

First

Last

"); expect(cell(0, 0).getAttribute("aria-rowspan")).toBe("2"); expect(cell(1, 1).getAttribute("aria-hidden")).toBe("true"); await click(button("ย้อนกลับการแก้ไขตาราง")); expect(latest.merges).toBeUndefined(); expect(latest.rows[1].cells[latest.columns[1].id]).toBe("

Last

"); await click(button("ทำซ้ำการแก้ไขตาราง")); expect(latest.merges).toHaveLength(1); await click(button("ยกเลิกผสาน")); expect(latest.merges).toEqual([]); }); it.each(["Enter", "Tab"])("commits merged inline edits and skips coverage with %s", async (key) => { const merged = mergeExtraSheetRange(createExtraSheet(createId), { start: { row: 0, column: 0 }, end: { row: 1, column: 1 } }); await mount(merged); await mouseDown(cell(0, 0)); await click(container.querySelector(".Spreadsheet__active-cell--view")!); const input = container.querySelector(".Spreadsheet__data-editor textarea")!; await typeText(input, "Committed with key"); await act(async () => { input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); await new Promise((resolve) => requestAnimationFrame(resolve)); }); expect(latest.rows[0].cells[latest.columns[0].id]).toBe("

Committed with key

"); const expected = key === "Enter" ? cell(2, 0) : cell(0, 2); const overlay = container.querySelector(".Spreadsheet__active-cell--view")!; expect(overlay.style.top).toBe(`${expected.offsetTop}px`); expect(overlay.style.left).toBe(`${expected.offsetLeft}px`); expect(document.activeElement).toBe(overlay); }); it("activates merge anchors, skips covered cells, and persists middle alignment", async () => { const merged = mergeExtraSheetRange(createExtraSheet(createId), { start: { row: 0, column: 0 }, end: { row: 1, column: 1 } }); await mount(merged); await mouseDown(cell(1, 1)); await click(button("กึ่งกลางแนวตั้ง")); expect(latest.rows[0].verticalAlignments[latest.columns[0].id]).toBe("middle"); expect(cell(0, 0).querySelector(".extra-sheet-merged-cell")?.style.justifyContent).toBe("center"); await act(async () => { cell(0, 0).dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); await new Promise((resolve) => requestAnimationFrame(resolve)); }); expect(container.querySelector(".Spreadsheet__active-cell")?.getAttribute("style")).toContain("left: 420px"); }); });