chore: release v1.0.5 (#10)
feat(projects): project management UI, sync countdown, fix project switching
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Sync countdown logic tests.
|
||||
*
|
||||
* The countdown in App.tsx works like this:
|
||||
* - scheduleCountdown() is called on every canvas onChange
|
||||
* - It records lastChangeTime = Date.now()
|
||||
* - 400ms after the LAST change (idle guard), a setInterval starts
|
||||
* - Interval ticks every 200ms, shows Math.ceil((lastChange + DEBOUNCE_MS - now) / 1000)
|
||||
* - Countdown clears when remaining <= 0 or when sync starts
|
||||
*
|
||||
* These tests simulate that logic with fake timers so we can verify the
|
||||
* exact behaviour without mounting React.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
const DEBOUNCE_MS = 3000;
|
||||
const IDLE_GUARD_MS = 400;
|
||||
const TICK_MS = 200;
|
||||
|
||||
// ── Pure simulation of the countdown mechanism ────────────────
|
||||
|
||||
interface CountdownSim {
|
||||
scheduleCountdown: () => void;
|
||||
cancelCountdown: () => void; // called when sync starts
|
||||
getCountdown: () => number | null;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function makeCountdownSim(): CountdownSim {
|
||||
let lastChangeTime = 0;
|
||||
let countdown: number | null = null;
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startTicking() {
|
||||
if (tickInterval) clearInterval(tickInterval);
|
||||
const initial = Math.ceil((lastChangeTime + DEBOUNCE_MS - Date.now()) / 1000);
|
||||
countdown = initial > 0 ? initial : null;
|
||||
tickInterval = setInterval(() => {
|
||||
const remaining = Math.ceil((lastChangeTime + DEBOUNCE_MS - Date.now()) / 1000);
|
||||
if (remaining <= 0) {
|
||||
clearInterval(tickInterval!);
|
||||
tickInterval = null;
|
||||
countdown = null;
|
||||
} else {
|
||||
countdown = remaining;
|
||||
}
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
function scheduleCountdown() {
|
||||
lastChangeTime = Date.now();
|
||||
// Reset idle guard — any new change pushes the idle window
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
// Hide countdown while actively drawing
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
countdown = null;
|
||||
// Show countdown only after IDLE_GUARD_MS of quiet
|
||||
idleTimer = setTimeout(startTicking, IDLE_GUARD_MS);
|
||||
}
|
||||
|
||||
function cancelCountdown() {
|
||||
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
||||
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
||||
countdown = null;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
cancelCountdown();
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleCountdown,
|
||||
cancelCountdown,
|
||||
getCountdown: () => countdown,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('sync countdown — idle guard', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('shows null while actively drawing (within idle guard window)', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
// Still within the 400ms idle guard
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS - 10);
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('starts showing countdown after idle guard passes', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBeGreaterThan(0);
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('resets idle guard on each new change — no countdown while drawing', () => {
|
||||
const sim = makeCountdownSim();
|
||||
|
||||
// Rapid changes every 100ms for 600ms total
|
||||
for (let i = 0; i < 6; i++) {
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(100);
|
||||
}
|
||||
// 600ms elapsed but idle guard resets each time — countdown still null
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
|
||||
// Now stop drawing; after idle guard the countdown appears
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBeGreaterThan(0);
|
||||
sim.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync countdown — tick behaviour', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('starts at DEBOUNCE_MS/1000 seconds after idle', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBe(DEBOUNCE_MS / 1000);
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('counts down and reaches null when debounce fires', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
|
||||
// Let idle guard pass + full debounce elapse
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + DEBOUNCE_MS + TICK_MS * 2);
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('passes through 3 → 2 → 1 without skipping', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
|
||||
const observed: (number | null)[] = [];
|
||||
// Sample countdown every second for 4 seconds after idle guard
|
||||
for (let s = 0; s <= 4; s++) {
|
||||
vi.advanceTimersByTime(s === 0 ? IDLE_GUARD_MS + TICK_MS : 1000);
|
||||
observed.push(sim.getCountdown());
|
||||
}
|
||||
|
||||
expect(observed).toContain(3);
|
||||
expect(observed).toContain(2);
|
||||
expect(observed).toContain(1);
|
||||
expect(observed[observed.length - 1]).toBeNull(); // cleared after 3s
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('never goes negative', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
// Advance well past debounce
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + DEBOUNCE_MS + 5000);
|
||||
const val = sim.getCountdown();
|
||||
expect(val === null || val > 0).toBe(true);
|
||||
sim.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync countdown — cancelCountdown (sync started)', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('cancels before idle guard fires', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(200); // still inside idle guard
|
||||
sim.cancelCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS * 5);
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('cancels after countdown has started', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS + 1000); // countdown showing 2
|
||||
expect(sim.getCountdown()).toBe(2);
|
||||
sim.cancelCountdown();
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('allows a new countdown cycle after cancel', () => {
|
||||
const sim = makeCountdownSim();
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS + 1000);
|
||||
sim.cancelCountdown(); // sync started
|
||||
|
||||
// User draws again after sync
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBe(DEBOUNCE_MS / 1000);
|
||||
sim.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync countdown — multiple change bursts', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('second burst after first sync resets correctly', () => {
|
||||
const sim = makeCountdownSim();
|
||||
|
||||
// First burst → sync → cancel
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + DEBOUNCE_MS + TICK_MS * 2);
|
||||
sim.cancelCountdown();
|
||||
|
||||
// Second burst
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
|
||||
expect(sim.getCountdown()).toBe(DEBOUNCE_MS / 1000);
|
||||
sim.cleanup();
|
||||
});
|
||||
|
||||
it('countdown stays null between burst end and idle guard', () => {
|
||||
const sim = makeCountdownSim();
|
||||
|
||||
// Two rapid changes 50ms apart
|
||||
sim.scheduleCountdown();
|
||||
vi.advanceTimersByTime(50);
|
||||
sim.scheduleCountdown();
|
||||
|
||||
// 300ms after last change — still inside idle guard
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(sim.getCountdown()).toBeNull();
|
||||
|
||||
// 400ms after last change — idle guard has passed
|
||||
vi.advanceTimersByTime(IDLE_GUARD_MS - 300 + TICK_MS);
|
||||
expect(sim.getCountdown()).toBeGreaterThan(0);
|
||||
sim.cleanup();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user