Files
PubScript/apps/web/src/features/ai/ai-accept.test.tsx
T

199 lines
7.0 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { act } from "react";
import { MemoryRouter } from "react-router-dom";
import type { Chapter, ProjectMeta, StorageAdapter } from "@pubscript/core";
import { useProjectStore } from "../../store/project-store.js";
import { WritingView } from "../../components/WritingView.js";
import { AISidebar } from "./AISidebar.js";
describe("AISidebar + WritingView granular text-pegged suggestion flow", () => {
beforeEach(() => {
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Element.prototype.scrollIntoView = () => {};
Range.prototype.getClientRects = () =>
({
length: 0,
item: () => null,
[Symbol.iterator]: function* () {},
}) as unknown as DOMRectList;
Range.prototype.getBoundingClientRect = () =>
({ top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
const ch: Chapter = {
id: "ch-1",
bookId: "book-1",
projectId: "proj-1",
title: "Chapter One",
order: 0,
rev: 1,
meta: { wordCount: 25, scenes: [], updatedAt: new Date().toISOString() },
doc: {
type: "doc",
content: [
// Blank line at the top to ensure blank lines are never targeted or misaligned
{
type: "paragraph",
content: [],
},
{
type: "paragraph",
content: [
{
type: "text",
text: "The ferry that carried Mara Vell to the Glass Harbor left the mainland before dawn, its iron hull shuddering against the black chop. By the time it came to rest against the pilings, the light had turned the sea the color of a bruised plum.",
},
],
},
{
type: "paragraph",
content: [
{
type: "text",
text: "She pressed her palm against the cold window and watched the lighthouse appear out of the mist.",
},
],
},
],
},
};
const mockAdapter = {
async saveDocument() {},
} as unknown as StorageAdapter;
useProjectStore.setState({
adapter: mockAdapter,
project: { id: "proj-1", title: "Test Project" } as ProjectMeta,
chapters: [ch],
activeChapterId: "ch-1",
highlightedLine: null,
highlightedText: null,
highlightSeq: 0,
docExternalRev: 0,
revisionsByChapter: {
"ch-1": [
{
id: "rev-initial",
chapterId: "ch-1",
chapterTitle: "Chapter One",
rev: 1,
wordCount: 42,
savedAt: new Date().toISOString(),
label: "Baseline",
doc: JSON.parse(JSON.stringify(ch.doc)),
},
],
},
status: "ready",
});
});
it("applies multiple granular suggestions sequentially without deleting surrounding sentences, skips blank lines, and supports revision restore", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryRouter>
<div style={{ display: "flex" }}>
<WritingView />
<AISidebar />
</div>
</MemoryRouter>,
);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 80));
});
const genBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Suggest Line Edits for Chapter"),
);
expect(genBtn).toBeDefined();
await act(async () => {
genBtn!.click();
});
await act(async () => {
await new Promise((r) => setTimeout(r, 1600));
});
// Ensure the blank 1st paragraph was skipped and the 2nd DOM child (index 2) is highlighted
const edAttr = container.querySelector(".ps-editor")?.getAttribute("data-highlight-line");
expect(edAttr).toBe("2");
// Accept Suggestion #1 (clause 1 of paragraph 2)
const acceptBtn1 = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Accept"),
);
expect(acceptBtn1).toBeDefined();
await act(async () => {
acceptBtn1!.click();
await new Promise((r) => setTimeout(r, 120));
});
// After accepting Suggestion #1, the next suggestion (#2) must be automatically highlighted
expect(useProjectStore.getState().highlightedText).toContain(
"the light had turned the sea the color of a bruised plum",
);
// Accept Suggestion #2 (clause 2 of the SAME paragraph 2)
const acceptBtn2 = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Accept"),
);
expect(acceptBtn2).toBeDefined();
await act(async () => {
acceptBtn2!.click();
await new Promise((r) => setTimeout(r, 120));
});
// After accepting Suggestion #2, Suggestion #3 (on the next paragraph) must be automatically highlighted
expect(useProjectStore.getState().highlightedText).toContain(
"She pressed her palm against the cold window",
);
expect(container.querySelector(".ps-editor")?.getAttribute("data-highlight-line")).toBe("3");
// Clicking "Copy to Note" on Suggestion #3 removes it from the inline suggestions list
const beforeCards = container.querySelectorAll(".ps-ai-suggestion-card").length;
const copyToNoteBtn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Copy to Note"),
);
expect(copyToNoteBtn).toBeDefined();
await act(async () => {
copyToNoteBtn!.click();
await new Promise((r) => setTimeout(r, 120));
});
const afterCards = container.querySelectorAll(".ps-ai-suggestion-card").length;
expect(afterCards).toBe(beforeCards - 1);
// Verify BOTH granular clauses in paragraph 2 were updated AND the rest of the paragraph was NOT deleted!
const pmText = container.querySelector(".ProseMirror")?.textContent ?? "";
expect(pmText).toContain("The ferry that carried Mara Vell to the Glass Harbor cleared the mainland before dawn");
expect(pmText).toContain("By the time it came to rest against the pilings, first light had stained the swell the dark violet of a bruised plum.");
// Verify revisions were recorded and restoring rev #1 brings back original text
const revs = useProjectStore.getState().revisionsByChapter["ch-1"] ?? [];
expect(revs.length).toBeGreaterThanOrEqual(3);
await act(async () => {
await useProjectStore.getState().restoreRevision("ch-1", "rev-initial");
});
const restoredText = container.querySelector(".ProseMirror")?.textContent ?? "";
expect(restoredText).toContain("left the mainland before dawn");
await act(async () => {
root.unmount();
});
container.remove();
});
});