| ... | @@ -1,7 +1,11 @@ | ... | @@ -1,7 +1,11 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | | 3 | |
| 4 | import { type ComposerImageAttachment, useComposerDraftStore } from "./composerDraftStore"; | 4 | import { |
| | 5 | type ComposerImageAttachment, |
| | 6 | createDebouncedStorage, |
| | 7 | useComposerDraftStore, |
| | 8 | } from "./composerDraftStore"; |
| 5 | | 9 | |
| 6 | function makeImage(input: { | 10 | function makeImage(input: { |
| 7 | id: string; | 11 | id: string; |
| ... | @@ -451,3 +455,125 @@ describe("composerDraftStore runtime and interaction settings", () => { | ... | @@ -451,3 +455,125 @@ describe("composerDraftStore runtime and interaction settings", () => { |
| 451 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | 455 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); |
| 452 | }); | 456 | }); |
| 453 | }); | 457 | }); |
| | 458 | |
| | 459 | // --------------------------------------------------------------------------- |
| | 460 | // createDebouncedStorage |
| | 461 | // --------------------------------------------------------------------------- |
| | 462 | |
| | 463 | function createMockStorage() { |
| | 464 | const store = new Map<string, string>(); |
| | 465 | return { |
| | 466 | getItem: vi.fn((name: string) => store.get(name) ?? null), |
| | 467 | setItem: vi.fn((name: string, value: string) => { |
| | 468 | store.set(name, value); |
| | 469 | }), |
| | 470 | removeItem: vi.fn((name: string) => { |
| | 471 | store.delete(name); |
| | 472 | }), |
| | 473 | }; |
| | 474 | } |
| | 475 | |
| | 476 | describe("createDebouncedStorage", () => { |
| | 477 | beforeEach(() => { |
| | 478 | vi.useFakeTimers(); |
| | 479 | }); |
| | 480 | |
| | 481 | afterEach(() => { |
| | 482 | vi.useRealTimers(); |
| | 483 | }); |
| | 484 | |
| | 485 | it("delegates getItem immediately", () => { |
| | 486 | const base = createMockStorage(); |
| | 487 | base.getItem.mockReturnValueOnce("value"); |
| | 488 | const storage = createDebouncedStorage(base); |
| | 489 | |
| | 490 | expect(storage.getItem("key")).toBe("value"); |
| | 491 | expect(base.getItem).toHaveBeenCalledWith("key"); |
| | 492 | }); |
| | 493 | |
| | 494 | it("does not write to base storage until the debounce fires", () => { |
| | 495 | const base = createMockStorage(); |
| | 496 | const storage = createDebouncedStorage(base); |
| | 497 | |
| | 498 | storage.setItem("key", "v1"); |
| | 499 | expect(base.setItem).not.toHaveBeenCalled(); |
| | 500 | |
| | 501 | vi.advanceTimersByTime(299); |
| | 502 | expect(base.setItem).not.toHaveBeenCalled(); |
| | 503 | |
| | 504 | vi.advanceTimersByTime(1); |
| | 505 | expect(base.setItem).toHaveBeenCalledWith("key", "v1"); |
| | 506 | }); |
| | 507 | |
| | 508 | it("only writes the last value when setItem is called rapidly", () => { |
| | 509 | const base = createMockStorage(); |
| | 510 | const storage = createDebouncedStorage(base); |
| | 511 | |
| | 512 | storage.setItem("key", "v1"); |
| | 513 | storage.setItem("key", "v2"); |
| | 514 | storage.setItem("key", "v3"); |
| | 515 | |
| | 516 | vi.advanceTimersByTime(300); |
| | 517 | expect(base.setItem).toHaveBeenCalledTimes(1); |
| | 518 | expect(base.setItem).toHaveBeenCalledWith("key", "v3"); |
| | 519 | }); |
| | 520 | |
| | 521 | it("removeItem cancels a pending setItem write", () => { |
| | 522 | const base = createMockStorage(); |
| | 523 | const storage = createDebouncedStorage(base); |
| | 524 | |
| | 525 | storage.setItem("key", "v1"); |
| | 526 | storage.removeItem("key"); |
| | 527 | |
| | 528 | vi.advanceTimersByTime(300); |
| | 529 | expect(base.setItem).not.toHaveBeenCalled(); |
| | 530 | expect(base.removeItem).toHaveBeenCalledWith("key"); |
| | 531 | }); |
| | 532 | |
| | 533 | it("flush writes the pending value immediately", () => { |
| | 534 | const base = createMockStorage(); |
| | 535 | const storage = createDebouncedStorage(base); |
| | 536 | |
| | 537 | storage.setItem("key", "v1"); |
| | 538 | expect(base.setItem).not.toHaveBeenCalled(); |
| | 539 | |
| | 540 | storage.flush(); |
| | 541 | expect(base.setItem).toHaveBeenCalledWith("key", "v1"); |
| | 542 | |
| | 543 | // Timer should be cancelled; no duplicate write. |
| | 544 | vi.advanceTimersByTime(300); |
| | 545 | expect(base.setItem).toHaveBeenCalledTimes(1); |
| | 546 | }); |
| | 547 | |
| | 548 | it("flush is a no-op when nothing is pending", () => { |
| | 549 | const base = createMockStorage(); |
| | 550 | const storage = createDebouncedStorage(base); |
| | 551 | |
| | 552 | storage.flush(); |
| | 553 | expect(base.setItem).not.toHaveBeenCalled(); |
| | 554 | }); |
| | 555 | |
| | 556 | it("flush after removeItem is a no-op", () => { |
| | 557 | const base = createMockStorage(); |
| | 558 | const storage = createDebouncedStorage(base); |
| | 559 | |
| | 560 | storage.setItem("key", "v1"); |
| | 561 | storage.removeItem("key"); |
| | 562 | storage.flush(); |
| | 563 | |
| | 564 | expect(base.setItem).not.toHaveBeenCalled(); |
| | 565 | }); |
| | 566 | |
| | 567 | it("setItem works normally after removeItem cancels a pending write", () => { |
| | 568 | const base = createMockStorage(); |
| | 569 | const storage = createDebouncedStorage(base); |
| | 570 | |
| | 571 | storage.setItem("key", "v1"); |
| | 572 | storage.removeItem("key"); |
| | 573 | storage.setItem("key", "v2"); |
| | 574 | |
| | 575 | vi.advanceTimersByTime(300); |
| | 576 | expect(base.setItem).toHaveBeenCalledTimes(1); |
| | 577 | expect(base.setItem).toHaveBeenCalledWith("key", "v2"); |
| | 578 | }); |
| | 579 | }); |