135 lines
4.7 KiB
TypeScript
135 lines
4.7 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { RecoveryStateStore, extractErrorCode, type RecoveryState } from './recovery';
|
|
|
|
describe('RecoveryStateStore', () => {
|
|
it('returns the initial state', () => {
|
|
const store = new RecoveryStateStore();
|
|
const s = store.get();
|
|
expect(s.mode).toBe('normal');
|
|
expect(s.reason).toBeNull();
|
|
expect(s.updateState).toBe('idle');
|
|
expect(s.updateVersion).toBeNull();
|
|
expect(s.lastUpdateError).toBeNull();
|
|
expect(s.lastCheckResult).toBeNull();
|
|
});
|
|
|
|
it('shallow-merges partial updates', () => {
|
|
const store = new RecoveryStateStore();
|
|
store.update({ updateState: 'checking' });
|
|
expect(store.get().updateState).toBe('checking');
|
|
expect(store.get().mode).toBe('normal');
|
|
store.update({ updateVersion: '1.2.3' });
|
|
expect(store.get().updateState).toBe('checking');
|
|
expect(store.get().updateVersion).toBe('1.2.3');
|
|
});
|
|
|
|
it('fires listeners on every update', () => {
|
|
const store = new RecoveryStateStore();
|
|
const cb = vi.fn();
|
|
store.subscribe(cb);
|
|
store.update({ updateState: 'checking' });
|
|
store.update({ updateState: 'idle' });
|
|
expect(cb).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('subscribers receive the latest snapshot', () => {
|
|
const store = new RecoveryStateStore();
|
|
let received: RecoveryState | null = null;
|
|
store.subscribe((s) => { received = s; });
|
|
store.update({ updateState: 'downloaded', updateVersion: '2.0.0' });
|
|
expect(received).not.toBeNull();
|
|
expect(received!.updateState).toBe('downloaded');
|
|
expect(received!.updateVersion).toBe('2.0.0');
|
|
});
|
|
|
|
it('unsubscribe stops callbacks', () => {
|
|
const store = new RecoveryStateStore();
|
|
const cb = vi.fn();
|
|
const unsub = store.subscribe(cb);
|
|
store.update({ updateState: 'checking' });
|
|
unsub();
|
|
store.update({ updateState: 'idle' });
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('multiple subscribers all receive updates', () => {
|
|
const store = new RecoveryStateStore();
|
|
const a = vi.fn();
|
|
const b = vi.fn();
|
|
store.subscribe(a);
|
|
store.subscribe(b);
|
|
store.update({ updateState: 'checking' });
|
|
expect(a).toHaveBeenCalledTimes(1);
|
|
expect(b).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('isInRecoveryMode is false initially, true after markRecoveryEntered, false after markRecoveryExited', () => {
|
|
const store = new RecoveryStateStore();
|
|
expect(store.isInRecoveryMode()).toBe(false);
|
|
store.markRecoveryEntered();
|
|
expect(store.isInRecoveryMode()).toBe(true);
|
|
store.markRecoveryExited();
|
|
expect(store.isInRecoveryMode()).toBe(false);
|
|
});
|
|
|
|
it('a throwing listener does not stop other listeners', () => {
|
|
const store = new RecoveryStateStore();
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const a = vi.fn(() => { throw new Error('boom'); });
|
|
const b = vi.fn();
|
|
store.subscribe(a);
|
|
store.subscribe(b);
|
|
expect(() => store.update({ updateState: 'checking' })).not.toThrow();
|
|
expect(a).toHaveBeenCalledTimes(1);
|
|
expect(b).toHaveBeenCalledTimes(1);
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it('a listener can unsubscribe itself during notification without breaking the pass', () => {
|
|
const store = new RecoveryStateStore();
|
|
const b = vi.fn();
|
|
let unsubA: (() => void) | null = null;
|
|
unsubA = store.subscribe(() => { unsubA?.(); });
|
|
store.subscribe(b);
|
|
expect(() => store.update({ updateState: 'checking' })).not.toThrow();
|
|
// Second update: the self-unsubscribed listener should be gone, b still fires
|
|
store.update({ updateState: 'idle' });
|
|
expect(b).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('returned state is frozen — accidental external mutation throws in strict mode', () => {
|
|
const store = new RecoveryStateStore();
|
|
store.update({ updateState: 'checking' });
|
|
const s = store.get();
|
|
expect(Object.isFrozen(s)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('extractErrorCode', () => {
|
|
it('extracts string code from Error-like object', () => {
|
|
const err = new Error('boom') as Error & { code: string };
|
|
err.code = 'ENOSPC';
|
|
expect(extractErrorCode(err)).toBe('ENOSPC');
|
|
});
|
|
|
|
it('returns null for Error without code', () => {
|
|
expect(extractErrorCode(new Error('boom'))).toBeNull();
|
|
});
|
|
|
|
it('returns null when code is non-string', () => {
|
|
const err = { code: 42 };
|
|
expect(extractErrorCode(err)).toBeNull();
|
|
});
|
|
|
|
it('returns null for null/undefined/primitives', () => {
|
|
expect(extractErrorCode(null)).toBeNull();
|
|
expect(extractErrorCode(undefined)).toBeNull();
|
|
expect(extractErrorCode('string')).toBeNull();
|
|
expect(extractErrorCode(42)).toBeNull();
|
|
});
|
|
|
|
it('handles plain objects with code', () => {
|
|
expect(extractErrorCode({ code: 'NET_FAIL' })).toBe('NET_FAIL');
|
|
});
|
|
});
|