8f937c3b47
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
import { act, cleanup, renderHook } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { PlanSaveError } from "@/hooks/domains/session/use-task-plan";
|
|
import { usePlanDraft } from "@/hooks/domains/session/use-plan-draft";
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: (selector: (state: unknown) => unknown) =>
|
|
selector({ tasks: { activeSessionId: null }, taskSessions: { items: {} } }),
|
|
useAppStoreApi: () => ({ getState: () => ({}) }),
|
|
}));
|
|
|
|
const AUTO_SAVE_DELAY = 1500;
|
|
const REJECTED_CONTENT = "rejected content";
|
|
const TASK_1 = "task-1";
|
|
const TASK_2 = "task-2";
|
|
// Autosave suppression only applies to a size rejection (AC-003.4's "such a
|
|
// rejection" chains back to AC-003.1's ceiling rejection); every suppression
|
|
// test in this file simulates one via this classified saveError.
|
|
const SIZE_REJECTION: PlanSaveError = {
|
|
kind: "content-too-large",
|
|
limit: 262144,
|
|
submitted: 300000,
|
|
};
|
|
const GENERIC_REJECTION: PlanSaveError = { kind: "generic", message: "temporary failure" };
|
|
|
|
type DraftPlan = { content?: string; title?: string } | null;
|
|
type DraftProps = {
|
|
plan: DraftPlan;
|
|
isSaving: boolean;
|
|
taskId: string | null;
|
|
saveError?: PlanSaveError | null;
|
|
};
|
|
|
|
function renderDraft(
|
|
plan: DraftPlan,
|
|
savePlan: (content: string, title?: string) => Promise<unknown>,
|
|
taskId: string | null = TASK_1,
|
|
saveError: PlanSaveError | null = SIZE_REJECTION,
|
|
) {
|
|
const editorWrapperRef = { current: null };
|
|
return renderHook(
|
|
({ plan: p, isSaving, taskId: id, saveError = null }: DraftProps) =>
|
|
usePlanDraft({
|
|
plan: p,
|
|
isSaving,
|
|
savePlan,
|
|
editorWrapperRef,
|
|
taskId: id,
|
|
saveError,
|
|
}),
|
|
{ initialProps: { plan, isSaving: false, taskId, saveError } as DraftProps },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Simulates the isSaving true -> false round trip a real savePlan call
|
|
* produces via the store (setTaskPlanSaving). The autosave effect depends on
|
|
* isSaving, so it only gets a chance to re-run — and the suppression guard a
|
|
* chance to matter — across exactly this transition. A test that never
|
|
* drives it can pass whether or not the guard is even present.
|
|
*/
|
|
async function settleSaveRoundTrip(
|
|
rerender: (props: DraftProps) => void,
|
|
plan: DraftPlan,
|
|
taskId: string | null,
|
|
saveError: PlanSaveError | null = null,
|
|
) {
|
|
rerender({ plan, isSaving: true, taskId, saveError: null });
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
rerender({ plan, isSaving: false, taskId, saveError });
|
|
}
|
|
|
|
describe("usePlanDraft autosave suppression (AC-003.4)", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not resubmit rejected content across an isSaving round trip", async () => {
|
|
const savePlan = vi.fn().mockResolvedValue(null); // simulates a rejected save (savePlan resolves null on failure)
|
|
const plan = { content: "" };
|
|
const { result, rerender } = renderDraft(plan, savePlan);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent("oversized content");
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
|
|
// First debounce fires the initial (rejected) attempt.
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
// The isSaving round trip a rejected save produces in the real app —
|
|
// this is the transition the autosave effect depends on, and a version
|
|
// of this hook without the suppression ref re-arms across it and
|
|
// resubmits indefinitely.
|
|
await settleSaveRoundTrip(rerender, plan, TASK_1, SIZE_REJECTION);
|
|
|
|
// Advance well past several more debounce intervals.
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY * 5);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("re-arms once the draft changes away from the rejected content", async () => {
|
|
const savePlan = vi.fn().mockResolvedValue(null);
|
|
const plan = { content: "" };
|
|
const { result, rerender } = renderDraft(plan, savePlan);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent(REJECTED_CONTENT);
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
await settleSaveRoundTrip(rerender, plan, TASK_1, SIZE_REJECTION);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent("a different, shorter draft");
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("an explicit save (attemptSave) resubmits even unchanged content", async () => {
|
|
const savePlan = vi.fn().mockResolvedValue(null);
|
|
const plan = { content: "" };
|
|
const { result, rerender } = renderDraft(plan, savePlan);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent(REJECTED_CONTENT);
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
await settleSaveRoundTrip(rerender, plan, TASK_1, SIZE_REJECTION);
|
|
|
|
// Simulate the Ctrl/Cmd+S escape hatch: useSaveShortcut calls
|
|
// attemptSave directly, bypassing the autosave debounce entirely.
|
|
await act(async () => {
|
|
await result.current.attemptSave(REJECTED_CONTENT);
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("usePlanDraft autosave suppression is scoped to size rejections", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("re-arms after a generic (non-size) failure once its isSaving round trip settles", async () => {
|
|
// Regression test: the suppression guard used to key only on "was the
|
|
// just-attempted content rejected", not on why — so a transient
|
|
// transport/server failure (classified "generic", not
|
|
// "content-too-large") permanently blocked autosave for an unchanged
|
|
// draft, with no self-healing once the backend recovered. AC-003.4's
|
|
// "such a rejection" is scoped to AC-003.1's ceiling rejection, so only
|
|
// a size rejection may suppress; a generic failure must keep retrying.
|
|
const savePlan = vi.fn().mockResolvedValue(null);
|
|
const plan = { content: "" };
|
|
const { result, rerender } = renderDraft(plan, savePlan);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent(REJECTED_CONTENT);
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1 });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
await settleSaveRoundTrip(rerender, plan, TASK_1, {
|
|
kind: "generic",
|
|
message: "network error",
|
|
});
|
|
|
|
// No draft change and no explicit save — a size rejection would stay
|
|
// suppressed here, but a generic failure must re-arm on its own.
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("usePlanDraft explicit save (Ctrl/Cmd+S) interactions (AC-003.3/003.4)", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("still reports unsaved changes after a rejected save (AC-003.3)", async () => {
|
|
const savePlan = vi.fn().mockResolvedValue(null);
|
|
const plan = { content: "" };
|
|
const { result, rerender } = renderDraft(plan, savePlan);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent(REJECTED_CONTENT);
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
expect(result.current.hasUnsavedChanges).toBe(true);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
await settleSaveRoundTrip(rerender, plan, TASK_1, SIZE_REJECTION);
|
|
|
|
// Never presented as saved: the plan is untouched (savePlan resolved
|
|
// null, so the store's plan was never updated) and the draft still
|
|
// differs from it.
|
|
expect(result.current.hasUnsavedChanges).toBe(true);
|
|
});
|
|
|
|
it("does not automatically resubmit after an explicit save is itself rejected", async () => {
|
|
// Regression test: useSaveShortcut used to clear the suppression ref
|
|
// without recording what it had just submitted, so once its own
|
|
// rejected attempt's isSaving round trip settled, the autosave effect
|
|
// saw no suppression match and re-armed on its own — resubmitting the
|
|
// same still-oversized draft with no further user action.
|
|
const savePlan = vi.fn().mockResolvedValue(null);
|
|
const plan = { content: "" };
|
|
const { result, rerender } = renderDraft(plan, savePlan);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent(REJECTED_CONTENT);
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
await settleSaveRoundTrip(rerender, plan, TASK_1, SIZE_REJECTION);
|
|
|
|
// Explicit save (Ctrl/Cmd+S) on unchanged, still-rejected content.
|
|
await act(async () => {
|
|
await result.current.attemptSave(REJECTED_CONTENT);
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
|
|
// The explicit save's own isSaving round trip settles, with no further
|
|
// draft change or user action afterward.
|
|
await settleSaveRoundTrip(rerender, plan, TASK_1, SIZE_REJECTION);
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY * 3);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("usePlanDraft autosave suppression reset (AC-003.4)", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("clears the suppression on a successful save so a later edit can autosave again", async () => {
|
|
const savePlan = vi.fn().mockResolvedValue({ content: "content that will save" });
|
|
const plan = { content: "" };
|
|
const { result, rerender } = renderDraft(plan, savePlan);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent("content that will save");
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
// The store's plan now reflects the saved content, matching the current
|
|
// draft exactly — same as the real flow, where a successful save's
|
|
// result flows back through the store before the next edit.
|
|
const updatedPlan = { content: "content that will save" };
|
|
rerender({ plan: updatedPlan, isSaving: false, taskId: TASK_1, saveError: null });
|
|
|
|
// A further, real edit re-diverges the draft from the plan.
|
|
act(() => {
|
|
result.current.setDraftContent("content that will save again");
|
|
});
|
|
rerender({ plan: updatedPlan, isSaving: false, taskId: TASK_1, saveError: null });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("resets the draft and suppression when switching between tasks without plans", async () => {
|
|
const savePlan = vi.fn().mockResolvedValue(null);
|
|
const plan = null;
|
|
const { result, rerender } = renderDraft(plan, savePlan, TASK_1);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent(REJECTED_CONTENT);
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
// Switch to a task with no plan. The previous task's rejected draft must
|
|
// not remain visible or get sent to the new task.
|
|
rerender({ plan: null, isSaving: false, taskId: TASK_2, saveError: null });
|
|
expect(result.current.draftContent).toBe("");
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY * 3);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
// A new edit on task B is the only event that may arm its autosave.
|
|
act(() => {
|
|
result.current.setDraftContent("task B content");
|
|
});
|
|
rerender({ plan: null, isSaving: false, taskId: TASK_2, saveError: null });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
expect(savePlan).toHaveBeenLastCalledWith("task B content", undefined);
|
|
});
|
|
|
|
it("retries unchanged content after a generic failure", async () => {
|
|
const savePlan = vi.fn().mockResolvedValue(null);
|
|
const plan = { content: "" };
|
|
const { result, rerender } = renderDraft(plan, savePlan, TASK_1, SIZE_REJECTION);
|
|
|
|
act(() => {
|
|
result.current.setDraftContent("temporarily unavailable");
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(1);
|
|
|
|
await settleSaveRoundTrip(rerender, plan, TASK_1, GENERIC_REJECTION);
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("usePlanDraft overlapping attemptSave calls (AC-003.4 regression)", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not let an earlier-started successful save clear suppression for a later-started rejected save", async () => {
|
|
// Regression test: attemptSave cleared the shared suppression ref
|
|
// whenever ANY attempt succeeded, without checking the ref still held
|
|
// that attempt's own content. Two overlapping attempts (an explicit save
|
|
// racing an in-flight autosave) can resolve out of start order: if the
|
|
// earlier-started one succeeds after the later-started one was already
|
|
// rejected, the earlier one's success must not erase the suppression the
|
|
// later, rejected attempt put in place.
|
|
let resolveA: (value: unknown) => void = () => {};
|
|
let resolveB: (value: unknown) => void = () => {};
|
|
const promiseA = new Promise((resolve) => {
|
|
resolveA = resolve;
|
|
});
|
|
const promiseB = new Promise((resolve) => {
|
|
resolveB = resolve;
|
|
});
|
|
const savePlan = vi.fn((content: string) => (content === "content-A" ? promiseA : promiseB));
|
|
const plan = { content: "" };
|
|
const { result, rerender } = renderDraft(plan, savePlan);
|
|
|
|
let attemptAResult: Promise<unknown> = Promise.resolve(null);
|
|
let attemptBResult: Promise<unknown> = Promise.resolve(null);
|
|
act(() => {
|
|
attemptAResult = result.current.attemptSave("content-A");
|
|
});
|
|
act(() => {
|
|
attemptBResult = result.current.attemptSave("content-B");
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
|
|
// The later-started attempt (B) resolves first, rejected.
|
|
await act(async () => {
|
|
resolveB(null);
|
|
await attemptBResult;
|
|
});
|
|
|
|
// The earlier-started attempt (A) resolves after, successfully.
|
|
await act(async () => {
|
|
resolveA({ content: "content-A" });
|
|
await attemptAResult;
|
|
});
|
|
|
|
// The draft still holds B's rejected content, unedited, with no explicit
|
|
// save. Autosave must stay suppressed.
|
|
act(() => {
|
|
result.current.setDraftContent("content-B");
|
|
});
|
|
rerender({ plan, isSaving: false, taskId: TASK_1, saveError: SIZE_REJECTION });
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(AUTO_SAVE_DELAY * 3);
|
|
await Promise.resolve();
|
|
});
|
|
expect(savePlan).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|