import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync, writeFileSync, unlinkSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { pathToFileURL } from 'node:url' import ts from 'typescript' const src = readFileSync(new URL('../utils/promptSequences.ts', import.meta.url), 'utf8') const js = ts.transpileModule(src, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText const tmp = join(tmpdir(), `prompt-sequences-${process.pid}.mjs`) writeFileSync(tmp, js) const { assembleSceneText, assembleSequenceText, formatCharacterLine, emptyScene, emptyCharacter, emptyOutfit, emptySet, emptyBeat, cloneSceneForNext, normalizePromptSequence, ensureSceneStatesForCharacters, beatMatchesShotSize } = await import(pathToFileURL(tmp).href) unlinkSync(tmp) test('formatCharacterLine covers name/description and outfit', () => { assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'Ada: tall') assert.equal( formatCharacterLine({ name: 'Ada', description: 'tall' }, { name: 'Work', description: 'blue jacket' }), 'Ada: tall. Outfit: blue jacket' ) }) test('shot size filters beats; untagged always included; tags never copied', () => { const set = { ...emptySet(), id: 's1', name: 'BadgeSet', description: 'studio backdrop', beats: [ { ...emptyBeat(), id: 'b1', name: 'WideBeat', text: 'wide only line', framings: ['wide'] }, { ...emptyBeat(), id: 'b2', name: 'WideMed', text: 'wide and medium', framings: ['wide', 'medium'] }, { ...emptyBeat(), id: 'b3', name: 'Always', text: 'untagged beat', framings: [] }, { ...emptyBeat(), id: 'b4', name: 'CU', text: 'close only', framings: ['close-up'] } ] } const sceneMedium = { style: 'style', settings: 'settings', shotSize: 'medium', setStates: [{ setId: 's1', on: true }], characterStates: [], prompt: 'prompt' } const mediumText = assembleSceneText(sceneMedium, [], [set]) assert.equal( mediumText, 'style\n\nsettings\n\nstudio backdrop\nwide and medium\nuntagged beat\n\nprompt' ) assert.ok(!mediumText.includes('wide only')) assert.ok(!mediumText.includes('close only')) assert.ok(!mediumText.includes('BadgeSet')) assert.ok(!mediumText.includes('WideBeat')) assert.ok(!mediumText.includes('close-up')) assert.ok(!/\bwide\b/.test(mediumText.replace('wide and medium', ''))) const sceneClose = { ...sceneMedium, shotSize: 'close-up' } const closeText = assembleSceneText(sceneClose, [], [set]) assert.equal( closeText, 'style\n\nsettings\n\nstudio backdrop\nuntagged beat\nclose only\n\nprompt' ) const sceneAny = { ...sceneMedium, shotSize: '' } const anyText = assembleSceneText(sceneAny, [], [set]) assert.ok(anyText.includes('wide only line')) assert.ok(anyText.includes('wide and medium')) assert.ok(anyText.includes('untagged beat')) assert.ok(anyText.includes('close only')) assert.equal(beatMatchesShotSize({ framings: ['wide'] }, 'medium'), false) assert.equal(beatMatchesShotSize({ framings: [] }, 'medium'), true) }) test('off set omitted; copy scene is one scene; sequence join is double blank', () => { const set = { ...emptySet(), id: 's1', description: 'set desc', beats: [{ ...emptyBeat(), text: 'beat', framings: [] }] } const scene1 = { style: 'a', settings: '', shotSize: '', setStates: [{ setId: 's1', on: true }], characterStates: [], prompt: 'p1' } const scene2 = { style: 'b', settings: '', shotSize: '', setStates: [{ setId: 's1', on: false }], characterStates: [], prompt: 'p2' } assert.equal(assembleSceneText(scene2, [], [set]), 'b\n\np2') const full = assembleSequenceText({ sets: [set], scenes: [scene1, scene2] }) assert.equal(full, 'a\n\nset desc\nbeat\n\np1\n\n\nb\n\np2') assert.equal(assembleSceneText(scene1, [], [set]), 'a\n\nset desc\nbeat\n\np1') }) test('legacy sequence without sets still assembles', () => { const legacy = normalizePromptSequence({ id: 'seq1', name: 'Old', scenes: [ { id: 'sc1', title: 'Scene 1', style: 'oil', settings: '', prompt: 'walk', characters: [{ id: 'x', name: 'Ada', description: 'tall' }] } ], createdAt: 1, updatedAt: 1 }) assert.deepEqual(legacy.sets, []) assert.equal(legacy.scenes[0].shotSize, '') const text = assembleSequenceText(legacy) assert.equal(text, 'oil\n\nAda: tall\n\nwalk') }) test('cloneSceneForNext copies shotSize and setStates', () => { const sets = [emptySet()] const first = emptyScene(1, [], sets) first.shotSize = 'close-up' first.setStates[0].on = false const next = cloneSceneForNext(first, 2) assert.equal(next.shotSize, 'close-up') assert.equal(next.setStates[0].on, false) assert.equal(next.setStates[0].setId, sets[0].id) }) test('character outfit and off still work with sets', () => { const ada = { ...emptyCharacter(), id: 'c1', name: 'Ada', description: 'tall', outfits: [{ ...emptyOutfit(), id: 'o1', description: 'red dress' }] } const text = assembleSceneText({ style: '', settings: '', shotSize: '', setStates: [], characterStates: [{ characterId: 'c1', on: true, outfitId: 'o1' }], prompt: 'hi' }, [ada], []) assert.equal(text, 'Ada: tall. Outfit: red dress\n\nhi') }) test('ensureSceneStatesForCharacters turns new character on only for current scene', () => { const a = emptyCharacter() const scenes = [emptyScene(1, [a]), emptyScene(2, [a])] const b = emptyCharacter() ensureSceneStatesForCharacters(scenes, [a, b], { newCharacterId: b.id, turnOnSceneIndex: 1 }) assert.equal(scenes[0].characterStates.find(s => s.characterId === b.id).on, false) assert.equal(scenes[1].characterStates.find(s => s.characterId === b.id).on, true) })