@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
327 lines (268 loc) • 11 kB
text/typescript
import { ObservableValue, usingAsync } from '@furystack/utils'
import { describe, expect, it, vi } from 'vitest'
import { ResourceManager } from './resource-manager.js'
describe('ResourceManager', () => {
it('Should return an observable from cache', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const o = new ObservableValue(1)
const [value1] = rm.useObservable('test', o, () => {
/** ignore */
})
const [value2] = rm.useObservable('test', o, () => {
/** ignore */
})
expect(value1).toBe(value2)
expect(o.getObservers().length).toBe(1)
})
})
it('Should switch to a new observable when a different reference is passed for the same key', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const o1 = new ObservableValue(1)
const o2 = new ObservableValue(42)
const onChange = vi.fn()
const [value1] = rm.useObservable('test', o1, onChange)
expect(value1).toBe(1)
expect(o1.getObservers().length).toBe(1)
const [value2] = rm.useObservable('test', o2, onChange)
expect(value2).toBe(42)
expect(o1.getObservers().length).toBe(0)
expect(o2.getObservers().length).toBe(1)
})
})
it('Should subscribe with the new onChange callback when switching observables', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const o1 = new ObservableValue('a')
const o2 = new ObservableValue('x')
const onChange1 = vi.fn()
const onChange2 = vi.fn()
rm.useObservable('test', o1, onChange1)
rm.useObservable('test', o2, onChange2)
o2.setValue('y')
expect(onChange2).toHaveBeenCalledWith('y')
expect(onChange1).not.toHaveBeenCalled()
})
})
it('Should not re-subscribe when the same observable reference is passed', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const o = new ObservableValue(1)
const onChange = vi.fn()
rm.useObservable('test', o, onChange)
rm.useObservable('test', o, onChange)
rm.useObservable('test', o, onChange)
expect(o.getObservers().length).toBe(1)
})
})
it('Should return a setValue bound to the new observable after switching', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const o1 = new ObservableValue(1)
const o2 = new ObservableValue(10)
const onChange = vi.fn()
const [, setValue1] = rm.useObservable('test', o1, onChange)
setValue1(5)
expect(o1.getValue()).toBe(5)
const [, setValue2] = rm.useObservable('test', o2, onChange)
setValue2(99)
expect(o2.getValue()).toBe(99)
expect(o1.getValue()).toBe(5)
})
})
it('Should handle multiple sequential observable switches for the same key', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const o1 = new ObservableValue('a')
const o2 = new ObservableValue('b')
const o3 = new ObservableValue('c')
const onChange = vi.fn()
const [v1] = rm.useObservable('test', o1, onChange)
expect(v1).toBe('a')
const [v2] = rm.useObservable('test', o2, onChange)
expect(v2).toBe('b')
expect(o1.getObservers().length).toBe(0)
const [v3] = rm.useObservable('test', o3, onChange)
expect(v3).toBe('c')
expect(o2.getObservers().length).toBe(0)
expect(o3.getObservers().length).toBe(1)
})
})
it('Should not trigger callbacks on the old observable after switching', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const o1 = new ObservableValue(1)
const o2 = new ObservableValue(2)
const onChange = vi.fn()
rm.useObservable('test', o1, onChange)
rm.useObservable('test', o2, onChange)
onChange.mockClear()
o1.setValue(999)
expect(onChange).not.toHaveBeenCalled()
o2.setValue(100)
expect(onChange).toHaveBeenCalledWith(100)
})
})
it('Should clean up switched observers on dispose', async () => {
const o1 = new ObservableValue(1)
const o2 = new ObservableValue(2)
const onChange = vi.fn()
await usingAsync(new ResourceManager(), async (rm) => {
rm.useObservable('test', o1, onChange)
rm.useObservable('test', o2, onChange)
expect(o2.getObservers().length).toBe(1)
})
expect(o2.getObservers().length).toBe(0)
})
it('Should return a disposable from cache', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const factory = vi.fn(() => ({
[Symbol.dispose]: () => {
/** ignore */
},
}))
const d1 = rm.useDisposable('test', factory)
const d2 = rm.useDisposable('test', factory)
expect(d1).toBe(d2)
expect(factory).toHaveBeenCalledTimes(1)
})
})
it('Should dispose all disposables on dispose', async () => {
const disposable = {
[Symbol.dispose]: vi.fn(),
}
const factory = vi.fn(() => disposable)
await usingAsync(new ResourceManager(), async (rm) => {
rm.useDisposable('test', factory)
expect(factory).toHaveBeenCalledTimes(1)
})
expect(disposable[Symbol.dispose]).toHaveBeenCalledTimes(1)
})
it('Should dispose all async disposables on dispose', async () => {
const disposable = {
[Symbol.asyncDispose]: vi.fn(),
}
const factory = vi.fn(() => disposable)
await usingAsync(new ResourceManager(), async (rm) => {
rm.useDisposable('test', factory)
expect(factory).toHaveBeenCalledTimes(1)
})
expect(disposable[Symbol.asyncDispose]).toHaveBeenCalledTimes(1)
})
it('Should throw an aggregated error when failed to dispose something', async () => {
const disposable = {
[Symbol.dispose]: vi.fn(() => {
throw new Error('Failed to dispose')
}),
}
const factory = vi.fn(() => disposable)
await expect(
async () =>
await usingAsync(new ResourceManager(), async (rm) => {
rm.useDisposable('test', factory)
expect(factory).toHaveBeenCalledTimes(1)
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: There was an error during disposing 1 stores: Error: Failed to dispose]`,
)
expect(disposable[Symbol.dispose]).toHaveBeenCalledTimes(1)
})
it('Should silently ignore useState setter calls after disposal', async () => {
let setValueFn: (value: number) => void
await usingAsync(new ResourceManager(), async (rm) => {
const [, setValue] = rm.useState<number>('count', 0, vi.fn())
setValueFn = setValue
})
// After disposal, calling the setter should not throw
expect(() => setValueFn!(42)).not.toThrow()
})
it('Should throw an aggregated error when failed to async dispose something', async () => {
const disposable = {
[Symbol.asyncDispose]: vi.fn(async () => {
throw new Error('Failed to dispose')
}),
}
const factory = vi.fn(() => disposable)
await expect(
async () =>
await usingAsync(new ResourceManager(), async (rm) => {
rm.useDisposable('test', factory)
expect(factory).toHaveBeenCalledTimes(1)
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: There was an error during disposing 1 stores: Error: Failed to dispose]`,
)
expect(disposable[Symbol.asyncDispose]).toHaveBeenCalledTimes(1)
})
describe('useDisposable with deps', () => {
it('Should behave identically without deps (backward compat)', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const factory = vi.fn(() => ({
[Symbol.dispose]: () => {},
}))
const d1 = rm.useDisposable('test', factory)
const d2 = rm.useDisposable('test', factory)
expect(d1).toBe(d2)
expect(factory).toHaveBeenCalledTimes(1)
})
})
it('Should return cached resource when deps are the same', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const factory = vi.fn(() => ({
[Symbol.dispose]: () => {},
}))
const d1 = rm.useDisposable('test', factory, [1, 'a'])
const d2 = rm.useDisposable('test', factory, [1, 'a'])
expect(d1).toBe(d2)
expect(factory).toHaveBeenCalledTimes(1)
})
})
it('Should dispose old resource and create new one when deps change', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const dispose1 = vi.fn()
const dispose2 = vi.fn()
const d1 = rm.useDisposable('test', () => ({ [Symbol.dispose]: dispose1 }), ['v1'])
expect(dispose1).not.toHaveBeenCalled()
const d2 = rm.useDisposable('test', () => ({ [Symbol.dispose]: dispose2 }), ['v2'])
expect(dispose1).toHaveBeenCalledTimes(1)
expect(d1).not.toBe(d2)
})
})
it('Should call Symbol.asyncDispose on old async-disposable resource when deps change', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const asyncDispose1 = vi.fn()
rm.useDisposable('test', () => ({ [Symbol.asyncDispose]: asyncDispose1 }), ['v1'])
rm.useDisposable('test', () => ({ [Symbol.dispose]: () => {} }), ['v2'])
expect(asyncDispose1).toHaveBeenCalledTimes(1)
})
})
it('Should handle multiple sequential dep changes (A -> B -> C)', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const disposeA = vi.fn()
const disposeB = vi.fn()
const disposeC = vi.fn()
const a = rm.useDisposable('test', () => ({ [Symbol.dispose]: disposeA }), ['A'])
const b = rm.useDisposable('test', () => ({ [Symbol.dispose]: disposeB }), ['B'])
const c = rm.useDisposable('test', () => ({ [Symbol.dispose]: disposeC }), ['C'])
expect(a).not.toBe(b)
expect(b).not.toBe(c)
expect(disposeA).toHaveBeenCalledTimes(1)
expect(disposeB).toHaveBeenCalledTimes(1)
expect(disposeC).not.toHaveBeenCalled()
})
})
it('Should dispose the final resource on ResourceManager disposal', async () => {
const disposeFn = vi.fn()
await usingAsync(new ResourceManager(), async (rm) => {
rm.useDisposable('test', () => ({ [Symbol.dispose]: vi.fn() }), ['v1'])
rm.useDisposable('test', () => ({ [Symbol.dispose]: disposeFn }), ['v2'])
expect(disposeFn).not.toHaveBeenCalled()
})
expect(disposeFn).toHaveBeenCalledTimes(1)
})
it('Should treat undefined and null as equal within deps (JSON.stringify behavior)', async () => {
await usingAsync(new ResourceManager(), async (rm) => {
const factory = vi.fn(() => ({
[Symbol.dispose]: vi.fn(),
}))
rm.useDisposable('test', factory, [undefined])
rm.useDisposable('test', factory, [null])
expect(factory).toHaveBeenCalledTimes(1)
})
})
})
})