summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/PendingInputs.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat/ui/PendingInputs.test.ts')
-rw-r--r--src/features/chat/ui/PendingInputs.test.ts217
1 files changed, 217 insertions, 0 deletions
diff --git a/src/features/chat/ui/PendingInputs.test.ts b/src/features/chat/ui/PendingInputs.test.ts
new file mode 100644
index 0000000..cea770d
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.test.ts
@@ -0,0 +1,217 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PendingInput } from "../pending-inputs";
+import { createChatStore } from "../store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "../test-helpers";
+import PendingInputs from "./PendingInputs.svelte";
+
+function input(requestId: string, type: "chat.send" | "chat.queue" = "chat.send"): PendingInput {
+ return {
+ request: {
+ ...(type === "chat.send" ? { type, message: requestId } : { type, text: requestId }),
+ conversationId: "c",
+ requestId,
+ images: [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }],
+ },
+ };
+}
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+ cleanup();
+ const remaining = vi.getTimerCount();
+ vi.useRealTimers();
+ expect(remaining).toBe(0);
+});
+
+describe("pending input warning grace period", () => {
+ it.each([
+ "chat.send",
+ "chat.queue",
+ ] as const)("renders no %s recovery UI before 1000 ms, then keeps its content stable", async (type) => {
+ const { container } = render(PendingInputs, {
+ inputs: [input("saved message", type)],
+ onDiscard: vi.fn(),
+ });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(999);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Delivery unconfirmed. Your input is saved on this device.");
+ expect(warning).toHaveTextContent(
+ "Check the conversation history before sending again to avoid a duplicate.",
+ );
+ expect(screen.getByRole("textbox", { name: "Saved message" })).toHaveValue("saved message");
+ expect(screen.getByRole("img", { name: "Saved attachment" })).toHaveAttribute(
+ "src",
+ "data:image/png;base64,AAAA",
+ );
+ const content = warning.innerHTML;
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning.innerHTML).toBe(content);
+ });
+
+ it.each([
+ "confirmation",
+ "discard",
+ ])("%s before the threshold cancels the timer and never flashes later", async (resolution) => {
+ const transport = createFakeTransport();
+ const save = vi.fn();
+ const store = createChatStore({
+ conversationId: "c",
+ createRequestId: () => "first",
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ pendingInputStorage: { load: () => [], save },
+ });
+ try {
+ store.send("first");
+ // Saving and transmission stay immediate, independent of warning display.
+ expect(save).toHaveBeenCalledExactlyOnceWith(store.pendingInputs);
+ expect(transport.sent).toHaveLength(1);
+ const { container, rerender } = render(PendingInputs, {
+ inputs: store.pendingInputs,
+ onDiscard: store.discardPendingInput,
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ if (resolution === "confirmation") {
+ store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "first" });
+ } else {
+ store.discardPendingInput("first");
+ }
+ expect(store.pendingInputs).toEqual([]);
+ expect(save).toHaveBeenLastCalledWith([]);
+ await rerender({ inputs: store.pendingInputs });
+ expect(vi.getTimerCount()).toBe(0);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("hides an already visible warning immediately on confirmation", async () => {
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("preserves the discard action and hides the saved copy without another delay", async () => {
+ const onDiscard = vi.fn();
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard,
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ await fireEvent.click(screen.getByRole("button", { name: "Discard saved copy" }));
+ expect(onDiscard).toHaveBeenCalledExactlyOnceWith("first");
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("cancels all waiting timers on unmount", async () => {
+ const { unmount } = render(PendingInputs, {
+ inputs: [input("first"), input("second")],
+ onDiscard: vi.fn(),
+ });
+ expect(vi.getTimerCount()).toBe(2);
+ await vi.advanceTimersByTimeAsync(500);
+ unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("gives successive inputs independent delays without restarting existing rows", async () => {
+ const first = input("first");
+ const second = input("second");
+ const { rerender } = render(PendingInputs, { inputs: [first], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [second, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(screen.getByRole("textbox")).toHaveValue("first");
+ await rerender({ inputs: [{ ...second }, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(599);
+ expect(screen.getAllByRole("status")).toEqual([warning]);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(
+ screen.getAllByRole("textbox").map((node) => (node as HTMLTextAreaElement).value),
+ ).toEqual(["second", "first"]);
+ // Removing the older warning must not re-hide/restart the newer warning.
+ await rerender({ inputs: [second] });
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("starts a fresh delay after an earlier input was confirmed during its grace period", async () => {
+ const { rerender } = render(PendingInputs, { inputs: [input("first")], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [] });
+ await rerender({ inputs: [input("second")] });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ });
+
+ it("keeps the original deadline when rejection arrives, and updates visible content immediately", async () => {
+ const pending = input("first");
+ const { rerender } = render(PendingInputs, { inputs: [pending], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [{ ...pending, rejection: "disk full" }] });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Message rejected: disk full");
+ expect(warning).not.toHaveTextContent("Delivery unconfirmed");
+ await rerender({ inputs: [{ ...pending, rejection: "still rejected" }] });
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning).toHaveTextContent("Message rejected: still rejected");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it.each([
+ undefined,
+ "disk full",
+ ])("restored entries (rejection: %s) use the same grace period and retain recovery content", async (rejection) => {
+ const restored = input("restored saved message");
+ render(PendingInputs, {
+ inputs: [rejection === undefined ? restored : { ...restored, rejection }],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("status")).toHaveTextContent(
+ rejection === undefined ? "Delivery unconfirmed" : "Message rejected: disk full",
+ );
+ expect(screen.getByRole("textbox")).toHaveValue("restored saved message");
+ expect(screen.getByRole("button", { name: "Discard saved copy" })).toBeEnabled();
+ });
+});