// Behavioural tests for the respawn coordinator. Verifies that each path
// (nonce, preset switch, seed-param debounce) coalesces correctly and that
// non-seed param edits do NOT cause a respawn (regression for the bug
// where editing moveSpeed re-seeded agents after 250 ms).
import { createRoot } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BUILT_IN_PRESETS, DEFAULT_PRESET } from '~/presets/builtIn';
import {
presetState,
resetEdited,
selectPreset,
updateEdited,
updateSpecies,
} from '~/stores/presetStore';
import { triggerRespawn } from '~/stores/uiStore';
import { createRespawnCoordinator } from '~/ui/respawnCoordinator';
const fakeLocalStorage = () => {
const store = new Map<string, string>();
return {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => {
store.set(k, v);
},
removeItem: (k: string) => {
store.delete(k);
},
clear: () => store.clear(),
key: () => null,
length: 0,
};
};
const otherPreset = () => {
const p = BUILT_IN_PRESETS.find((x) => x.id !== DEFAULT_PRESET.id);
if (!p) throw new Error('test setup: need a second built-in preset');
return p;
};
describe('respawn coordinator', () => {
let respawnCount = 0;
let dispose: (() => void) | null = null;
beforeEach(() => {
vi.stubGlobal('localStorage', fakeLocalStorage());
vi.useFakeTimers();
selectPreset(DEFAULT_PRESET);
respawnCount = 0;
dispose = createRoot((d) => {
createRespawnCoordinator({
performRespawn: () => {
respawnCount += 1;
},
});
return d;
});
});
afterEach(() => {
dispose?.();
dispose = null;
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('does NOT respawn when a non-seed species param (moveSpeed) is edited', () => {
updateSpecies(0, 'moveSpeed', 5);
vi.advanceTimersByTime(500);
expect(respawnCount).toBe(0);
});
it('does NOT respawn on edits to other non-seed params (decayRate, exposure)', () => {
updateEdited('decayRate', 0.5);
updateEdited('exposure', 2);
vi.advanceTimersByTime(500);
expect(respawnCount).toBe(0);
});
it('debounces a respawn when populationShare is edited', () => {
updateSpecies(0, 'populationShare', 0.5);
vi.advanceTimersByTime(200);
expect(respawnCount).toBe(0);
vi.advanceTimersByTime(100);
expect(respawnCount).toBe(1);
});
it('debounces a respawn when agentCount is edited', () => {
updateEdited('agentCount', 50000);
vi.advanceTimersByTime(250);
expect(respawnCount).toBe(1);
});
it('coalesces rapid edits into one respawn', () => {
updateEdited('agentCount', 50000);
vi.advanceTimersByTime(100);
updateEdited('agentCount', 60000);
vi.advanceTimersByTime(100);
updateEdited('agentCount', 70000);
vi.advanceTimersByTime(250);
expect(respawnCount).toBe(1);
});
it('respawns exactly once on preset switch (no double-respawn from debounce)', () => {
selectPreset(otherPreset());
expect(respawnCount).toBe(1);
vi.advanceTimersByTime(500);
expect(respawnCount).toBe(1);
});
it('respawns immediately on triggerRespawn (no debounce)', () => {
triggerRespawn();
expect(respawnCount).toBe(1);
});
it('cancels a pending debounce when triggerRespawn fires', () => {
updateEdited('agentCount', 50000);
vi.advanceTimersByTime(100);
expect(respawnCount).toBe(0);
triggerRespawn();
expect(respawnCount).toBe(1);
vi.advanceTimersByTime(500);
expect(respawnCount).toBe(1);
});
it('respawns exactly once on resetEdited (only the nonce path fires)', () => {
updateEdited('agentCount', 50000);
vi.advanceTimersByTime(100);
expect(respawnCount).toBe(0);
resetEdited();
expect(respawnCount).toBe(1);
vi.advanceTimersByTime(500);
expect(respawnCount).toBe(1);
});
it('coalesces a pending debounce when preset is switched mid-edit', () => {
updateEdited('agentCount', 50000);
vi.advanceTimersByTime(100);
expect(respawnCount).toBe(0);
selectPreset(otherPreset());
expect(respawnCount).toBe(1);
vi.advanceTimersByTime(500);
expect(respawnCount).toBe(1);
});
it('fires onPresetSwitch before performRespawn on preset switch', () => {
const events: string[] = [];
dispose?.();
dispose = createRoot((d) => {
createRespawnCoordinator({
performRespawn: () => events.push('respawn'),
onPresetSwitch: () => events.push('switch'),
});
return d;
});
selectPreset(otherPreset());
expect(events).toEqual(['switch', 'respawn']);
});
});