Zum Inhalt

tests/bildfolge.test.ts

import { describe, it, expect } from 'vitest';
import {
  parseArgs, frameName, reverseFrames, buildManifest, compareManifests, bewerteDifferenzen, darfGeleertWerden, dirtyFromPorcelain, pageUrl, seededRandomSource, sha256,
} from '../scripts/lib/bildfolge.mjs';

describe('parseArgs', () => {
  it('applies defaults', () => {
    const o = parseArgs(['--scene', 'city']);
    expect(o).toMatchObject({ scene: 'city', preset: 'kuro', seed: 1, fps: 24, seconds: 10, width: 1920, height: 1080, clock: 'page', crash: 'none', warmup: 3, allowDirty: false, check: false });
    expect(o.out).toBe('render-out/city-kuro-s1');
  });
  it('rejects an unknown scene naming the allowed ones', () => {
    expect(() => parseArgs(['--scene', 'bogus'])).toThrow(/scene.*terrain, city, rift, tunnel, void, wreckage, matrix, metro/);
    expect(parseArgs(['--scene', 'metro']).scene).toBe('metro');
  });
  it('rejects fps, seconds, crash and clock out of range (Review Focus 1)', () => {
    expect(() => parseArgs(['--scene', 'city', '--fps', '0'])).toThrow(/fps/);
    expect(() => parseArgs(['--scene', 'city', '--seconds', '-1'])).toThrow(/seconds/);
    expect(() => parseArgs(['--scene', 'city', '--crash', 'sideways'])).toThrow(/crash.*none, forward, reverse/);
    expect(() => parseArgs(['--scene', 'city', '--clock', 'wall'])).toThrow(/clock.*page, virtual/);
    expect(() => parseArgs(['--scene', 'city', '--renderer', 'cuda'])).toThrow(/renderer.*metal, swiftshader/);
  });
  it('passes --extra through to the page URL and rejects junk', () => {
    const o = parseArgs(['--scene', 'city', '--extra', 'crt=off&bloom=off']);
    expect(o.extra).toBe('crt=off&bloom=off');
    expect(() => parseArgs(['--scene', 'city', '--extra', 'crt=off; rm'])).toThrow(/--extra/);
    // reserved keys would make the manifest lie about scene, seed, preset, hud or audio
    expect(() => parseArgs(['--scene', 'city', '--extra', 'seed=9'])).toThrow(/--extra.*seed/);
    expect(() => parseArgs(['--scene', 'city', '--extra', 'scene=void&crt=off'])).toThrow(/--extra.*scene/);
  });
  it('requires --scene', () => {
    expect(() => parseArgs([])).toThrow(/--scene/);
  });
});

describe('frameName', () => {
  it('is four digits from 0000', () => {
    expect(frameName(0)).toBe('frame-0000.png');
    expect(frameName(1234)).toBe('frame-1234.png');
  });
});

describe('reverseFrames (Review Focus 2)', () => {
  it('reverses order, renumbers files and restarts t at 0 with the same spacing', () => {
    const frames = [
      { file: 'frame-0000.png', t: 0, sha256: 'a' },
      { file: 'frame-0001.png', t: 0.5, sha256: 'b' },
      { file: 'frame-0002.png', t: 1, sha256: 'c' },
    ];
    expect(reverseFrames(frames)).toEqual([
      { file: 'frame-0000.png', t: 0, sha256: 'c', source: 'frame-0002.png' },
      { file: 'frame-0001.png', t: 0.5, sha256: 'b', source: 'frame-0001.png' },
      { file: 'frame-0002.png', t: 1, sha256: 'a', source: 'frame-0000.png' },
    ]);
  });
});

describe('buildManifest', () => {
  it('carries parameters, frames and provenance', () => {
    const opts = parseArgs(['--scene', 'tunnel', '--seed', '7', '--fps', '20', '--seconds', '2']);
    const m = buildManifest(opts, [{ file: 'frame-0000.png', t: 0, sha256: 'x' }], { commit: 'abc', unsauber: [], playwright: '1.60.0', chromium: '140' });
    expect(m.scene).toBe('tunnel');
    expect(m.seed).toBe(7);
    expect(m.url).toContain('scene=tunnel&preset=kuro&seed=7');
    expect(buildManifest({ ...opts, base: 'http://127.0.0.1:5199' }, [], { commit: 'a', unsauber: [], playwright: '1', chromium: '1' }).url.startsWith('http://127.0.0.1:5199/')).toBe(true);   // the manifest names the server that really served the page
    expect(buildManifest(parseArgs(['--scene', 'city', '--extra', 'crt=off']), [], { commit: 'a', unsauber: [], playwright: '1', chromium: '1' }).url).toContain('&crt=off');
    expect(m.herkunft).toEqual({ repo: 'kuro-screensaver', commit: 'abc', unsauber: [], license: 'AGPL-3.0-only', playwright: '1.60.0', chromium: '140' });
    expect(m.frames).toHaveLength(1);
  });
});

describe('compareManifests (Review Focus 3)', () => {
  const a = { frames: [{ file: 'frame-0000.png', sha256: '1' }, { file: 'frame-0001.png', sha256: '2' }] };
  it('reports identical manifests as equal', () => {
    expect(compareManifests(a, a)).toEqual({ gleich: true, verschieden: [], anzahl: 2 });
  });
  it('names exactly the differing frames', () => {
    const b = { frames: [{ file: 'frame-0000.png', sha256: '1' }, { file: 'frame-0001.png', sha256: '9' }] };
    expect(compareManifests(a, b)).toEqual({ gleich: false, verschieden: ['frame-0001.png'], anzahl: 2 });
  });
  it('treats a different frame count as different', () => {
    expect(compareManifests(a, { frames: a.frames.slice(0, 1) }).gleich).toBe(false);
  });
});

describe('bewerteDifferenzen (Review Focus 3, Toleranz)', () => {
  it('is byte-identical only when every hash matches (not when the luma difference happens to be 0)', () => {
    expect(bewerteDifferenzen([{ file: 'a', max: 0, hashGleich: true }, { file: 'b', max: 0, hashGleich: true }])).toMatchObject({ stufe: 'byte-gleich', max: 0, identisch: 2, anzahl: 2, abweichend: [], ok: true });
    expect(bewerteDifferenzen([{ file: 'a', max: 0, hashGleich: false }])).toMatchObject({ stufe: 'gleich bis auf Rauschen', identisch: 0 });
  });
  it('tolerates rendering noise up to noiseMax, counts identical frames by hash', () => {
    expect(bewerteDifferenzen([{ file: 'a', max: 7, hashGleich: false }, { file: 'b', max: 0, hashGleich: true }])).toMatchObject({ stufe: 'gleich bis auf Rauschen', max: 7, identisch: 1, abweichend: [], ok: true });
  });
  it('names the frames above the tolerance and fails', () => {
    expect(bewerteDifferenzen([{ file: 'a', max: 3, hashGleich: false }, { file: 'b', max: 90, hashGleich: false }])).toMatchObject({ stufe: 'verschieden', max: 90, abweichend: ['b'], ok: false });
  });
  it('treats an empty list as not ok', () => {
    expect(bewerteDifferenzen([]).ok).toBe(false);
  });
});

describe('darfGeleertWerden (Review C1: --out must not wipe foreign directories)', () => {
  it('allows a missing or empty directory and one holding only our own output', () => {
    expect(darfGeleertWerden(null)).toBe(true);
    expect(darfGeleertWerden([])).toBe(true);
    expect(darfGeleertWerden(['frame-0000.png', 'frame-0001.png', 'bildfolge.json', '.rev', 'konkat.txt'])).toBe(true);
  });
  it('refuses a directory with anything else in it', () => {
    expect(darfGeleertWerden(['frame-0000.png', 'notes.md'])).toBe(false);
    expect(darfGeleertWerden(['.git'])).toBe(false);
  });
});

describe('pageUrl', () => {
  it('switches HUD and radar ping off and takes the server base', () => {
    const u = pageUrl(parseArgs(['--scene', 'city']), 'http://127.0.0.1:5199');
    expect(u.startsWith('http://127.0.0.1:5199/screensaver.html?')).toBe(true);
    expect(u).toContain('hud=off');
    expect(u).toContain('ping=off');
  });
});

describe('dirtyFromPorcelain (Review Focus 5)', () => {
  it('lists changed paths and ignores blank lines', () => {
    expect(dirtyFromPorcelain(' M src/a.ts\n?? scripts/x.mjs\n\n')).toEqual(['src/a.ts', 'scripts/x.mjs']);
    expect(dirtyFromPorcelain('')).toEqual([]);
  });
});

describe('seededRandomSource', () => {
  it('yields a deterministic Math.random replacement', () => {
    const original = Math.random;   // the source replaces the global; restore it for the other tests
    try {
      const src = seededRandomSource(42);
      const a = new Function(`${src}; return [Math.random(), Math.random()];`)();
      const b = new Function(`${src}; return [Math.random(), Math.random()];`)();
      expect(a).toEqual(b);
      expect(a[0]).not.toEqual(a[1]);
      expect(a[0]).toBeGreaterThanOrEqual(0);
      expect(a[0]).toBeLessThan(1);
    } finally {
      Math.random = original;
    }
  });
});

describe('sha256', () => {
  it('hashes bytes', () => {
    expect(sha256(Buffer.from('abc'))).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
  });
});