Ctrl K
ComunidadGuías…

lib.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, it, expect } from 'vitest';
import {
  PROMPT_WORD_CAP,
  smartTrimPrompt,
  buildFilmTitles,
  parseCreditLines,
  subtitleEnableField,
  buildCharacterRefs,
  buildCastLoras,
  characterRefFromStudioMember,
  resolveCastMember,
  formatPreflightResult,
  gatePreflight,
  matchBackend,
  pickAutoMotionBackend,
  pickAutoBind,
  buildCastContextBlock,
  buildStoryboardPayload,
  isRenderCommand,
} from './lib.mjs';

// Real unit tests over the pure helpers extracted from bot.mjs. No Discord client, no network,
// no env: each asserts the exact transform contract these functions promise to the render path.

const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');

describe('smartTrimPrompt', () => {
  it('leaves a prompt at or under the cap untouched', () => {
    const r = smartTrimPrompt(words(PROMPT_WORD_CAP));
    expect(r.trimmed).toBe(false);
    expect(r.text.split(' ').length).toBe(PROMPT_WORD_CAP);
  });

  it('normalizes whitespace and trims edges without flagging a trim', () => {
    expect(smartTrimPrompt('  a   b\tc  ')).toEqual({ text: 'a   b\tc', trimmed: false });
  });

  it('clamps to the cap and flags a trim when over', () => {
    const r = smartTrimPrompt(words(PROMPT_WORD_CAP + 20));
    expect(r.trimmed).toBe(true);
    expect(r.text.split(/\s+/).length).toBe(PROMPT_WORD_CAP);
  });

  it('keeps the leading sentence whole, then tops up from the rest', () => {
    const head = 'A hero runs fast.'; // 4 words, motion-critical clause
    const tail = words(PROMPT_WORD_CAP + 30);
    const r = smartTrimPrompt(`${head} ${tail}`);
    expect(r.trimmed).toBe(true);
    expect(r.text.startsWith('A hero runs fast.')).toBe(true);
    expect(r.text.split(/\s+/).length).toBe(PROMPT_WORD_CAP);
  });

  it('hard-slices when the leading sentence alone exceeds the cap', () => {
    const longSentence = words(PROMPT_WORD_CAP + 10) + '.';
    const r = smartTrimPrompt(longSentence + ' ' + words(5));
    expect(r.trimmed).toBe(true);
    expect(r.text.split(/\s+/).length).toBe(PROMPT_WORD_CAP);
    // Falls back to a plain head slice: first PROMPT_WORD_CAP tokens of the whole text.
    expect(r.text.startsWith('w0 w1 w2')).toBe(true);
  });

  it('treats null/undefined/empty as an empty untrimmed prompt', () => {
    expect(smartTrimPrompt(undefined)).toEqual({ text: '', trimmed: false });
    expect(smartTrimPrompt(null)).toEqual({ text: '', trimmed: false });
    expect(smartTrimPrompt('')).toEqual({ text: '', trimmed: false });
  });
});

describe('buildFilmTitles', () => {
  it('returns undefined for a null/empty render-settings', () => {
    expect(buildFilmTitles(null)).toBeUndefined();
    expect(buildFilmTitles(undefined)).toBeUndefined();
    expect(buildFilmTitles({})).toBeUndefined();
  });

  it('emits a title card from title text', () => {
    expect(buildFilmTitles({ titles: { text: '  The Reel  ' } })).toEqual({ title: { text: 'The Reel' } });
  });

  it('includes a subtitle only when the title text is present', () => {
    expect(buildFilmTitles({ titles: { text: 'The Reel', subtitle: '  A short  ' } }))
      .toEqual({ title: { text: 'The Reel', subtitle: 'A short' } });
    // Subtitle alone (no title text) is dropped -- the card requires text.
    expect(buildFilmTitles({ titles: { subtitle: 'orphan' } })).toBeUndefined();
  });

  it('strips blank credit lines and omits credits when all blank', () => {
    expect(buildFilmTitles({ credits: { lines: ['Dir: X', '  ', '', 'DP: Y'] } }))
      .toEqual({ credits: { lines: ['Dir: X', 'DP: Y'] } });
    expect(buildFilmTitles({ credits: { lines: ['  ', ''] } })).toBeUndefined();
  });

  it('combines title and credits', () => {
    expect(buildFilmTitles({ titles: { text: 'T' }, credits: { lines: ['a'] } }))
      .toEqual({ title: { text: 'T' }, credits: { lines: ['a'] } });
  });

  it('ignores a non-array credits.lines', () => {
    expect(buildFilmTitles({ credits: { lines: 'not-an-array' } })).toBeUndefined();
  });
});

describe('parseCreditLines', () => {
  it('splits on pipe, semicolon, and newline', () => {
    expect(parseCreditLines('a | b ; c\nd')).toEqual(['a', 'b', 'c', 'd']);
  });

  it('trims each line and drops blanks', () => {
    expect(parseCreditLines('  a  ||  ; \n b ')).toEqual(['a', 'b']);
  });

  it('returns an empty array for null/undefined/empty', () => {
    expect(parseCreditLines(null)).toEqual([]);
    expect(parseCreditLines(undefined)).toEqual([]);
    expect(parseCreditLines('')).toEqual([]);
  });
});

describe('subtitleEnableField', () => {
  it('prefers a bool key that looks like an enable switch', () => {
    const mod = { config_schema: { quality: { type: 'string' }, burn_in: { type: 'bool' } } };
    expect(subtitleEnableField(mod)).toBe('burn_in');
  });

  it('matches enable/on/subtitle/caption name shapes', () => {
    expect(subtitleEnableField({ config_schema: { on: { type: 'bool' } } })).toBe('on');
    expect(subtitleEnableField({ config_schema: { captions: { type: 'bool' } } })).toBe('captions');
  });

  it('falls back to the first bool field when none match the enable shape', () => {
    const mod = { config_schema: { alpha: { type: 'bool' }, beta: { type: 'bool' } } };
    expect(subtitleEnableField(mod)).toBe('alpha');
  });

  it('falls back to "enabled" when there are no bool fields or no schema', () => {
    expect(subtitleEnableField({ config_schema: { size: { type: 'string' } } })).toBe('enabled');
    expect(subtitleEnableField({})).toBe('enabled');
    expect(subtitleEnableField(null)).toBe('enabled');
  });
});

describe('buildCharacterRefs', () => {
  it('includes only cast with both a studio castId and an uploaded portraitKey (inline path)', () => {
    const brief = {
      cast: [
        { slot: 'A', name: 'Ada', castId: 'c1', portraitKey: 'k1' },
        { slot: 'B', name: 'Ben', castId: 'c2' },
        { slot: 'C', name: 'Cy', portraitKey: 'k3' },
      ],
    };
    expect(buildCharacterRefs(brief)).toEqual({
      A: { name: 'Ada', portrait: { key: 'k1' }, trainingImages: [{ key: 'k1' }] },
    });
  });

  it('builds refs from bound studio cast members (portrait + ref_keys)', () => {
    const brief = {
      cast_bindings: { A: 'cast-uuid' },
      cast: [{ slot: 'A', name: 'Wren', prompt: 'a pilot' }],
    };
    const catalog = [{
      id: 'cast-uuid',
      name: 'Wren',
      bible: 'brave pilot',
      portrait_key: 'p1',
      ref_keys: ['r1', 'r2'],
    }];
    expect(buildCharacterRefs(brief, catalog)).toEqual({
      A: {
        name: 'Wren',
        prompt: 'brave pilot',
        portrait: { key: 'p1' },
        trainingImages: [{ key: 'p1' }, { key: 'r1' }, { key: 'r2' }],
      },
    });
  });

  it('returns an empty map when no cast qualifies', () => {
    expect(buildCharacterRefs({ cast: [] })).toEqual({});
    expect(buildCharacterRefs({ cast: [{ slot: 'A', name: 'Ada' }] })).toEqual({});
  });
});

describe('buildCastLoras', () => {
  it('maps slot bindings to cast ids', () => {
    expect(buildCastLoras({ A: 'id-a', B: 'id-b' })).toEqual({ A: 'id-a', B: 'id-b' });
    expect(buildCastLoras({})).toEqual({});
  });
});

describe('resolveCastMember', () => {
  const catalog = [{ id: 'uuid-wren', name: 'Wren' }, { id: 'uuid-mara', name: 'Mara' }];
  it('matches by id or name', () => {
    expect(resolveCastMember(catalog, 'uuid-wren')).toEqual(catalog[0]);
    expect(resolveCastMember(catalog, 'wren')).toEqual(catalog[0]);
  });
});

describe('formatPreflightResult', () => {
  it('summarizes ok and blocked runs', () => {
    expect(formatPreflightResult({ ok: true, counts: { error: 0, warning: 1, info: 0 }, issues: [] }))
      .toContain('OK');
    expect(formatPreflightResult({
      ok: false,
      counts: { error: 1, warning: 0, info: 0 },
      issues: [{ level: 'error', message: 'LoRA not ready' }],
    })).toContain('blocked');
  });
});

describe('matchBackend', () => {
  const names = ['CogVideoX', 'LTX', 'Wan22'];

  it('maps auto/default/empty to a null value (omit on submit)', () => {
    expect(matchBackend(names, 'auto')).toEqual({ value: null });
    expect(matchBackend(names, 'default')).toEqual({ value: null });
    expect(matchBackend(names, '')).toEqual({ value: null });
    expect(matchBackend(names, '   ')).toEqual({ value: null });
    expect(matchBackend(names, undefined)).toEqual({ value: null });
  });

  it('matches a name case-insensitively (exact wins)', () => {
    expect(matchBackend(names, 'ltx')).toEqual({ value: 'LTX' });
    expect(matchBackend(names, 'CogVideoX')).toEqual({ value: 'CogVideoX' });
  });

  it('falls back to a substring match', () => {
    expect(matchBackend(names, 'cog')).toEqual({ value: 'CogVideoX' });
    expect(matchBackend(names, 'wan')).toEqual({ value: 'Wan22' });
  });

  it('returns an error with the valid options for an unknown backend', () => {
    const r = matchBackend(names, 'nope');
    expect(r.value).toBeUndefined();
    expect(r.error).toContain('Unknown backend `nope`');
    expect(r.error).toContain('auto, CogVideoX, LTX, Wan22');
  });

  it('reports "(none reported)" when the registry list is empty', () => {
    expect(matchBackend([], 'x').error).toContain('(none reported)');
    expect(matchBackend(undefined, 'x').error).toContain('(none reported)');
  });
});
describe('pickAutoMotionBackend', () => {
  // A GET /api/modules payload: `hooks['motion.backend']` is the serving list the studio already
  // sorts by ui.order then name; `modules[].ui.locality` classifies each (undeclared = cloud).
  const reg = (order, localities) => ({
    hooks: { 'motion.backend': order },
    modules: order.map((name) =>
      localities[name] === undefined ? { name } : { name, ui: { locality: localities[name] } },
    ),
  });

  it('prefers a cloud module over an order-first local gpu-door (the #58 bug)', () => {
    const r = pickAutoMotionBackend(reg(['local-gpu', 'alibaba-wan'], { 'local-gpu': 'local', 'alibaba-wan': 'cloud' }));
    expect(r).toEqual({ value: 'alibaba-wan' });
  });

  it('prefers the operator own-gpu (byo) over a local door when no cloud serves', () => {
    const r = pickAutoMotionBackend(reg(['local-gpu', 'own-gpu'], { 'local-gpu': 'local', 'own-gpu': 'byo' }));
    expect(r).toEqual({ value: 'own-gpu' });
  });

  it('prefers cloud over byo', () => {
    const r = pickAutoMotionBackend(reg(['own-gpu', 'alibaba-wan'], { 'own-gpu': 'byo', 'alibaba-wan': 'cloud' }));
    expect(r).toEqual({ value: 'alibaba-wan' });
  });

  it('within a class, respects the studio ui.order (first serving name wins)', () => {
    const r = pickAutoMotionBackend(reg(['alibaba-wan', 'google-veo'], { 'alibaba-wan': 'cloud', 'google-veo': 'cloud' }));
    expect(r).toEqual({ value: 'alibaba-wan' });
  });

  it('treats an undeclared locality as cloud', () => {
    const r = pickAutoMotionBackend(reg(['own-gpu', 'mystery'], { 'own-gpu': 'byo', 'mystery': undefined }));
    expect(r).toEqual({ value: 'mystery' });
  });

  it('sends a lone local door when it is the only thing serving (pure self-host)', () => {
    const r = pickAutoMotionBackend(reg(['local-gpu'], { 'local-gpu': 'local' }));
    expect(r).toEqual({ value: 'local-gpu' });
  });

  it('classifies as cloud when the modules array is absent', () => {
    const r = pickAutoMotionBackend({ hooks: { 'motion.backend': ['x'] } });
    expect(r).toEqual({ value: 'x' });
  });

  it('errors (never omits) when nothing serves motion.backend', () => {
    expect(pickAutoMotionBackend(reg([], {})).error).toContain('no motion backend');
    expect(pickAutoMotionBackend({}).error).toContain('no motion backend');
    expect(pickAutoMotionBackend({ hooks: {} }).error).toContain('no motion backend');
  });
});


describe('characterRefFromStudioMember (slate #88: ref_keys are {key,mime} objects)', () => {
  it('extracts the R2 key STRING from object ref_keys, never pushes [object Object]', () => {
    const ref = characterRefFromStudioMember({
      name: 'Wren', bible: 'a botanist', portrait_key: 'cast/4/portrait.jpg',
      ref_keys: [{ key: 'cast-gen/4/ref_01.jpg', mime: 'image/jpeg' }, { key: 'cast-gen/4/ref_02.jpg', mime: 'image/jpeg' }],
    }, 'A');
    const keys = ref.trainingImages.map((t) => t.key);
    expect(keys).toEqual(['cast/4/portrait.jpg', 'cast-gen/4/ref_01.jpg', 'cast-gen/4/ref_02.jpg']);
    expect(keys.every((k) => typeof k === 'string')).toBe(true);
    expect(JSON.stringify(ref)).not.toContain('[object Object]');
  });
  it('still accepts bare-string ref_keys (defensive)', () => {
    const ref = characterRefFromStudioMember({ name: 'X', ref_keys: ['cast-gen/9/ref_01.jpg'] }, 'A');
    expect(ref.trainingImages.map((t) => t.key)).toEqual(['cast-gen/9/ref_01.jpg']);
  });
});

describe("pickAutoBind (#84 auto-reuse of trained studio cast)", () => {
  const catalog = [
    { id: "wren-id", name: "Wren", lora_status: "ready" },
    { id: "riff-a", name: "Riff", lora_status: "ready" },
    { id: "riff-b", name: "Riff", lora_status: "failed" },
  ];
  it("binds on a unique exact case-insensitive name match", () => {
    const r = pickAutoBind(catalog, "wren");
    expect(r.status).toBe("bound");
    expect(r.member.id).toBe("wren-id");
  });
  it("is ambiguous when >1 member shares the name -- never auto-picks", () => {
    const r = pickAutoBind(catalog, "Riff");
    expect(r.status).toBe("ambiguous");
    expect(r.matches).toHaveLength(2);
  });
  it("returns none when no member has the name", () => {
    expect(pickAutoBind(catalog, "Nobody").status).toBe("none");
  });
  it("skips a generic placeholder slot name", () => {
    expect(pickAutoBind(catalog, "Character A").status).toBe("skip");
    expect(pickAutoBind(catalog, "").status).toBe("skip");
  });
});

describe("buildCastContextBlock (#84 cast-blind brain fix)", () => {
  const catalog = [
    { id: "w", name: "Wren", lora_status: "ready", voice_id: "asteria" },
    { id: "s", name: "Salvage Robot", lora_status: "ready" },
  ];
  it("lists the studio roster with LoRA readiness", () => {
    const out = buildCastContextBlock({ cast: [], cast_bindings: {} }, catalog);
    expect(out).toContain("Wren -- LoRA ready");
    expect(out).toContain("Salvage Robot -- LoRA ready");
    expect(out).toContain("voice asteria");
  });
  it("marks a bound slot as BOUND and an inline slot as auto-bind", () => {
    const brief = {
      cast: [{ slot: "A", name: "Wren" }, { slot: "B", name: "Nova" }],
      cast_bindings: { A: "w" },
    };
    const out = buildCastContextBlock(brief, catalog);
    expect(out).toMatch(/A: Wren -- BOUND/);
    expect(out).toMatch(/B: Nova -- inline \(auto-binds/);
  });
  it("returns empty string when there is nothing to show", () => {
    expect(buildCastContextBlock({ cast: [], cast_bindings: {} }, [])).toBe("");
  });
  it("tells the brain it can see the cast (anti-confabulation)", () => {
    expect(buildCastContextBlock({ cast: [], cast_bindings: {} }, catalog))
      .toContain("NEVER tell the user to run a command");
  });
});

// The studio storyboard validator (vivijure storyboard-validate.ts) accepts scene.dialogue ONLY as
// { slot, text }; a bare string 400s the bundle. These pin the shape slate puts on the wire so a
// future edit that re-introduces a string dialogue (the audited silent-failure regression) fails CI.
describe("buildStoryboardPayload dialogue shape (studio contract)", () => {
  const refs = { A: { name: "Wren" }, B: { name: "Nova" } };
  it("never emits a bare string dialogue for any scene shape", () => {
    const brief = {
      title: "T",
      scenes: [
        { id: "s1", prompt: "p", character_slots: ["A"], dialogue: "Hello there" },
        { id: "s2", prompt: "p", character_slots: ["A", "B"], dialogue: "Who spoke?" },
        { id: "s3", prompt: "p", character_slots: [], dialogue: "Nobody here" },
        { id: "s4", prompt: "p", character_slots: ["A"] },
      ],
    };
    const out = buildStoryboardPayload(brief, refs);
    for (const sc of out.scenes) {
      expect(typeof sc.dialogue).not.toBe("string");
      if (sc.dialogue !== undefined) {
        expect(sc.dialogue).toMatchObject({ slot: expect.any(String), text: expect.any(String) });
      }
    }
  });
  it("attributes a spoken line to the single speaking slot as { slot, text }", () => {
    const brief = { title: "T", scenes: [{ id: "s1", prompt: "p", character_slots: ["A"], dialogue: "  Hello  " }] };
    const out = buildStoryboardPayload(brief, refs);
    expect(out.scenes[0].dialogue).toEqual({ slot: "A", text: "Hello" });
  });
  it("omits dialogue (no string, no wrong speaker) when the speaker is ambiguous or absent", () => {
    const brief = {
      title: "T",
      scenes: [
        { id: "s1", prompt: "p", character_slots: ["A", "B"], dialogue: "ambiguous" },
        { id: "s2", prompt: "p", character_slots: [], dialogue: "no slots" },
      ],
    };
    const out = buildStoryboardPayload(brief, refs);
    expect(out.scenes[0].dialogue).toBeUndefined();
    expect(out.scenes[1].dialogue).toBeUndefined();
  });
  it("carries the top-level authored fields the studio StoryboardInput reads", () => {
    const brief = {
      title: "My Film", full_prompt: "fp", style_prefix: "sp", style_category: "Noir",
      duration_seconds: 30, clip_seconds: 5,
      scenes: [{ id: "s1", prompt: "p", character_slots: ["A"], act: "I", target_seconds: 4 }],
    };
    const out = buildStoryboardPayload(brief, refs);
    expect(out).toMatchObject({ title: "My Film", full_prompt: "fp", style_prefix: "sp", style_category: "Noir", duration_seconds: 30, clip_seconds: 5 });
    expect(out.use_characters).toEqual(["A"]);
    expect(out.scenes[0]).toMatchObject({ id: "s1", prompt: "p", act: "I", character_slots: ["A"], target_seconds: 4 });
  });
});

describe("isRenderCommand", () => {
  it("matches the bare command and the command followed by whitespace-delimited args", () => {
    expect(isRenderCommand("!render")).toBe(true);
    expect(isRenderCommand("!render now")).toBe(true);
    expect(isRenderCommand("!render draft")).toBe(true);
    expect(isRenderCommand("!render\tfinal")).toBe(true);
  });
  it("does not match sibling commands that merely share the !render prefix", () => {
    for (const t of ["!renders", "!renders 5", "!rendertags", "!renderpoll abc", "!render-plan", "!render-keyframes now", "!render-keyframes"]) {
      expect(isRenderCommand(t), t).toBe(false);
    }
  });
  it("does not match unrelated text", () => {
    expect(isRenderCommand("render")).toBe(false);
    expect(isRenderCommand("!ship")).toBe(false);
    expect(isRenderCommand("")).toBe(false);
  });
});

describe('gatePreflight (paid-render guard, slate#183)', () => {
  const okResult = { ok: true, counts: { error: 0, warning: 0, info: 0 }, issues: [] };
  const noResult = { ok: false, counts: { error: 1, warning: 0, info: 0 }, issues: [{ level: 'error', message: 'LoRA not ready' }] };

  it('case 1: preflight ran and said NO -> refuse with the issues', async () => {
    const r = await gatePreflight(async () => ({ ok: true, result: noResult }));
    expect(r?.ok).toBe(false);
    expect(r?.error).toContain('blocked');
    expect(r?.preflight).toEqual(noResult);
  });

  it('case 2 (control): preflight ran and said YES -> proceed', async () => {
    expect(await gatePreflight(async () => ({ ok: true, result: okResult }))).toBeNull();
  });

  it('case 3a: preflight call threw -> refuse', async () => {
    const r = await gatePreflight(async () => { throw new Error('connect ECONNREFUSED'); });
    expect(r?.ok).toBe(false);
    expect(r?.error).toContain('did not start');
  });

  it('case 3b: preflight timed out (TimeoutError rejection) -> refuse', async () => {
    const r = await gatePreflight(() => Promise.reject(Object.assign(new Error('The operation timed out'), { name: 'TimeoutError' })));
    expect(r?.ok).toBe(false);
    expect(r?.error).toContain('did not start');
  });

  it('case 3c: studio answered with an error (5xx/401 shape) -> refuse with that error', async () => {
    const r = await gatePreflight(async () => ({ ok: false, error: 'the studio hit a server error trying to run preflight (503).' }));
    expect(r?.ok).toBe(false);
    expect(r?.error).toContain('server error');
  });

  it('case 3d: preflight returned nothing or no verdict -> refuse', async () => {
    expect((await gatePreflight(async () => null))?.ok).toBe(false);
    expect((await gatePreflight(async () => undefined))?.ok).toBe(false);
    expect((await gatePreflight(async () => ({ ok: true, result: {} })))?.ok).toBe(false);
    expect((await gatePreflight(async () => ({ ok: true })))?.ok).toBe(false);
  });
});