Tester #5506 §5: the existing test only asserted the final 'saved' state, which would also pass if the hook skipped the saving state altogether. Hold the second mocked saveFn promise so we can assert the intermediate transition. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
206 lines
7.5 KiB
TypeScript
206 lines
7.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
|
import type { PersonMention } from '$lib/types';
|
|
|
|
const mockSaveFn =
|
|
vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>>();
|
|
|
|
const NO_MENTIONS: PersonMention[] = [];
|
|
|
|
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
|
|
|
|
describe('createBlockAutoSave', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockSaveFn.mockClear();
|
|
mockSaveFn.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('getSaveState returns idle initially', () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
expect(as.getSaveState('block-1')).toBe('idle');
|
|
});
|
|
|
|
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'text 1', NO_MENTIONS);
|
|
as.handleTextChange('block-1', 'text 2', NO_MENTIONS);
|
|
as.handleTextChange('block-1', 'text 3', NO_MENTIONS);
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
expect(mockSaveFn).toHaveBeenCalledTimes(1);
|
|
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS);
|
|
});
|
|
|
|
it('handles concurrent blocks independently', async () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
|
|
as.handleTextChange('block-2', 'world', NO_MENTIONS);
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('sets save state to saving then saved on success', async () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
|
vi.advanceTimersByTime(1500);
|
|
expect(as.getSaveState('block-1')).toBe('saving');
|
|
await Promise.resolve();
|
|
expect(as.getSaveState('block-1')).toBe('saved');
|
|
});
|
|
|
|
it('sets save state to error on save failure', async () => {
|
|
mockSaveFn.mockRejectedValue(new Error('save failed'));
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
expect(as.getSaveState('block-1')).toBe('error');
|
|
});
|
|
|
|
it('handleRetry saves with provided current text', async () => {
|
|
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
|
|
mockSaveFn.mockResolvedValueOnce(undefined);
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'original', NO_MENTIONS);
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
expect(as.getSaveState('block-1')).toBe('error');
|
|
await as.handleRetry('block-1', 'original', NO_MENTIONS);
|
|
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
|
expect(as.getSaveState('block-1')).toBe('saved');
|
|
});
|
|
|
|
it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => {
|
|
// Hold the second saveFn so we can observe the saving→saved transition
|
|
// (Tester #5506 §5).
|
|
let resolveSecond!: () => void;
|
|
mockSaveFn.mockRejectedValueOnce(new Error('boom'));
|
|
mockSaveFn.mockReturnValueOnce(new Promise<void>((r) => (resolveSecond = r)));
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
|
|
const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
|
|
as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions);
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
expect(as.getSaveState('block-1')).toBe('error');
|
|
|
|
// Retry without re-passing the data — the hook resends the preserved payload.
|
|
const retryPromise = as.handleRetry('block-1', 'should-not-be-used', []);
|
|
// Yield once so executeSave runs synchronously up to the saveFn await.
|
|
await Promise.resolve();
|
|
expect(as.getSaveState('block-1')).toBe('saving');
|
|
expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions);
|
|
|
|
resolveSecond();
|
|
await retryPromise;
|
|
expect(as.getSaveState('block-1')).toBe('saved');
|
|
});
|
|
|
|
it('clearBlock removes all state for a block', () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
|
as.clearBlock('block-1');
|
|
expect(as.getSaveState('block-1')).toBe('idle');
|
|
});
|
|
|
|
it('destroy clears all pending timers so no save occurs', async () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
|
as.destroy();
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(mockSaveFn).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('flushOnUnload', () => {
|
|
let mockFetch: Mock;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
mockSaveFn.mockClear();
|
|
mockSaveFn.mockResolvedValue(undefined);
|
|
mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('sends a PUT request with keepalive:true for each pending block', () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
|
|
as.handleTextChange('block-2', 'world', NO_MENTIONS);
|
|
as.flushOnUnload();
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/api/documents/doc-1/transcription-blocks/block-1',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
keepalive: true,
|
|
body: JSON.stringify({ text: 'hello', mentionedPersons: [] })
|
|
})
|
|
);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/api/documents/doc-1/transcription-blocks/block-2',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
keepalive: true,
|
|
body: JSON.stringify({ text: 'world', mentionedPersons: [] })
|
|
})
|
|
);
|
|
});
|
|
|
|
it('does not call navigator.sendBeacon', () => {
|
|
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
|
as.flushOnUnload();
|
|
|
|
expect(sendBeaconSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing when there are no pending edits', () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.flushOnUnload();
|
|
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('cancels the debounce timer so saveFn is not also called', async () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
|
as.flushOnUnload();
|
|
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(mockSaveFn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
mockFetch.mockClear();
|
|
|
|
as.flushOnUnload();
|
|
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('flushes the pending mentionedPersons sidecar alongside text', () => {
|
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
|
const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }];
|
|
as.handleTextChange('block-1', '@Auguste Raddatz', mentions);
|
|
as.flushOnUnload();
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/api/documents/doc-1/transcription-blocks/block-1',
|
|
expect.objectContaining({
|
|
body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions })
|
|
})
|
|
);
|
|
});
|
|
});
|