feat(desktop): add RecoveryStateStore with subscriber lifecycle
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { RecoveryStateStore, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export type RecoveryReasonCode =
|
||||||
|
| 'load-failed'
|
||||||
|
| 'render-gone'
|
||||||
|
| 'unresponsive'
|
||||||
|
| 'renderer-stalled';
|
||||||
|
|
||||||
|
export type UpdateState =
|
||||||
|
| 'idle'
|
||||||
|
| 'checking'
|
||||||
|
| 'downloading'
|
||||||
|
| 'downloaded'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export interface RecoveryState {
|
||||||
|
mode: 'normal' | 'recovery';
|
||||||
|
reason: { code: RecoveryReasonCode; detail: string } | null;
|
||||||
|
updateState: UpdateState;
|
||||||
|
updateVersion: string | null;
|
||||||
|
lastUpdateError: { message: string; code: string | null; at: number } | null;
|
||||||
|
lastCheckResult: 'up-to-date' | 'failed' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: RecoveryState = {
|
||||||
|
mode: 'normal',
|
||||||
|
reason: null,
|
||||||
|
updateState: 'idle',
|
||||||
|
updateVersion: null,
|
||||||
|
lastUpdateError: null,
|
||||||
|
lastCheckResult: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RecoveryStateStore {
|
||||||
|
private state: RecoveryState = { ...INITIAL_STATE };
|
||||||
|
private listeners = new Set<(s: RecoveryState) => void>();
|
||||||
|
private inRecoveryMode = false;
|
||||||
|
|
||||||
|
get(): Readonly<RecoveryState> {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(partial: Partial<RecoveryState>): void {
|
||||||
|
this.state = { ...this.state, ...partial };
|
||||||
|
for (const cb of this.listeners) cb(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(cb: (s: RecoveryState) => void): () => void {
|
||||||
|
this.listeners.add(cb);
|
||||||
|
return () => { this.listeners.delete(cb); };
|
||||||
|
}
|
||||||
|
|
||||||
|
isInRecoveryMode(): boolean {
|
||||||
|
return this.inRecoveryMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
markRecoveryEntered(): void {
|
||||||
|
this.inRecoveryMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
markRecoveryExited(): void {
|
||||||
|
this.inRecoveryMode = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recoveryStore = new RecoveryStateStore();
|
||||||
Reference in New Issue
Block a user