Zum Inhalt

tests/settings-form.test.ts

import { describe, expect, it } from 'vitest';
import {
  FORM_DEFAULTS,
  WALLPAPER_FORM_DEFAULTS,
  buildSaveMessage,
  initialMonitorStates,
  readAutostart,
  readInitial,
  readMonitors,
  readTab,
  readWallpaperInitial,
  readWallpaperRunning,
  type MonitorEntry,
  type MonitorFormState,
} from '../src/settings/form-state';

/**
 * A monitor form state as the dialog starts it: both trios neutral, the
 * wallpaper card untouched (what initialMonitorStates produces) — override what
 * matters. `wtouched: true` is a user who picked something in the wallpaper tab.
 */
const MON = (id: string, over: Partial<MonitorFormState> = {}): MonitorFormState => ({
  id, mode: 'on', scene: '', preset: '', wmode: 'on', wscene: '', wpreset: '',
  wtouched: false, ...over,
});

// v0.10 fixtures — pinned on both sides (options_test.cpp mirrors them byte-identically).
const DEFAULT_QUERY =
  'scene=random&preset=toxic-haze&speed=norm&audio=off&bloom=on&trails=off' +
  '&scan=on&crt=on&matrix=off&terminal=on&radar=on&crosshair=on' +
  '&look=&altitude=low&fog=auto&weather=light-fog&bank=1&reactive=on' +
  '&autocycle=on&cyclemin=5&termlayout=strip&boot=on&bootspeed=normal' +
  '&daynight=on&crtintensity=0.35&curvature=0.012&aperture=0.22' +
  '&bloomstrength=1.4&trailsamount=0.84&ntsc=0&halation=0.15&scale=1&perfadapt=on';

const FLIP_QUERY =
  'scene=void&preset=phosphor&speed=fast&audio=on&bloom=off&trails=on' +
  '&scan=off&crt=off&matrix=on&terminal=off&radar=off&crosshair=off' +
  '&look=heavy&altitude=high&fog=dense&weather=storm&bank=2&reactive=off' +
  '&autocycle=off&cyclemin=0.5&termlayout=window&boot=off&bootspeed=cinematic' +
  '&daynight=off&crtintensity=0.85&curvature=0.022&aperture=0.45' +
  '&bloomstrength=1.6&trailsamount=0.9&ntsc=0.6&halation=0.4&scale=0.66&perfadapt=off';

describe('settings form state (native /c dialog bridge)', () => {
  it('serialises the defaults in the pinned key order', () => {
    // Byte-identical to the host's default BuildQueryString() output,
    // with the "save:" prefix instead of "?".
    expect(buildSaveMessage(FORM_DEFAULTS)).toBe('save:' + DEFAULT_QUERY);
  });

  it('reads host query params over the defaults', () => {
    const s = readInitial(new URLSearchParams('?scene=city&preset=kuro&audio=on&bloom=off&curvature=0.05&look=vapor'));
    expect(s.scene).toBe('city');
    expect(s.preset).toBe('kuro');
    expect(s.audio).toBe(true);
    expect(s.bloom).toBe(false);
    expect(s.curvature).toBe('0.05'); // numerics stay raw strings
    expect(s.look).toBe('vapor');
    expect(s.terminal).toBe(true); // untouched default
    expect(s.trailsamount).toBe('0.84'); // untouched default
  });

  it('round-trips: what readInitial parses, buildSaveMessage re-serialises', () => {
    expect(buildSaveMessage(readInitial(new URLSearchParams('?' + FLIP_QUERY)))).toBe('save:' + FLIP_QUERY);
  });

  it('readInitial rejects garbage / out-of-range numerics back to the defaults', () => {
    // Registry values arrive unvalidated (LoadOptions never checks them) —
    // accepting them verbatim would make the host reject every save.
    const s = readInitial(new URLSearchParams('?bank=1,5&crtintensity=9&scale=abc'));
    expect(s.bank).toBe(FORM_DEFAULTS.bank);
    expect(s.crtintensity).toBe(FORM_DEFAULTS.crtintensity);
    expect(s.scale).toBe(FORM_DEFAULTS.scale);
  });

  it('readInitial rejects unknown enum values back to the defaults', () => {
    // Same threat model as the numerics: LoadOptions reads these straight out of
    // the registry, and ParseOptionField whitelists them on the way back — an
    // unknown value would fail every save of BOTH tabs, and on the inactive tab
    // no select ever renders to normalise it away.
    const s = readInitial(new URLSearchParams(
      '?speed=warp&altitude=orbit&fog=soup&weather=hail&termlayout=fullscreen&bootspeed=instant',
    ));
    expect(s.speed).toBe(FORM_DEFAULTS.speed);
    expect(s.altitude).toBe(FORM_DEFAULTS.altitude);
    expect(s.fog).toBe(FORM_DEFAULTS.fog);
    expect(s.weather).toBe(FORM_DEFAULTS.weather);
    expect(s.termlayout).toBe(FORM_DEFAULTS.termlayout);
    expect(s.bootspeed).toBe(FORM_DEFAULTS.bootspeed);
    // Valid ones pass through untouched — including the wp half's own defaults.
    expect(readInitial(new URLSearchParams('?speed=fast&fog=dense')).speed).toBe('fast');
    expect(readWallpaperInitial(new URLSearchParams('?wpspeed=warp')).speed)
      .toBe(WALLPAPER_FORM_DEFAULTS.speed);
  });

  it('readInitial keeps valid raw numeric strings byte-identical, incl. range bounds', () => {
    const s = readInitial(new URLSearchParams('?cyclemin=0.5&bank=2&scale=0.66'));
    expect(s.cyclemin).toBe('0.5');
    expect(s.bank).toBe('2');
    expect(s.scale).toBe('0.66');
  });

  it('appends monitor entries in index order', () => {
    const mons = [
      MON('DELL_ABC1', { mode: 'scene', scene: 'matrix', preset: 'phosphor' }),
      MON('LAPTOP_0', { mode: 'off' }),
    ];
    // No wallpaper half passed → no wallpaper trio, byte-identical to v0.10.
    expect(buildSaveMessage(FORM_DEFAULTS, mons)).toContain(
      '&m0id=DELL_ABC1&m0mode=scene&m0scene=matrix&m0preset=phosphor&m1id=LAPTOP_0&m1mode=off&m1scene=&m1preset=',
    );
  });

  it('caps the serialised monitors at 8 (ParseSaveMessage accepts m0..m7 only)', () => {
    const mons = Array.from({ length: 9 }, (_, i) => MON(`MON_${i}`));
    const msg = buildSaveMessage(FORM_DEFAULTS, mons);
    expect(msg).toContain('&m7id=MON_7');
    expect(msg).not.toContain('m8id');
  });

  it('appends monitormode after perfadapt and before the monitor keys, only when non-empty', () => {
    expect(buildSaveMessage(FORM_DEFAULTS, [], 'span')).toMatch(/&perfadapt=on&monitormode=span$/);
    expect(buildSaveMessage(FORM_DEFAULTS, [], '')).not.toContain('monitormode');
    expect(buildSaveMessage(FORM_DEFAULTS)).not.toContain('monitormode');
    expect(buildSaveMessage(FORM_DEFAULTS, [MON('A')], 'per')).toContain(
      '&perfadapt=on&monitormode=per&m0id=A',
    );
  });

  it('readMonitors parses the host monitor list', () => {
    const json = encodeURIComponent(
      JSON.stringify([{ id: 'A', name: 'Dell U2720Q', w: 3840, h: 2160, portrait: false, primary: true }]),
    );
    expect(readMonitors(new URLSearchParams('?monitors=' + json))[0].name).toBe('Dell U2720Q');
    expect(readMonitors(new URLSearchParams(''))).toEqual([]);
  });

  it('readMonitors tolerates malformed JSON', () => {
    expect(readMonitors(new URLSearchParams('?monitors=%7Bnope'))).toEqual([]);
    expect(readMonitors(new URLSearchParams('?monitors=42'))).toEqual([]);
  });

  it('readMonitors passes the existing per-monitor config through', () => {
    const json = encodeURIComponent(JSON.stringify([{
      id: 'A', name: 'Dell', w: 1920, h: 1080, portrait: false, primary: true,
      mode: 'scene', scene: 'matrix', preset: 'phosphor',
    }]));
    const [m] = readMonitors(new URLSearchParams('?monitors=' + json));
    expect(m.mode).toBe('scene');
    expect(m.scene).toBe('matrix');
    expect(m.preset).toBe('phosphor');
  });

  it('monitor cards start from the host config, missing subkey falls back to on/global', () => {
    const entries: MonitorEntry[] = [
      { id: 'A', name: 'Dell', w: 1920, h: 1080, portrait: false, primary: true,
        mode: 'scene', scene: 'matrix', preset: 'phosphor',
        wmode: 'random', wscene: '', wpreset: 'kuro' },
      { id: 'B', name: 'Laptop', w: 1280, h: 800, portrait: false, primary: false },
    ];
    const states = initialMonitorStates(entries);
    expect(states[0]).toEqual({
      id: 'A', mode: 'scene', scene: 'matrix', preset: 'phosphor',
      wmode: 'random', wscene: '', wpreset: 'kuro', wtouched: false,
    });
    // A host that sends no trio at all: wmode stays '' so the save omits the
    // keys and the host's "never set" sentinel survives — this side must not
    // invent 'on'/'off' for an absence only the host can resolve.
    expect(states[1]).toEqual({
      id: 'B', mode: 'on', scene: '', preset: '',
      wmode: '', wscene: '', wpreset: '', wtouched: false,
    });
  });

  it('round-trips: an untouched save reproduces the loaded per-monitor config', () => {
    const json = encodeURIComponent(JSON.stringify([
      { id: 'A', name: 'Dell', w: 1920, h: 1080, portrait: false, primary: true,
        mode: 'scene', scene: 'matrix', preset: 'phosphor' },
      { id: 'B', name: 'Laptop', w: 1280, h: 800, portrait: false, primary: false,
        mode: 'off', scene: '', preset: '' },
    ]));
    const loaded = readMonitors(new URLSearchParams('?monitors=' + json));
    expect(buildSaveMessage(FORM_DEFAULTS, initialMonitorStates(loaded))).toContain(
      '&m0id=A&m0mode=scene&m0scene=matrix&m0preset=phosphor&m1id=B&m1mode=off&m1scene=&m1preset=',
    );
  });
});

// v0.11 — the wallpaper's per-monitor assignment. It has existed in the registry
// since v0.10 (WMode/WScene/WPreset) and was reachable from no UI at all.
describe('per-monitor wallpaper assignment', () => {
  it('reads the wallpaper trio the host resolved for it', () => {
    const json = encodeURIComponent(JSON.stringify([{
      id: 'A', name: 'Dell', w: 1920, h: 1080, portrait: false, primary: true,
      mode: 'on', scene: '', preset: '',
      wmode: 'scene', wscene: 'void', wpreset: 'phosphor',
    }]));
    const [m] = readMonitors(new URLSearchParams('?monitors=' + json));
    expect(m.wmode).toBe('scene');
    expect(m.wscene).toBe('void');
    expect(m.wpreset).toBe('phosphor');
  });

  it('sends the trio per monitor once the user touched the card', () => {
    const mons = [MON('A', {
      mode: 'off', wmode: 'scene', wscene: 'void', wpreset: 'phosphor', wtouched: true,
    })];
    const msg = buildSaveMessage(FORM_DEFAULTS, mons, 'per', WALLPAPER_FORM_DEFAULTS, 'per');
    expect(msg).toContain('&m0id=A&m0mode=off&m0scene=&m0preset=');
    expect(msg).toContain('&m0wmode=scene&m0wscene=void&m0wpreset=phosphor');
  });

  it('sends no trio for an untouched card, even though every save has a wp half', () => {
    // main.ts ALWAYS passes a wallpaper half (it serves both tabs from one Save
    // button), so `wallpaper` says nothing about whether the user ever decided
    // anything per monitor — only wtouched does. Sending the trio here writes
    // WMode explicitly for every monitor and the host's "unset primary = on"
    // rule can never fire again (the v0.10 black-wallpaper bug, one dock away).
    // The dialog-level proof of this lives in settings-dialog.test.ts.
    const mons = [MON('A', { wmode: 'on' }), MON('B', { wmode: 'off' })];
    const msg = buildSaveMessage(FORM_DEFAULTS, mons, 'per', WALLPAPER_FORM_DEFAULTS, 'per');
    expect(msg).toContain('&m0id=A&m0mode=on&m0scene=&m0preset=&m1id=B');
    expect(msg).not.toContain('m0wmode');
    expect(msg).not.toContain('m1wmode');
  });

  it('never sends an empty wmode — absence is the host sentinel, not a value', () => {
    // The empty string means "never set" = "primary on, others off" in the host.
    // Spelling it out in a save would materialise it as a real value and put the
    // v0.10 black-wallpaper bug back — so the key stays away entirely, even for
    // a card the user did touch (a host too old to send the trio at all).
    const mons = [MON('A', { wmode: '', wtouched: true })];
    const msg = buildSaveMessage(FORM_DEFAULTS, mons, 'per', WALLPAPER_FORM_DEFAULTS, 'per');
    expect(msg).not.toContain('m0wmode');
    expect(msg).toContain('&m0id=A');
  });

  it('touching a card only affects its own trio', () => {
    const mons = [MON('A'), MON('B', { wmode: 'random', wtouched: true })];
    const msg = buildSaveMessage(FORM_DEFAULTS, mons, 'per', WALLPAPER_FORM_DEFAULTS, 'per');
    expect(msg).not.toContain('m0wmode');
    expect(msg).toContain('&m1wmode=random&m1wscene=&m1wpreset=');
  });
});

// v0.11 — the wallpaper's own half of the dialog. Pinned against
// BuildWallpaperQuerySuffix / ParseWallpaperSaveMessage on the host side.
describe('wallpaper tab', () => {
  it('reads the wp-prefixed half into its own state', () => {
    const p = new URLSearchParams('scene=terrain&wpscene=void&wppreset=phosphor&wpscale=0.66');
    const wp = readWallpaperInitial(p);
    expect(wp.scene).toBe('void');
    expect(wp.preset).toBe('phosphor');
    expect(wp.scale).toBe('0.66');
  });

  it('leaves the saver state untouched', () => {
    const p = new URLSearchParams('scene=terrain&wpscene=void');
    expect(readInitial(p).scene).toBe('terrain');
  });

  it('falls back to wallpaper defaults when the half is absent', () => {
    const wp = readWallpaperInitial(new URLSearchParams('scene=terrain'));
    expect(wp.audio).toBe(false);
    expect(wp.scale).toBe('0.66');
    expect(wp.preset).toBe(FORM_DEFAULTS.preset); // rest = saver defaults
  });

  it('does not mistake the saver half for the wallpaper half', () => {
    // A bare `scene=` must not leak in through a sloppy prefix strip.
    const wp = readWallpaperInitial(new URLSearchParams('scene=terrain&audio=on&scale=1'));
    expect(wp.scene).toBe(WALLPAPER_FORM_DEFAULTS.scene);
    expect(wp.audio).toBe(false);
    expect(wp.scale).toBe('0.66');
  });

  it('forces audio off even when the registry says otherwise', () => {
    // The host renders the wallpaper with audio off unconditionally
    // (BuildWallpaperPage). A checkbox greyed out as "aus — im Wallpaper immer"
    // must therefore never show, or save, anything else.
    expect(readWallpaperInitial(new URLSearchParams('wpaudio=on')).audio).toBe(false);
  });

  it('rejects garbage / out-of-range wp numerics back to the WALLPAPER defaults', () => {
    const wp = readWallpaperInitial(new URLSearchParams('wpscale=abc&wpcrtintensity=9'));
    expect(wp.scale).toBe('0.66'); // not FORM_DEFAULTS.scale ('1')
    expect(wp.crtintensity).toBe(FORM_DEFAULTS.crtintensity);
  });

  it('serialises both sets, numerics unchanged', () => {
    const saver = { ...FORM_DEFAULTS, scene: 'terrain', crtintensity: '0.35' };
    const wallpaper = { ...WALLPAPER_FORM_DEFAULTS, scene: 'void', crtintensity: '0.35' };
    const out = buildSaveMessage(saver, [], '', wallpaper);
    expect(out).toContain('scene=terrain');
    expect(out).toContain('wpscene=void');
    expect(out).toContain('crtintensity=0.35');
    expect(out).toContain('wpcrtintensity=0.35');
    expect(out).toContain('wpscale=0.66'); // raw, never reformatted
  });

  it('keeps the saver half byte-identical when a wallpaper half is appended', () => {
    // The format invariant: everything new hangs off the back, the pinned
    // 33-key prefix does not move a byte.
    const msg = buildSaveMessage(FORM_DEFAULTS, [], '', WALLPAPER_FORM_DEFAULTS);
    expect(msg.startsWith('save:' + DEFAULT_QUERY + '&wpscene=')).toBe(true);
  });

  it('appends the wp half after the monitor keys, and wpmonitormode last', () => {
    const mons = [MON('A', { wtouched: true })];
    const msg = buildSaveMessage(FORM_DEFAULTS, mons, 'per', WALLPAPER_FORM_DEFAULTS, 'span');
    expect(msg).toContain('&m0wpreset=&wpscene=');
    expect(msg).toMatch(/&wpperfadapt=on&wpmonitormode=span$/);
  });

  it('omits the wp half and wpmonitormode entirely when not passed', () => {
    // The saver-only callers (and the pinned fixtures) must not gain a byte.
    expect(buildSaveMessage(FORM_DEFAULTS)).toBe('save:' + DEFAULT_QUERY);
    expect(buildSaveMessage(FORM_DEFAULTS, [], 'span')).not.toContain('wp');
    expect(buildSaveMessage(FORM_DEFAULTS, [], 'span', WALLPAPER_FORM_DEFAULTS)).not.toContain(
      'wpmonitormode=',
    );
  });

  it('round-trips: what readWallpaperInitial parses, buildSaveMessage re-serialises', () => {
    const wpQuery = FLIP_QUERY.split('&')
      .map((pair) => 'wp' + pair)
      .join('&')
      .replace('wpaudio=on', 'wpaudio=off'); // forced off, see above
    const wp = readWallpaperInitial(new URLSearchParams('?' + wpQuery));
    const msg = buildSaveMessage(FORM_DEFAULTS, [], '', wp);
    expect(msg).toBe('save:' + DEFAULT_QUERY + '&' + wpQuery);
  });

  it('reads the run status, the initial tab and the autostart flag', () => {
    const p = new URLSearchParams('wprunning=2/3&tab=wallpaper&wpautostart=on');
    expect(readWallpaperRunning(p)).toBe('2/3');
    expect(readTab(p)).toBe('wallpaper');
    expect(readAutostart(p)).toBe(true);

    // Absent = nothing runs, saver tab, no autostart — the /c screensaver path.
    const empty = new URLSearchParams('');
    expect(readWallpaperRunning(empty)).toBe('');
    expect(readTab(empty)).toBe('saver');
    expect(readAutostart(empty)).toBe(false);

    // An unknown tab value is not a reason to show a broken dialog.
    expect(readTab(new URLSearchParams('tab=nonsense'))).toBe('saver');
  });
});