245 lines
8.4 KiB
TypeScript
245 lines
8.4 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { useTimerStore } from '@/store/timerStore';
|
|
|
|
describe('timerStore', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
useTimerStore.setState({
|
|
currentRate: 50,
|
|
running: false,
|
|
runStartedAt: null,
|
|
accumulatedMs: 0,
|
|
splits: [],
|
|
lastHeartbeat: Date.now(),
|
|
elapsedMs: 0,
|
|
crashRecovery: null,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('starts and tracks elapsed time', () => {
|
|
const s = useTimerStore.getState();
|
|
s.start();
|
|
expect(useTimerStore.getState().running).toBe(true);
|
|
vi.advanceTimersByTime(5000);
|
|
useTimerStore.getState()._tick();
|
|
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(5000);
|
|
});
|
|
|
|
it('pause freezes elapsed and stops ticking', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(3000);
|
|
useTimerStore.getState().pause();
|
|
const frozen = useTimerStore.getState().elapsedMs;
|
|
expect(useTimerStore.getState().running).toBe(false);
|
|
vi.advanceTimersByTime(10000);
|
|
expect(useTimerStore.getState().elapsedMs).toBe(frozen);
|
|
});
|
|
|
|
it('resume after pause accumulates correctly', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(2000);
|
|
useTimerStore.getState().pause();
|
|
vi.advanceTimersByTime(5000); // paused time doesn't count
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(3000);
|
|
useTimerStore.getState()._tick();
|
|
const elapsed = useTimerStore.getState().elapsedMs;
|
|
expect(elapsed).toBeGreaterThanOrEqual(5000);
|
|
expect(elapsed).toBeLessThan(7000); // not 10000
|
|
});
|
|
|
|
it('split records current segment and resets clock', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(4000);
|
|
const split = useTimerStore.getState().split('task A');
|
|
|
|
expect(split.elapsedMs).toBeGreaterThanOrEqual(4000);
|
|
expect(split.rate).toBe(50);
|
|
expect(split.label).toBe('task A');
|
|
expect(split.recorded).toBe(false);
|
|
expect(useTimerStore.getState().splits).toHaveLength(1);
|
|
// Clock resets but keeps running
|
|
expect(useTimerStore.getState().accumulatedMs).toBe(0);
|
|
expect(useTimerStore.getState().running).toBe(true);
|
|
});
|
|
|
|
it('split preserves rate at time of split (rate change after doesn\'t affect)', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(1000);
|
|
const split = useTimerStore.getState().split();
|
|
expect(split.rate).toBe(50);
|
|
|
|
useTimerStore.getState().setRate(100);
|
|
expect(useTimerStore.getState().splits[0].rate).toBe(50);
|
|
expect(useTimerStore.getState().currentRate).toBe(100);
|
|
});
|
|
|
|
it('setRate only affects live clock, not existing splits', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(1000);
|
|
useTimerStore.getState().split();
|
|
useTimerStore.getState().split();
|
|
useTimerStore.getState().setRate(999);
|
|
expect(useTimerStore.getState().splits.every((s) => s.rate === 50)).toBe(true);
|
|
});
|
|
|
|
it('markRecorded sets flag and stores work entry id', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(1000);
|
|
const split = useTimerStore.getState().split();
|
|
useTimerStore.getState().markRecorded(split.id, 'we-123');
|
|
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
|
|
expect(updated.recorded).toBe(true);
|
|
expect(updated.recordedWorkEntryId).toBe('we-123');
|
|
});
|
|
|
|
it('updateSplit modifies fields', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(1000);
|
|
const split = useTimerStore.getState().split();
|
|
useTimerStore.getState().updateSplit(split.id, { label: 'new label', elapsedMs: 9999 });
|
|
const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!;
|
|
expect(updated.label).toBe('new label');
|
|
expect(updated.elapsedMs).toBe(9999);
|
|
});
|
|
|
|
it('deleteSplit removes from table', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(1000);
|
|
const split = useTimerStore.getState().split();
|
|
useTimerStore.getState().deleteSplit(split.id);
|
|
expect(useTimerStore.getState().splits).toHaveLength(0);
|
|
});
|
|
|
|
it('reset clears everything', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(1000);
|
|
useTimerStore.getState().split();
|
|
useTimerStore.getState().reset();
|
|
expect(useTimerStore.getState().running).toBe(false);
|
|
expect(useTimerStore.getState().elapsedMs).toBe(0);
|
|
expect(useTimerStore.getState().splits).toHaveLength(0);
|
|
});
|
|
|
|
it('persists state to cookie on tick', () => {
|
|
useTimerStore.getState().start();
|
|
vi.advanceTimersByTime(1500);
|
|
useTimerStore.getState()._tick();
|
|
expect(document.cookie).toContain('t99_timer');
|
|
});
|
|
});
|
|
|
|
describe('timerStore crash recovery', () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('detects crash when heartbeat stale + running', () => {
|
|
const now = Date.now();
|
|
const staleSnapshot = {
|
|
currentRate: 75,
|
|
running: true,
|
|
runStartedAt: now - 60000, // started 1 min ago
|
|
accumulatedMs: 0,
|
|
splits: [],
|
|
lastHeartbeat: now - 30000, // last seen 30s ago (> 5s threshold)
|
|
};
|
|
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(staleSnapshot))}; path=/`;
|
|
|
|
useTimerStore.setState({ crashRecovery: null });
|
|
useTimerStore.getState()._restoreFromCookie();
|
|
|
|
const cr = useTimerStore.getState().crashRecovery;
|
|
expect(cr).not.toBeNull();
|
|
expect(cr!.gapMs).toBeGreaterThan(25000);
|
|
expect(cr!.crashTime).toBe(now - 30000);
|
|
});
|
|
|
|
it('does NOT flag crash when paused', () => {
|
|
const now = Date.now();
|
|
const snapshot = {
|
|
currentRate: 50,
|
|
running: false,
|
|
runStartedAt: null,
|
|
accumulatedMs: 120000,
|
|
splits: [],
|
|
lastHeartbeat: now - 60000,
|
|
};
|
|
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
|
|
|
|
useTimerStore.setState({ crashRecovery: null });
|
|
useTimerStore.getState()._restoreFromCookie();
|
|
expect(useTimerStore.getState().crashRecovery).toBeNull();
|
|
});
|
|
|
|
it('restores elapsed time from original start (includes gap)', () => {
|
|
const now = Date.now();
|
|
const snapshot = {
|
|
currentRate: 50,
|
|
running: true,
|
|
runStartedAt: now - 120000, // 2 min ago
|
|
accumulatedMs: 0,
|
|
splits: [],
|
|
lastHeartbeat: now - 60000,
|
|
};
|
|
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
|
|
|
|
useTimerStore.getState()._restoreFromCookie();
|
|
// Clock should show ~2 minutes (original start was preserved)
|
|
expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(120000);
|
|
});
|
|
|
|
it('subtractCrashGap removes gap from accumulated time', () => {
|
|
useTimerStore.setState({
|
|
running: false,
|
|
runStartedAt: null,
|
|
accumulatedMs: 100000,
|
|
crashRecovery: { crashTime: 0, reloadTime: 30000, gapMs: 30000 },
|
|
elapsedMs: 100000,
|
|
});
|
|
useTimerStore.getState().subtractCrashGap();
|
|
expect(useTimerStore.getState().accumulatedMs).toBe(70000);
|
|
expect(useTimerStore.getState().crashRecovery).toBeNull();
|
|
});
|
|
|
|
it('subtractCrashGap clamps to zero', () => {
|
|
useTimerStore.setState({
|
|
accumulatedMs: 5000,
|
|
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 99999 },
|
|
});
|
|
useTimerStore.getState().subtractCrashGap();
|
|
expect(useTimerStore.getState().accumulatedMs).toBe(0);
|
|
});
|
|
|
|
it('dismissCrashBanner keeps time but removes banner', () => {
|
|
useTimerStore.setState({
|
|
accumulatedMs: 100000,
|
|
crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 30000 },
|
|
});
|
|
useTimerStore.getState().dismissCrashBanner();
|
|
expect(useTimerStore.getState().crashRecovery).toBeNull();
|
|
expect(useTimerStore.getState().accumulatedMs).toBe(100000); // unchanged
|
|
});
|
|
|
|
it('restores splits from cookie', () => {
|
|
const snapshot = {
|
|
currentRate: 50,
|
|
running: false,
|
|
runStartedAt: null,
|
|
accumulatedMs: 0,
|
|
splits: [
|
|
{ id: 's1', startedAt: 0, elapsedMs: 60000, rate: 50, recorded: false },
|
|
{ id: 's2', startedAt: 0, elapsedMs: 30000, rate: 75, recorded: true, recordedWorkEntryId: 'we-1' },
|
|
],
|
|
lastHeartbeat: Date.now(),
|
|
};
|
|
document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`;
|
|
useTimerStore.getState()._restoreFromCookie();
|
|
expect(useTimerStore.getState().splits).toHaveLength(2);
|
|
expect(useTimerStore.getState().splits[1].recorded).toBe(true);
|
|
});
|
|
});
|