Zum Inhalt

tests/ui/respawnCoordinator.test.ts

// 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']);
  });
});