import { describe, expect, it, vi } from 'vitest'; import { pendingScaleConfirmed, retrySimPost, SIM_POST_MAX_ATTEMPTS, SIM_POST_RETRY_DELAY_MS } from './LeftPane'; // ISS-001 — the optimistic slider value is cleared exactly when a rendered snapshot confirms // it (server scale === pending), never earlier. Clearing at commit time would snap the pointer // back to the stale scale for one poll cycle, then jump forward again on the confirming snapshot. describe('pendingScaleConfirmed', () => { it('is false while there is no pending value', () => { expect(pendingScaleConfirmed(null, 1)).toBe(false); expect(pendingScaleConfirmed(null, 64)).toBe(false); }); it('stays false while the server still reports the stale scale (the ISS-001 window)', () => { // User committed 64×; the last rendered snapshot is still at 1× → must NOT clear yet. expect(pendingScaleConfirmed(64, 1)).toBe(false); expect(pendingScaleConfirmed(32, 8)).toBe(false); }); it('becomes true exactly when a snapshot carries the pending scale', () => { expect(pendingScaleConfirmed(64, 64)).toBe(true); expect(pendingScaleConfirmed(1, 1)).toBe(true); // committing the current value confirms immediately }); }); // ISS-008 fix (CI runs #44/#47): a dropped POST /api/sim must be retried before surfacing the // error — up to SIM_POST_MAX_ATTEMPTS tries with SIM_POST_RETRY_DELAY_MS between them, and a newer // intent supersedes the in-flight body instead of waiting out its retries. describe('retrySimPost', () => { it('pins the retry budget: 3 attempts total, ~500 ms backoff', () => { expect(SIM_POST_MAX_ATTEMPTS).toBe(3); expect(SIM_POST_RETRY_DELAY_MS).toBe(500); }); it('succeeds on the first attempt without sleeping or retrying', async () => { const post = vi.fn(async () => {}); const sleep = vi.fn(async () => {}); await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true }); expect(post).toHaveBeenCalledTimes(1); expect(sleep).not.toHaveBeenCalled(); }); it('retries with the backoff delay and succeeds on a later attempt', async () => { let calls = 0; const post = vi.fn(async () => { if (++calls < 3) throw new Error('dropped'); }); const sleep = vi.fn(async () => {}); await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true }); expect(post).toHaveBeenCalledTimes(3); expect(sleep).toHaveBeenCalledTimes(2); // one backoff between each pair of attempts expect(sleep).toHaveBeenCalledWith(SIM_POST_RETRY_DELAY_MS); }); it('gives up after the final attempt with the last error (no trailing sleep)', async () => { const err = new Error('down'); const post = vi.fn(async () => { throw err; }); const sleep = vi.fn(async () => {}); const result = await retrySimPost(post, () => false, sleep); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe('exhausted'); if (result.reason === 'exhausted') expect(result.error).toBe(err); // the LAST attempt's error } expect(post).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS); expect(sleep).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS - 1); }); it('stops retrying once a newer intent supersedes the in-flight body (no further attempts or sleeps)', async () => { let calls = 0; const post = vi.fn(async () => { calls++; // flip "superseded" right after the first failure, like a queued commitNow would throw new Error('dropped'); }); const sleep = vi.fn(async () => {}); await expect(retrySimPost(post, () => calls >= 1, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' }); expect(post).toHaveBeenCalledTimes(1); // the caller's loop sends the newer body instead expect(sleep).not.toHaveBeenCalled(); // supersede is checked before the backoff sleep }); it('never even sends a body that is already superseded', async () => { const post = vi.fn(async () => {}); const sleep = vi.fn(async () => {}); await expect(retrySimPost(post, () => true, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' }); expect(post).not.toHaveBeenCalled(); expect(sleep).not.toHaveBeenCalled(); }); });