Zum Inhalt

tests/screensaver-params.test.ts

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { applyParamOverrides } from '../src/screensaver/params';
import { DEFAULT_SCREENSAVER, HUD_PRESETS, type ScreensaverSettings } from '../src/engine/data/defaults';

// The native OS-screensaver hosts (Windows .scr / macOS) cannot show the web
// control bar — any input exits the saver — so persistent settings are bridged
// in as URL query params instead. `applyParamOverrides` is the pure seam that
// maps those params onto the merged settings tree. These tests pin that mapping.

function fresh(): ScreensaverSettings {
  return structuredClone(DEFAULT_SCREENSAVER);
}
function apply(query: string): ScreensaverSettings {
  const s = fresh();
  applyParamOverrides(s, new URLSearchParams(query));
  return s;
}

describe('applyParamOverrides', () => {
  it('leaves every setting at its default when no params are present', () => {
    expect(apply('')).toEqual(DEFAULT_SCREENSAVER);
  });

  it('sets a valid speed enum and ignores an invalid one', () => {
    expect(apply('speed=fast').speed).toBe('fast');
    expect(apply('speed=slow').speed).toBe('slow');
    expect(apply('speed=ludicrous').speed).toBe(DEFAULT_SCREENSAVER.speed); // unchanged
  });

  it('toggles fx.bloom via on/off', () => {
    expect(apply('bloom=off').fx.bloom.on).toBe(false);
    expect(apply('bloom=on').fx.bloom.on).toBe(true);
  });

  it('pins the engine seed via seed=<int> and ignores malformed values', () => {
    expect(apply('seed=7').seedLock).toBe(7);
    expect(apply('seed=0').seedLock).toBe(0);
    expect(apply('seed=abc').seedLock).toBe(DEFAULT_SCREENSAVER.seedLock);
    expect(apply('seed=1.5').seedLock).toBe(DEFAULT_SCREENSAVER.seedLock);
    expect(apply('').seedLock).toBeNull();
  });

  it('selects a named HUD preset via hud=<name> and ignores unknown names', () => {
    expect(apply('hud=off').hudPreset).toBe('off');
    expect(apply('hud=minimal').hudPreset).toBe('minimal');
    expect(apply('hud=bogus').hudPreset).toBe(DEFAULT_SCREENSAVER.hudPreset);
    // a named preset wins over the single toggles: controller.open() re-applies it
    expect(apply('hud=off&terminal=on').hudPreset).toBe('off');
  });

  it('switches the radar ping off via ping=off (a render for a composition must not show it)', () => {
    expect(apply('ping=off').fx.radarPing.on).toBe(false);
    expect(apply('ping=on').fx.radarPing.on).toBe(true);
    expect(apply('').fx.radarPing.on).toBe(DEFAULT_SCREENSAVER.fx.radarPing.on);
  });

  it('toggles fx.trails and fx.scan', () => {
    expect(apply('trails=on').fx.trails.on).toBe(true);
    expect(apply('scan=off').fx.scan.on).toBe(false);
  });

  it('toggles the crtSim aggregate via crt=', () => {
    expect(apply('crt=off').crtSim.on).toBe(false);
    expect(apply('crt=on').crtSim.on).toBe(true);
  });

  it('toggles fx.matrix via matrix= (accepts on and 1)', () => {
    expect(apply('matrix=on').fx.matrix.on).toBe(true);
    expect(apply('matrix=1').fx.matrix.on).toBe(true);
    expect(apply('matrix=off').fx.matrix.on).toBe(false);
  });

  it('toggles hud.terminal, hud.radar and hud.crosshair', () => {
    expect(apply('terminal=off').hud.terminal).toBe(false);
    expect(apply('radar=off').hud.radar).toBe(false);
    expect(apply('crosshair=off').hud.crosshair).toBe(false);
  });

  it('accepts 1/0 as boolean aliases and leaves unknown values unchanged', () => {
    expect(apply('bloom=0').fx.bloom.on).toBe(false);
    expect(apply('bloom=1').fx.bloom.on).toBe(true);
    expect(apply('bloom=maybe').fx.bloom.on).toBe(DEFAULT_SCREENSAVER.fx.bloom.on); // unchanged
  });

  it('applies several params together without touching unrelated settings', () => {
    const s = apply('speed=fast&bloom=off&crt=off&radar=off&matrix=on');
    expect(s.speed).toBe('fast');
    expect(s.fx.bloom.on).toBe(false);
    expect(s.crtSim.on).toBe(false);
    expect(s.hud.radar).toBe(false);
    expect(s.fx.matrix.on).toBe(true);
    // untouched
    expect(s.fx.trails.on).toBe(DEFAULT_SCREENSAVER.fx.trails.on);
    expect(s.hud.terminal).toBe(DEFAULT_SCREENSAVER.hud.terminal);
    expect(s.defaultScene).toBe(DEFAULT_SCREENSAVER.defaultScene);
  });
});

// Mirror of ScreensaverController.applyHudPreset (controller.ts:90-95). The engine
// re-applies the named HUD preset over s.hud at open() time unless hudPreset is
// 'custom'. With the default 'tactical' preset this used to clobber the
// ?terminal/?radar/?crosshair params right back on. We can't import the controller
// in a node test (it pulls THREE/DOM), so we mirror its trivial logic against the
// REAL exported HUD_PRESETS — the inline-oracle pattern used elsewhere in this repo.
function applyHudPreset(s: ScreensaverSettings): ScreensaverSettings {
  if (s.hudPreset === 'custom') return s;
  const p = HUD_PRESETS[s.hudPreset];
  if (p) Object.assign(s.hud, p);
  return s;
}

describe('applyParamOverrides — HUD params survive the engine preset', () => {
  it('switches hudPreset to "custom" when any HUD flag is overridden', () => {
    expect(apply('terminal=off').hudPreset).toBe('custom');
    expect(apply('radar=off').hudPreset).toBe('custom');
    expect(apply('crosshair=off').hudPreset).toBe('custom');
  });

  it('leaves hudPreset untouched when no HUD flag is present', () => {
    expect(apply('bloom=off&speed=fast').hudPreset).toBe(DEFAULT_SCREENSAVER.hudPreset);
    expect(apply('').hudPreset).toBe(DEFAULT_SCREENSAVER.hudPreset);
  });

  it('keeps ?terminal=off/?radar=off/?crosshair=off after applyHudPreset runs (regression)', () => {
    const s = apply('terminal=off&radar=off&crosshair=off');
    applyHudPreset(s); // the engine step that previously overwrote these
    expect(s.hud.terminal).toBe(false);
    expect(s.hud.radar).toBe(false);
    expect(s.hud.crosshair).toBe(false);
    expect(s.hud.vaultKanji).toBe(true); // non-overridden field stays at the tactical/default value
  });
});

// v0.10 — the Windows host bridges the FULL settings dialog through the query
// string (keys 13–33 of the pinned contract in the v0.10 implementation plan).
describe('applyParamOverrides — v0.10 keys', () => {
  it('maps enums with whitelist validation', () => {
    expect(apply('altitude=high').cityAltitude).toBe('high');
    expect(apply('altitude=orbit').cityAltitude).toBe(DEFAULT_SCREENSAVER.cityAltitude);
    expect(apply('fog=dense').fogMode).toBe('dense');
    expect(apply('weather=storm').weather).toBe('storm');
    expect(apply('weather=sharknado').weather).toBe(DEFAULT_SCREENSAVER.weather);
    expect(apply('bootspeed=cinematic').bootSpeed).toBe('cinematic');
    expect(apply('termlayout=window').terminalLayout).toBe('center-window');
    expect(apply('termlayout=strip').terminalLayout).toBe('bottom-strip');
  });
  it('maps bools', () => {
    expect(apply('reactive=off').narrativeReactiveWorld).toBe(false);
    expect(apply('autocycle=off').autoCycle.on).toBe(false);
    expect(apply('boot=off').bootEnabled).toBe(false);
    expect(apply('daynight=off').dayNightCycle.on).toBe(false);
    // NOTE: only the param MAPPING is pinned here. The plain-browser default
    // (absent ?perfadapt → perfAdapt=false, e9e2719 regression guard) is entry
    // logic in src/screensaver/main.ts, deliberately outside this pure seam.
    expect(apply('perfadapt=off').perfAdapt).toBe(false);
  });
  it('maps clamped numerics from raw strings', () => {
    expect(apply('bank=2').bankStrength).toBe(2);
    expect(apply('bank=9').bankStrength).toBe(2);          // clamp
    expect(apply('cyclemin=0.5').autoCycle.intervalMin).toBe(0.5);
    expect(apply('crtintensity=0.85').crtSim.intensity).toBe(0.85);
    expect(apply('curvature=0.022').fx.curvature).toEqual({ on: true, amount: 0.022 });
    expect(apply('curvature=0').fx.curvature.on).toBe(false);
    expect(apply('aperture=0.45').fx.aperture).toEqual({ on: true, strength: 0.45 });
    expect(apply('trailsamount=0.9').fx.trails.damp).toBe(0.9);
    expect(apply('ntsc=0.6').fx.ntsc).toEqual({ on: true, amount: 0.6 });
    expect(apply('halation=0.4').fx.halation).toEqual({ on: true, amount: 0.4 });
    expect(apply('scale=0.66').renderScale).toBe(0.66);
    expect(apply('scale=0.1').renderScale).toBe(0.25);     // clamp low
    expect(apply('bank=abc').bankStrength).toBe(DEFAULT_SCREENSAVER.bankStrength);
  });
  it('bloomstrength disables theme inheritance so the value sticks', () => {
    const s = apply('bloomstrength=1.6');
    expect(s.fx.bloom.strength).toBe(1.6);
    expect(s.fxInheritFromTheme).toBe(false);
  });
  it('ignores look (dialog-only key)', () => {
    expect(apply('look=heavy')).toEqual(DEFAULT_SCREENSAVER);
  });
});

describe('applyParamOverrides — kiosk mode', () => {
  it('kiosk=on hides the control bar and survives applyHudPreset', () => {
    const s = apply('kiosk=on');
    expect(s.kioskMode).toBe(true);
    expect(s.hud.controlBar).toBe(false);
    expect(s.hudPreset).toBe('custom');
    applyHudPreset(s);
    expect(s.hud.controlBar).toBe(false);
  });
  it('defaults stay without the param', () => {
    expect(apply('').kioskMode).toBe(false);
    expect(apply('').hud.controlBar).toBe(true);
  });
});

// The one contract line all three hosts read: native/shared/query-contract.txt.
// The native tests check the WRITE direction (default options must produce exactly
// this query); this checks the READ direction (this query must produce exactly the
// default settings). Both together are what makes the line a contract rather than
// two independent opinions that happen to agree today.
describe('gemeinsames Query-Fixture (native/shared/query-contract.txt)', () => {
  function contractLine(): string {
    const raw = readFileSync('native/shared/query-contract.txt', 'utf8');
    const line = raw.split('\n').find((l) => l.trim() && !l.startsWith('#'));
    if (!line) throw new Error('query-contract.txt enthält keine Vertragszeile');
    return line.trim();
  }

  it('ist vorhanden und beginnt mit ?', () => {
    expect(contractLine().startsWith('?')).toBe(true);
  });

  // Defaults in, defaults out — with two documented exceptions, both mechanism:
  //  - hudPreset 'tactical' → 'custom'. The contract line always carries the HUD keys,
  //    so `hudOverridden` fires (params.ts:154-157). That flip is what stops
  //    controller.open()'s applyHudPreset from clobbering query-set HUD flags, and
  //    since 'tactical' IS the default hud it changes the marker, never the look.
  //  - fxInheritFromTheme true → false, same reason one level down (params.ts:141):
  //    explicit FX values must not be overwritten by the theme.
  //
  // There used to be a third entry here — a real divergence, autoCycle, which defaulted
  // to off on the web and on in the native hosts. This fixture is what surfaced it, and
  // it was resolved on 2026-08-30 by pulling the native hosts to the web default. Every
  // other field, autoCycle included, must now match exactly.
  it('ergibt die Default-Settings — bis auf die zwei Mechanik-Marker', () => {
    const settings = structuredClone(DEFAULT_SCREENSAVER);
    applyParamOverrides(settings, new URLSearchParams(contractLine().slice(1)));

    // The HUD tree itself must be untouched — only the marker moves.
    expect(settings.hud).toEqual(DEFAULT_SCREENSAVER.hud);
    expect(settings.hudPreset).toBe('custom');
    expect(settings.fxInheritFromTheme).toBe(false);
    // The former divergence, now pinned as parity rather than as an exception.
    expect(settings.autoCycle).toEqual(DEFAULT_SCREENSAVER.autoCycle);

    const strip = (x: ScreensaverSettings) => {
      const { hudPreset: _a, fxInheritFromTheme: _b, ...rest } = x;
      return rest;
    };
    expect(strip(settings)).toEqual(strip(DEFAULT_SCREENSAVER));
  });

  // Guards the sentence above: 'tactical' really is the default hud. If someone
  // ever changes the tactical preset, the test above would keep passing while the
  // native hosts silently started rendering a different HUD than the web default.
  it('der tactical-Preset ist identisch mit dem Default-HUD', () => {
    expect(HUD_PRESETS.tactical).toEqual(DEFAULT_SCREENSAVER.hud);
  });
});