chore: release v1.0.5 (#10)
feat(projects): project management UI, sync countdown, fix project switching
This commit is contained in:
+215
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting, getCurrentSyncVersion, setActiveTenant } from '../../src/db.js';
|
||||
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting, getCurrentSyncVersion, setActiveTenant, getActiveProjectId, getElementCountForProject } from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
@@ -653,3 +653,217 @@ describe('Text alignment fields — REST round-trip regression', () => {
|
||||
expect(updateRes.body.element?.textAlign).toBe('center');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Projects ─────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/projects', () => {
|
||||
it('returns the default project and marks it active', async () => {
|
||||
const res = await request(app).get('/api/projects');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(Array.isArray(res.body.projects)).toBe(true);
|
||||
expect(res.body.projects.length).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body.activeProjectId).toBeTruthy();
|
||||
const active = res.body.projects.find((p: any) => p.id === res.body.activeProjectId);
|
||||
expect(active).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/projects', () => {
|
||||
it('creates a new project and returns it', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/projects')
|
||||
.send({ name: 'My Diagram', description: 'test desc' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.project.name).toBe('My Diagram');
|
||||
expect(res.body.project.description).toBe('test desc');
|
||||
expect(res.body.project.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns 400 when name is missing', async () => {
|
||||
const res = await request(app).post('/api/projects').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 400 when name is blank', async () => {
|
||||
const res = await request(app).post('/api/projects').send({ name: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('new project appears in GET /api/projects list', async () => {
|
||||
await request(app).post('/api/projects').send({ name: 'Alpha' });
|
||||
await request(app).post('/api/projects').send({ name: 'Beta' });
|
||||
const res = await request(app).get('/api/projects');
|
||||
const names = res.body.projects.map((p: any) => p.name);
|
||||
expect(names).toContain('Alpha');
|
||||
expect(names).toContain('Beta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/project/active', () => {
|
||||
it('switches the active project', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/projects')
|
||||
.send({ name: 'Switch Target' });
|
||||
const newId = created.body.project.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/project/active')
|
||||
.send({ projectId: newId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.project.id).toBe(newId);
|
||||
|
||||
// DB state reflects the switch
|
||||
expect(getActiveProjectId()).toBe(newId);
|
||||
});
|
||||
|
||||
it('returns 400 when projectId is missing', async () => {
|
||||
const res = await request(app).put('/api/project/active').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 400 for a non-existent projectId', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/project/active')
|
||||
.send({ projectId: 'does-not-exist' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Project switch preserves elements ──────────────────────
|
||||
|
||||
describe('Project switch round-trip — elements survive', () => {
|
||||
it('elements saved in project A persist after switching to B and back', async () => {
|
||||
// Create project "dude"
|
||||
const dudeRes = await request(app).post('/api/projects').send({ name: 'dude' });
|
||||
const dudeId = dudeRes.body.project.id;
|
||||
const defaultId = getActiveProjectId(); // save original
|
||||
|
||||
// Switch to "dude"
|
||||
await request(app).put('/api/project/active').send({ projectId: dudeId });
|
||||
expect(getActiveProjectId()).toBe(dudeId);
|
||||
|
||||
// Draw 2 elements in "dude"
|
||||
const el1 = makeElement({ id: 'dude-rect-1', type: 'rectangle', x: 10, y: 10, width: 100, height: 50 });
|
||||
const el2 = makeElement({ id: 'dude-rect-2', type: 'rectangle', x: 200, y: 200, width: 120, height: 80 });
|
||||
await request(app).post('/api/elements').send(el1);
|
||||
await request(app).post('/api/elements').send(el2);
|
||||
|
||||
// Verify 2 elements in "dude"
|
||||
const dudeElems1 = await request(app).get('/api/elements');
|
||||
expect(dudeElems1.body.elements.length).toBe(2);
|
||||
|
||||
// Switch to "default"
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
expect(getActiveProjectId()).toBe(defaultId);
|
||||
|
||||
// "default" should have 0 elements (fresh DB)
|
||||
const defaultElems = await request(app).get('/api/elements');
|
||||
expect(defaultElems.body.elements.length).toBe(0);
|
||||
|
||||
// Switch back to "dude"
|
||||
await request(app).put('/api/project/active').send({ projectId: dudeId });
|
||||
expect(getActiveProjectId()).toBe(dudeId);
|
||||
|
||||
// "dude" should still have the 2 elements
|
||||
const dudeElems2 = await request(app).get('/api/elements');
|
||||
expect(dudeElems2.body.elements.length).toBe(2);
|
||||
const ids = dudeElems2.body.elements.map((e: any) => e.id);
|
||||
expect(ids).toContain('dude-rect-1');
|
||||
expect(ids).toContain('dude-rect-2');
|
||||
});
|
||||
|
||||
it('elements in different projects are isolated', async () => {
|
||||
// Create two projects
|
||||
const projA = await request(app).post('/api/projects').send({ name: 'Project A' });
|
||||
const projB = await request(app).post('/api/projects').send({ name: 'Project B' });
|
||||
const aId = projA.body.project.id;
|
||||
const bId = projB.body.project.id;
|
||||
|
||||
// Add element to Project A
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
await request(app).post('/api/elements').send(
|
||||
makeElement({ id: 'a-only', type: 'ellipse', x: 0, y: 0, width: 50, height: 50 })
|
||||
);
|
||||
|
||||
// Add element to Project B
|
||||
await request(app).put('/api/project/active').send({ projectId: bId });
|
||||
await request(app).post('/api/elements').send(
|
||||
makeElement({ id: 'b-only', type: 'diamond', x: 0, y: 0, width: 50, height: 50 })
|
||||
);
|
||||
|
||||
// Verify isolation
|
||||
const bElems = await request(app).get('/api/elements');
|
||||
expect(bElems.body.elements.length).toBe(1);
|
||||
expect(bElems.body.elements[0].id).toBe('b-only');
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
const aElems = await request(app).get('/api/elements');
|
||||
expect(aElems.body.elements.length).toBe(1);
|
||||
expect(aElems.body.elements[0].id).toBe('a-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/projects/:id', () => {
|
||||
it('deletes a non-active project', async () => {
|
||||
const created = await request(app).post('/api/projects').send({ name: 'To Delete' });
|
||||
const id = created.body.project.id;
|
||||
|
||||
const res = await request(app).delete(`/api/projects/${id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.projectId).toBe(id);
|
||||
|
||||
const list = await request(app).get('/api/projects');
|
||||
const ids = list.body.projects.map((p: any) => p.id);
|
||||
expect(ids).not.toContain(id);
|
||||
});
|
||||
|
||||
it('cascades and deletes elements belonging to the project', async () => {
|
||||
const created = await request(app).post('/api/projects').send({ name: 'With Elements' });
|
||||
const id = created.body.project.id;
|
||||
|
||||
// Switch to new project and add an element
|
||||
await request(app).put('/api/project/active').send({ projectId: id });
|
||||
await request(app).post('/api/elements').send({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
|
||||
expect(getElementCountForProject(id)).toBe(1);
|
||||
|
||||
// Switch back to default before deleting
|
||||
const defaultId = getActiveProjectId() === id
|
||||
? (await request(app).get('/api/projects')).body.projects.find((p: any) => p.id !== id)?.id
|
||||
: getActiveProjectId();
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
|
||||
await request(app).delete(`/api/projects/${id}`);
|
||||
expect(getElementCountForProject(id)).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses to delete the active project', async () => {
|
||||
// Create a second project so the "last project" guard doesn't fire first
|
||||
await request(app).post('/api/projects').send({ name: 'Second' });
|
||||
const activeId = getActiveProjectId();
|
||||
const res = await request(app).delete(`/api/projects/${activeId}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/active/);
|
||||
});
|
||||
|
||||
it('refuses to delete the last project', async () => {
|
||||
// Only default project exists — try to delete it (it is also active, so both guards fire)
|
||||
const activeId = getActiveProjectId();
|
||||
const res = await request(app).delete(`/api/projects/${activeId}`);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 400 for a non-existent project', async () => {
|
||||
const res = await request(app).delete('/api/projects/ghost-id');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* End-to-end tests for project switching.
|
||||
*
|
||||
* Exercises the full HTTP stack: create projects → add elements → switch →
|
||||
* verify elements are isolated per project and survive round-trips.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-e2e-project-switch-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
function rect(id: string, x = 0, y = 0) {
|
||||
return { id, type: 'rectangle', x, y, width: 100, height: 60, version: 1 };
|
||||
}
|
||||
|
||||
// ─── E2E: draw in project, switch away, switch back ─────────
|
||||
|
||||
describe('E2E: project switch round-trip', () => {
|
||||
it('draw 2 elements in "dude", switch to default, switch back — elements preserved', async () => {
|
||||
// 1. Create project "dude"
|
||||
const createRes = await request(app).post('/api/projects').send({ name: 'dude' });
|
||||
expect(createRes.status).toBe(201);
|
||||
const dudeId = createRes.body.project.id;
|
||||
|
||||
// Remember default project id
|
||||
const listBefore = await request(app).get('/api/projects');
|
||||
const defaultProject = listBefore.body.projects.find((p: any) => p.name === 'Default');
|
||||
expect(defaultProject).toBeDefined();
|
||||
const defaultId = defaultProject.id;
|
||||
|
||||
// 2. Switch to "dude"
|
||||
const switchRes = await request(app).put('/api/project/active').send({ projectId: dudeId });
|
||||
expect(switchRes.status).toBe(200);
|
||||
|
||||
// 3. Draw 2 rectangles in "dude"
|
||||
const r1 = await request(app).post('/api/elements').send(rect('dude-box-1', 10, 10));
|
||||
const r2 = await request(app).post('/api/elements').send(rect('dude-box-2', 200, 200));
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
|
||||
// Verify 2 elements present
|
||||
const dudeCheck1 = await request(app).get('/api/elements');
|
||||
expect(dudeCheck1.body.elements.length).toBe(2);
|
||||
|
||||
// 4. Switch to "default"
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
|
||||
// Default should be empty
|
||||
const defaultCheck = await request(app).get('/api/elements');
|
||||
expect(defaultCheck.body.elements.length).toBe(0);
|
||||
|
||||
// 5. Switch back to "dude"
|
||||
await request(app).put('/api/project/active').send({ projectId: dudeId });
|
||||
|
||||
// 6. Verify both elements are still there
|
||||
const dudeCheck2 = await request(app).get('/api/elements');
|
||||
expect(dudeCheck2.body.elements.length).toBe(2);
|
||||
const ids = dudeCheck2.body.elements.map((e: any) => e.id);
|
||||
expect(ids).toContain('dude-box-1');
|
||||
expect(ids).toContain('dude-box-2');
|
||||
});
|
||||
|
||||
it('multiple switches do not leak elements between projects', async () => {
|
||||
// Create 3 projects
|
||||
const pA = await request(app).post('/api/projects').send({ name: 'Alpha' });
|
||||
const pB = await request(app).post('/api/projects').send({ name: 'Bravo' });
|
||||
const pC = await request(app).post('/api/projects').send({ name: 'Charlie' });
|
||||
const aId = pA.body.project.id;
|
||||
const bId = pB.body.project.id;
|
||||
const cId = pC.body.project.id;
|
||||
|
||||
// Add 1 element to each
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
await request(app).post('/api/elements').send(rect('alpha-el'));
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: bId });
|
||||
await request(app).post('/api/elements').send(rect('bravo-el'));
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: cId });
|
||||
await request(app).post('/api/elements').send(rect('charlie-el'));
|
||||
|
||||
// Rapid switching: C → A → B → A → C
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
await request(app).put('/api/project/active').send({ projectId: bId });
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
await request(app).put('/api/project/active').send({ projectId: cId });
|
||||
|
||||
// Verify each project has exactly its own element
|
||||
await request(app).put('/api/project/active').send({ projectId: aId });
|
||||
const aElems = await request(app).get('/api/elements');
|
||||
expect(aElems.body.elements.length).toBe(1);
|
||||
expect(aElems.body.elements[0].id).toBe('alpha-el');
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: bId });
|
||||
const bElems = await request(app).get('/api/elements');
|
||||
expect(bElems.body.elements.length).toBe(1);
|
||||
expect(bElems.body.elements[0].id).toBe('bravo-el');
|
||||
|
||||
await request(app).put('/api/project/active').send({ projectId: cId });
|
||||
const cElems = await request(app).get('/api/elements');
|
||||
expect(cElems.body.elements.length).toBe(1);
|
||||
expect(cElems.body.elements[0].id).toBe('charlie-el');
|
||||
});
|
||||
|
||||
it('updating an element in one project does not affect another', async () => {
|
||||
const pX = await request(app).post('/api/projects').send({ name: 'ProjX' });
|
||||
const xId = pX.body.project.id;
|
||||
const listRes = await request(app).get('/api/projects');
|
||||
const defaultId = listRes.body.projects.find((p: any) => p.name === 'Default').id;
|
||||
|
||||
// Add element to default
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
await request(app).post('/api/elements').send(rect('def-rect', 0, 0));
|
||||
|
||||
// Add element to ProjX
|
||||
await request(app).put('/api/project/active').send({ projectId: xId });
|
||||
await request(app).post('/api/elements').send(rect('x-rect', 0, 0));
|
||||
|
||||
// Update element in ProjX
|
||||
const updateRes = await request(app).put('/api/elements/x-rect').send({ x: 999, y: 999 });
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(updateRes.body.success).toBe(true);
|
||||
|
||||
// Verify ProjX has updated coords
|
||||
const xElems = await request(app).get('/api/elements');
|
||||
expect(xElems.body.elements).toHaveLength(1);
|
||||
expect(xElems.body.elements[0].x).toBe(999);
|
||||
|
||||
// Verify Default still has original coords
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
const defElems = await request(app).get('/api/elements');
|
||||
expect(defElems.body.elements[0].x).toBe(0);
|
||||
});
|
||||
|
||||
it('deleting an element in one project does not affect another', async () => {
|
||||
const pY = await request(app).post('/api/projects').send({ name: 'ProjY' });
|
||||
const yId = pY.body.project.id;
|
||||
const listRes = await request(app).get('/api/projects');
|
||||
const defaultId = listRes.body.projects.find((p: any) => p.name === 'Default').id;
|
||||
|
||||
// Add element to default
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
await request(app).post('/api/elements').send(rect('def-del', 50, 50));
|
||||
|
||||
// Add element to ProjY
|
||||
await request(app).put('/api/project/active').send({ projectId: yId });
|
||||
await request(app).post('/api/elements').send(rect('y-del', 50, 50));
|
||||
|
||||
// Delete from ProjY
|
||||
await request(app).delete('/api/elements/y-del');
|
||||
|
||||
// ProjY: 0 elements
|
||||
const yElems = await request(app).get('/api/elements');
|
||||
expect(yElems.body.elements.length).toBe(0);
|
||||
|
||||
// Default: still has its element
|
||||
await request(app).put('/api/project/active').send({ projectId: defaultId });
|
||||
const defElems = await request(app).get('/api/elements');
|
||||
expect(defElems.body.elements.length).toBe(1);
|
||||
expect(defElems.body.elements[0].id).toBe('def-del');
|
||||
});
|
||||
});
|
||||
@@ -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