test: cover autosave controller and lifecycle helper
This commit is contained in:
parent
7b5af20dee
commit
6b21c4f7b3
2 changed files with 159 additions and 0 deletions
60
src/lib/admin/autoSaveLifecycle.ts
Normal file
60
src/lib/admin/autoSaveLifecycle.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { beforeNavigate } from '$app/navigation'
|
||||||
|
import { onDestroy } from 'svelte'
|
||||||
|
import type { AutoSaveController } from './autoSave'
|
||||||
|
|
||||||
|
interface AutoSaveLifecycleOptions {
|
||||||
|
isReady?: () => boolean
|
||||||
|
onFlushError?: (error: unknown) => void
|
||||||
|
enableShortcut?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAutoSaveLifecycle(
|
||||||
|
controller: AutoSaveController,
|
||||||
|
options: AutoSaveLifecycleOptions = {}
|
||||||
|
) {
|
||||||
|
const { isReady = () => true, onFlushError, enableShortcut = true } = options
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
onDestroy(() => controller.destroy())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!enableShortcut) return
|
||||||
|
if (!isReady()) return
|
||||||
|
const key = event.key.toLowerCase()
|
||||||
|
const isModifier = event.metaKey || event.ctrlKey
|
||||||
|
if (!isModifier || key !== 's') return
|
||||||
|
event.preventDefault()
|
||||||
|
controller.flush().catch((error) => {
|
||||||
|
onFlushError?.(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableShortcut) {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopNavigating = beforeNavigate(async (navigation) => {
|
||||||
|
if (!isReady()) return
|
||||||
|
navigation.cancel()
|
||||||
|
try {
|
||||||
|
await controller.flush()
|
||||||
|
navigation.retry()
|
||||||
|
} catch (error) {
|
||||||
|
onFlushError?.(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (enableShortcut) {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
stopNavigating?.()
|
||||||
|
controller.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(stop)
|
||||||
|
|
||||||
|
return { stop }
|
||||||
|
}
|
||||||
99
tests/autoSaveController.test.ts
Normal file
99
tests/autoSaveController.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { describe, it, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createAutoSaveController } from '../src/lib/admin/autoSave'
|
||||||
|
|
||||||
|
describe('createAutoSaveController', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
// @ts-expect-error add minimal navigator shim for tests
|
||||||
|
global.navigator = { onLine: true }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips save when payload matches primed baseline', async () => {
|
||||||
|
let value = 0
|
||||||
|
let saveCalls = 0
|
||||||
|
|
||||||
|
const controller = createAutoSaveController<{ value: number }>({
|
||||||
|
debounceMs: 5,
|
||||||
|
getPayload: () => ({ value }),
|
||||||
|
save: async () => {
|
||||||
|
saveCalls += 1
|
||||||
|
return { value }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
controller.prime({ value })
|
||||||
|
controller.schedule()
|
||||||
|
|
||||||
|
await wait(15)
|
||||||
|
assert.equal(saveCalls, 0)
|
||||||
|
|
||||||
|
await controller.flush()
|
||||||
|
assert.equal(saveCalls, 0)
|
||||||
|
|
||||||
|
controller.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves when payload changes and returns to idle after success', async () => {
|
||||||
|
let value = 0
|
||||||
|
let updatedAt = 0
|
||||||
|
let saveCalls = 0
|
||||||
|
const statuses: string[] = []
|
||||||
|
|
||||||
|
const controller = createAutoSaveController<{ value: number; updatedAt: number }, { updatedAt: number }>({
|
||||||
|
debounceMs: 5,
|
||||||
|
idleResetMs: 10,
|
||||||
|
getPayload: () => ({ value, updatedAt }),
|
||||||
|
save: async (payload) => {
|
||||||
|
saveCalls += 1
|
||||||
|
return { updatedAt: payload.updatedAt + 1 }
|
||||||
|
},
|
||||||
|
onSaved: (response, { prime }) => {
|
||||||
|
updatedAt = response.updatedAt
|
||||||
|
prime({ value, updatedAt })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubscribe = controller.status.subscribe((status) => {
|
||||||
|
statuses.push(status)
|
||||||
|
})
|
||||||
|
|
||||||
|
controller.prime({ value, updatedAt })
|
||||||
|
|
||||||
|
value = 1
|
||||||
|
controller.schedule()
|
||||||
|
|
||||||
|
await wait(15)
|
||||||
|
assert.equal(saveCalls, 1)
|
||||||
|
assert.ok(statuses.includes('saving'))
|
||||||
|
assert.ok(statuses.includes('saved'))
|
||||||
|
|
||||||
|
await wait(20)
|
||||||
|
assert.equal(statuses.at(-1), 'idle')
|
||||||
|
|
||||||
|
unsubscribe()
|
||||||
|
controller.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels pending work on destroy', async () => {
|
||||||
|
let saveCalls = 0
|
||||||
|
const controller = createAutoSaveController<{ foo: string }>({
|
||||||
|
debounceMs: 20,
|
||||||
|
getPayload: () => ({ foo: 'bar' }),
|
||||||
|
save: async () => {
|
||||||
|
saveCalls += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
controller.schedule()
|
||||||
|
controller.destroy()
|
||||||
|
|
||||||
|
await wait(30)
|
||||||
|
assert.equal(saveCalls, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function wait(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue