useDraft.test.ts (7169B)
1 import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2 import { renderHook, act } from "@testing-library/react"; 3 import { useDraft, clearDraft, migrateDraft, DRAFT_KEY_PREFIX } from "./useDraft"; 4 5 beforeEach(() => { 6 localStorage.clear(); 7 vi.useFakeTimers({ shouldAdvanceTime: true }); 8 }); 9 10 afterEach(() => { 11 vi.useRealTimers(); 12 }); 13 14 describe("useDraft", () => { 15 it("returns empty string when no draft exists", () => { 16 const { result } = renderHook(() => useDraft("session-1")); 17 const [text] = result.current; 18 expect(text).toBe(""); 19 }); 20 21 it("returns existing draft from localStorage", () => { 22 localStorage.setItem(`${DRAFT_KEY_PREFIX}session-1`, "hello world"); 23 const { result } = renderHook(() => useDraft("session-1")); 24 const [text] = result.current; 25 expect(text).toBe("hello world"); 26 }); 27 28 it("uses 'new' key for null sessionId", () => { 29 localStorage.setItem(`${DRAFT_KEY_PREFIX}new`, "draft for new session"); 30 const { result } = renderHook(() => useDraft(null)); 31 const [text] = result.current; 32 expect(text).toBe("draft for new session"); 33 }); 34 35 it("saves draft to localStorage after debounce", () => { 36 const { result } = renderHook(() => useDraft("session-1")); 37 const [, setText] = result.current; 38 39 act(() => { 40 setText("new draft text"); 41 }); 42 43 // Not saved yet (debounced) 44 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}session-1`)).toBeNull(); 45 46 // Advance past debounce (300ms) 47 act(() => { 48 vi.advanceTimersByTime(350); 49 }); 50 51 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}session-1`)).toBe("new draft text"); 52 }); 53 54 it("debounces rapid saves — only last value persists", () => { 55 const { result } = renderHook(() => useDraft("session-1")); 56 const [, setText] = result.current; 57 58 act(() => { 59 setText("a"); 60 vi.advanceTimersByTime(100); 61 setText("ab"); 62 vi.advanceTimersByTime(100); 63 setText("abc"); 64 }); 65 66 act(() => { 67 vi.advanceTimersByTime(350); 68 }); 69 70 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}session-1`)).toBe("abc"); 71 }); 72 73 it("restores draft when sessionId changes", () => { 74 localStorage.setItem(`${DRAFT_KEY_PREFIX}s1`, "draft one"); 75 localStorage.setItem(`${DRAFT_KEY_PREFIX}s2`, "draft two"); 76 77 const { result, rerender } = renderHook( 78 ({ sessionId }) => useDraft(sessionId), 79 { initialProps: { sessionId: "s1" as string | null } }, 80 ); 81 82 expect(result.current[0]).toBe("draft one"); 83 84 rerender({ sessionId: "s2" }); 85 expect(result.current[0]).toBe("draft two"); 86 }); 87 88 it("clears in-memory text when switching to session with no draft", () => { 89 const { result, rerender } = renderHook( 90 ({ sessionId }) => useDraft(sessionId), 91 { initialProps: { sessionId: "s1" as string | null } }, 92 ); 93 94 act(() => { 95 result.current[1]("some text"); 96 }); 97 98 rerender({ sessionId: "s2" }); 99 expect(result.current[0]).toBe(""); 100 }); 101 }); 102 103 describe("clearDraft", () => { 104 it("removes draft from localStorage for given session", () => { 105 localStorage.setItem(`${DRAFT_KEY_PREFIX}session-1`, "to be cleared"); 106 clearDraft("session-1"); 107 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}session-1`)).toBeNull(); 108 }); 109 110 it("removes 'new' draft when sessionId is null", () => { 111 localStorage.setItem(`${DRAFT_KEY_PREFIX}new`, "new draft"); 112 clearDraft(null); 113 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}new`)).toBeNull(); 114 }); 115 116 it("is a no-op when no draft exists", () => { 117 clearDraft("nonexistent"); 118 // no error thrown 119 }); 120 }); 121 122 describe("markSent", () => { 123 it("does not restore draft after markSent when sessionId changes", () => { 124 const { result, rerender } = renderHook( 125 ({ sessionId }) => useDraft(sessionId), 126 { initialProps: { sessionId: null as string | null } }, 127 ); 128 129 // User types a message in the "new" (null) session 130 act(() => { 131 result.current[1]("hello world"); 132 }); 133 134 // User hits send — markSent + clear text (simulates Composer's handleSend) 135 act(() => { 136 result.current[2](); // markSent 137 result.current[1](""); // setText("") 138 }); 139 140 expect(result.current[0]).toBe(""); 141 142 // Session creation triggers sessionId change (null → "new-session-id") 143 // migrateDraft copies text to the new key, but markSent should suppress restore 144 localStorage.setItem(`${DRAFT_KEY_PREFIX}new-session-id`, "hello world"); 145 rerender({ sessionId: "new-session-id" }); 146 147 // Text should stay empty — the draft should NOT be restored 148 expect(result.current[0]).toBe(""); 149 }); 150 151 it("clears migrated draft from localStorage after markSent", () => { 152 localStorage.setItem(`${DRAFT_KEY_PREFIX}new`, "old draft"); 153 154 const { result, rerender } = renderHook( 155 ({ sessionId }) => useDraft(sessionId), 156 { initialProps: { sessionId: null as string | null } }, 157 ); 158 159 act(() => { 160 result.current[2](); // markSent 161 result.current[1](""); // setText 162 }); 163 164 // Simulate migrateDraft (ChatView does this before sessionId changes) 165 localStorage.setItem(`${DRAFT_KEY_PREFIX}real-session`, "old draft"); 166 rerender({ sessionId: "real-session" }); 167 168 // The migrated draft should have been cleared from localStorage 169 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}real-session`)).toBeNull(); 170 }); 171 172 it("allows normal draft restore on subsequent sessionId changes", () => { 173 const { result, rerender } = renderHook( 174 ({ sessionId }) => useDraft(sessionId), 175 { initialProps: { sessionId: null as string | null } }, 176 ); 177 178 // First send — markSent suppresses restore 179 act(() => { 180 result.current[1]("msg 1"); 181 result.current[2](); // markSent 182 result.current[1](""); 183 }); 184 185 localStorage.setItem(`${DRAFT_KEY_PREFIX}s1`, "msg 1"); 186 rerender({ sessionId: "s1" }); 187 expect(result.current[0]).toBe(""); // suppressed 188 189 // Set a draft for s2 and switch — should restore normally 190 localStorage.setItem(`${DRAFT_KEY_PREFIX}s2`, "draft for s2"); 191 rerender({ sessionId: "s2" }); 192 expect(result.current[0]).toBe("draft for s2"); // normal restore 193 }); 194 }); 195 196 describe("migrateDraft", () => { 197 it("copies draft from 'new' to session ID and removes 'new'", () => { 198 localStorage.setItem(`${DRAFT_KEY_PREFIX}new`, "my draft"); 199 migrateDraft(null, "created-session-id"); 200 201 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}new`)).toBeNull(); 202 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}created-session-id`)).toBe("my draft"); 203 }); 204 205 it("does nothing if source draft doesn't exist", () => { 206 migrateDraft(null, "session-1"); 207 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}session-1`)).toBeNull(); 208 }); 209 210 it("overwrites target if it already exists", () => { 211 localStorage.setItem(`${DRAFT_KEY_PREFIX}new`, "new draft"); 212 localStorage.setItem(`${DRAFT_KEY_PREFIX}session-1`, "old draft"); 213 migrateDraft(null, "session-1"); 214 215 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}new`)).toBeNull(); 216 expect(localStorage.getItem(`${DRAFT_KEY_PREFIX}session-1`)).toBe("new draft"); 217 }); 218 });