Zum Inhalt

tests/settings-dialog.test.ts

// @vitest-environment jsdom
//
// The settings dialog as the host actually opens it: main.ts is imported against
// a query string in the exact shape settings_window.cpp builds (BuildQueryString
// + monitormode + monitors-JSON + BuildWallpaperQuerySuffix + wpmonitormode +
// tab), and the save message is read off the webview bridge stub.
//
// Why through main.ts and not buildSaveMessage directly: main.ts is the ONLY
// production caller, it always passes both option sets, and every guard in
// form-state.ts is only worth what it is worth on THAT call. A test that
// composes a call main.ts never makes proves nothing — that is how the wmode
// sentinel could be destroyed on every save while two green tests watched.

import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
  FORM_DEFAULTS,
  WALLPAPER_FORM_DEFAULTS,
  buildSaveMessage,
  type MonitorEntry,
  type SettingsFormState,
} from '../src/settings/form-state';

/** Laptop (primary) + external dock monitor, wmode as EffectiveWallpaperMode resolves it. */
const LAPTOP_AND_DOCK: MonitorEntry[] = [
  { id: 'LAPTOP_0', name: 'Laptop', w: 1920, h: 1080, portrait: false, primary: true,
    mode: 'on', scene: '', preset: '', wmode: 'on', wscene: '', wpreset: '' },
  { id: 'DELL_ABC1', name: 'Dell U2720Q', w: 3840, h: 2160, portrait: false, primary: false,
    mode: 'on', scene: '', preset: '', wmode: 'off', wscene: '', wpreset: '' },
];

/**
 * The host's page query. Query and save message share the key format (pinned
 * byte for byte in settings-form.test.ts and options_test.cpp), so the
 * serialiser doubles as the host stub here — with the two keys only the host
 * sends appended.
 */
function hostQuery(o: {
  saver?: Partial<SettingsFormState>;
  wallpaper?: Partial<SettingsFormState>;
  monitors?: MonitorEntry[];
  tab?: 'saver' | 'wallpaper';
  running?: string;
  paused?: boolean;
} = {}): string {
  const query = buildSaveMessage(
    { ...FORM_DEFAULTS, ...o.saver }, [], 'per',
    { ...WALLPAPER_FORM_DEFAULTS, ...o.wallpaper }, 'per',
  ).slice('save:'.length);
  return `?${query}&monitors=${encodeURIComponent(JSON.stringify(o.monitors ?? []))}` +
    `&wprunning=${o.running ?? ''}&wppaused=${o.paused ? 'on' : 'off'}` +
    `&tab=${o.tab ?? 'saver'}`;
}

const posted: string[] = [];

/** Boots the real dialog against `query` — one module instance per test. */
async function openDialog(query: string): Promise<void> {
  document.body.innerHTML = '<div id="app"></div>';
  window.history.replaceState({}, '', '/settings.html' + query);
  posted.length = 0;
  (window as unknown as { chrome: unknown }).chrome = {
    webview: { postMessage: (m: string) => posted.push(m), addEventListener: () => {} },
  };
  vi.resetModules(); // main.ts builds the whole dialog on import
  await import('../src/settings/main');
}

function clickTab(text: string): void {
  const btn = [...document.querySelectorAll<HTMLButtonElement>('button.tab')]
    .find((b) => b.textContent === text);
  if (!btn) throw new Error(`no tab "${text}"`);
  btn.click();
}

function save(): string {
  const btn = [...document.querySelectorAll<HTMLButtonElement>('.buttons button')]
    .find((b) => b.textContent === 'Save');
  if (!btn) throw new Error('no save button');
  btn.click();
  const msg = posted.at(-1);
  if (!msg) throw new Error('save posted nothing');
  return msg;
}

/** The select of the `label` row inside `root` — cards and sections share the row shape. */
function selectIn(root: ParentNode, label: string): HTMLSelectElement {
  for (const rowEl of root.querySelectorAll('.row')) {
    if (rowEl.querySelector('label')?.textContent === label) {
      return rowEl.querySelector('select')!;
    }
  }
  throw new Error(`no select labelled "${label}"`);
}

/** A user picking a value — `change` is what the dialog's listeners react to. */
function pick(sel: HTMLSelectElement, value: string): void {
  sel.value = value;
  if (sel.value !== value) throw new Error(`no option "${value}"`);
  sel.dispatchEvent(new Event('change'));
}

const cards = (): HTMLElement[] => [...document.querySelectorAll<HTMLElement>('.mon-card')];

describe('settings dialog — per-monitor wallpaper trio', () => {
  beforeEach(() => { vi.resetModules(); });

  it('a save that never touched a monitor card leaves the wmode sentinel alone', async () => {
    // The /c path: Systemsteuerung → scene changed → Save, wallpaper tab never
    // seen. Spelling out m<N>wmode here writes WMode explicitly for every
    // monitor, and the host's "unset primary = on" rule can never fire again —
    // re-dock with the external as primary and the wallpaper runs nowhere.
    await openDialog(hostQuery({ monitors: LAPTOP_AND_DOCK, tab: 'saver' }));
    pick(selectIn(document.body, 'Scene'), 'void');
    const msg = save();
    expect(msg).toContain('&m0id=LAPTOP_0&m0mode=on');
    expect(msg).not.toContain('m0wmode');
    expect(msg).not.toContain('m1wmode');
  });

  it('sends the trio only for the cards the user actually touched', async () => {
    await openDialog(hostQuery({ monitors: LAPTOP_AND_DOCK, tab: 'wallpaper' }));
    pick(selectIn(cards()[1], 'Mode'), 'random');
    const msg = save();
    expect(msg).toContain('&m1wmode=random&m1wscene=&m1wpreset=');
    // Monitor 0 was never touched — its WMode stays unwritten, so the rule keeps
    // following the primary.
    expect(msg).not.toContain('m0wmode');
  });

  it('a card touched in the saver tab does not send the wallpaper trio', async () => {
    // Same cards serve both tabs; only the wallpaper tab speaks for WMode.
    await openDialog(hostQuery({ monitors: LAPTOP_AND_DOCK, tab: 'saver' }));
    pick(selectIn(cards()[0], 'Mode'), 'off');
    const msg = save();
    expect(msg).toContain('&m0id=LAPTOP_0&m0mode=off');
    expect(msg).not.toContain('m0wmode');
  });

  it('changing only the wallpaper tab globals sends no trio at all', async () => {
    await openDialog(hostQuery({ monitors: LAPTOP_AND_DOCK, tab: 'wallpaper' }));
    pick(selectIn(document.body, 'Scene'), 'void'); // the global Bild scene
    const msg = save();
    expect(msg).toContain('&wpscene=void');
    expect(msg).not.toContain('m0wmode');
    expect(msg).not.toContain('m1wmode');
  });

  it('a card touched in the wallpaper tab keeps its trio across a tab switch', async () => {
    await openDialog(hostQuery({ monitors: LAPTOP_AND_DOCK, tab: 'wallpaper' }));
    pick(selectIn(cards()[0], 'Mode'), 'scene');
    pick(selectIn(cards()[0], 'Scene'), 'void');
    clickTab('Screensaver'); // refreshAll repoints every card accessor
    pick(selectIn(cards()[0], 'Mode'), 'off'); // the saver's mode, not the wallpaper's
    const msg = save();
    expect(msg).toContain('&m0id=LAPTOP_0&m0mode=off&m0scene=&m0preset=');
    expect(msg).toContain('&m0wmode=scene&m0wscene=void&m0wpreset=');
  });
});

describe('settings dialog — the inactive tab is normalised too', () => {
  beforeEach(() => { vi.resetModules(); });

  it('saves from the wallpaper tab with a poisoned saver enum in the registry', async () => {
    // Speed=warp (hand edit / third-party writer — the model ReadNumber already
    // defends the numerics against). Opened on the wallpaper tab, the saver's
    // selects never render, so nothing normalised `speed` — and since one save
    // carries both halves, the host rejected it all-or-nothing: BOTH tabs
    // unsavable until someone happens to click the Screensaver tab once.
    await openDialog(hostQuery({ saver: { speed: 'warp' }, tab: 'wallpaper' }));
    const msg = save();
    expect(msg).toContain('&speed=norm');
    expect(msg).not.toContain('warp');
  });

  it('normalises every enum of the inactive half, not just the ones on screen', async () => {
    await openDialog(hostQuery({
      saver: { altitude: 'orbit', fog: 'soup', weather: 'hail', bootspeed: 'instant' },
      wallpaper: { speed: 'warp', termlayout: 'fullscreen' },
      tab: 'wallpaper',
    }));
    const msg = save();
    expect(msg).toContain(`&altitude=${FORM_DEFAULTS.altitude}`);
    expect(msg).toContain(`&fog=${FORM_DEFAULTS.fog}`);
    expect(msg).toContain(`&weather=${FORM_DEFAULTS.weather}`);
    expect(msg).toContain(`&bootspeed=${FORM_DEFAULTS.bootspeed}`);
    expect(msg).toContain(`&wpspeed=${WALLPAPER_FORM_DEFAULTS.speed}`);
    expect(msg).toContain(`&wptermlayout=${WALLPAPER_FORM_DEFAULTS.termlayout}`);
  });
});

describe('pause button', () => {
  /** The [Anhalten] control from the approved layout — the tray's toggle, in the dialog. */
  function pauseButton(): HTMLButtonElement | undefined {
    return [...document.querySelectorAll<HTMLButtonElement>('button.pause')][0];
  }

  it('posts pause:on immediately, not on save', async () => {
    await openDialog(hostQuery({ tab: 'wallpaper', running: '2/3' }));
    pauseButton()!.click();
    // Immediate-apply like the tray item and the autostart box: pausing is an
    // action, not a setting you stage and confirm.
    expect(posted.at(-1)).toBe('pause:on');
  });

  it('posts pause:off and relabels when resuming', async () => {
    await openDialog(hostQuery({ tab: 'wallpaper', running: '2/3', paused: true }));
    expect(pauseButton()!.textContent).toBe('Fortsetzen');
    pauseButton()!.click();
    expect(posted.at(-1)).toBe('pause:off');
    expect(pauseButton()!.textContent).toBe('Anhalten');
  });

  it('is absent when no wallpaper runs — nothing to pause', async () => {
    // The /c path: the saver's dialog has no tray and no wallpaper behind it.
    await openDialog(hostQuery({ tab: 'wallpaper', running: '' }));
    expect(pauseButton()).toBeUndefined();
  });

  it('never leaks into the save message', async () => {
    await openDialog(hostQuery({ tab: 'wallpaper', running: '2/3' }));
    pauseButton()!.click();
    expect(save()).not.toContain('pause');
  });
});