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, cloneSceneForNext, normalizePromptSequence, ensureSceneStatesForCharacters } = 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: '' }), 'Ada') assert.equal(formatCharacterLine({ name: '', description: 'tall' }), 'tall') assert.equal(formatCharacterLine({ name: '', description: '' }), '') assert.equal( formatCharacterLine({ name: 'Ada', description: 'tall' }, { name: 'Work', description: 'blue jacket' }), 'Ada: tall. Outfit: blue jacket' ) }) test('assembleSceneText joins non-empty boxes with a single blank line', () => { const text = assembleSceneText({ style: 'oil paint', settings: 'night', characters: [ { name: 'Ada', description: 'tall' }, { name: '', description: '' }, { name: 'Bo', description: 'hat' } ], prompt: 'walks' }) assert.equal(text, 'oil paint\n\nnight\n\nAda: tall\nBo: hat\n\nwalks') }) test('off characters and active outfits affect assembly', () => { const ada = { ...emptyCharacter(), id: 'c1', name: 'Ada', description: 'tall', outfits: [ { ...emptyOutfit(), id: 'o1', name: 'Day', description: 'red dress' }, { ...emptyOutfit(), id: 'o2', name: 'Night', description: 'black coat' } ] } const bo = { ...emptyCharacter(), id: 'c2', name: 'Bo', description: 'hat', outfits: [] } const scene1 = { style: 's1', settings: '', prompt: 'p1', characterStates: [ { characterId: 'c1', on: true, outfitId: 'o1' }, { characterId: 'c2', on: true, outfitId: null } ] } const scene2 = { style: 's2', settings: '', prompt: 'p2', characterStates: [ { characterId: 'c1', on: false, outfitId: 'o1' }, { characterId: 'c2', on: true, outfitId: null } ] } assert.equal( assembleSceneText(scene1, [ada, bo]), 's1\n\nAda: tall. Outfit: red dress\nBo: hat\n\np1' ) assert.equal( assembleSceneText(scene2, [ada, bo]), 's2\n\nBo: hat\n\np2' ) const full = assembleSequenceText({ characters: [ada, bo], scenes: [scene1, scene2] }) assert.equal(full, 's1\n\nAda: tall. Outfit: red dress\nBo: hat\n\np1\n\n\ns2\n\nBo: hat\n\np2') assert.ok(!full.includes('not present')) }) test('assembleSequenceText uses double blank line between scenes and skips empties', () => { const text = assembleSequenceText({ name: 'ignored', scenes: [ { style: 's1', settings: '', characters: [], prompt: 'p1' }, { style: '', settings: '', characters: [{ name: '', description: '' }], prompt: '' }, { style: 's2', settings: 'set2', characters: [{ name: 'C', description: 'd' }], prompt: 'p2' } ] }) assert.equal(text, 's1\n\np1\n\n\ns2\n\nset2\n\nC: d\n\np2') assert.ok(!text.includes('ignored')) }) test('legacy sequences migrate to sequence characters all on with no outfits', () => { const legacy = normalizePromptSequence({ id: 'seq1', name: 'Old', scenes: [ { id: 'sc1', title: 'Scene 1', style: 'a', settings: '', prompt: 'p', characters: [{ id: 'x', name: 'Ada', description: 'tall' }] }, { id: 'sc2', title: 'Scene 2', style: 'b', settings: '', prompt: 'q', characters: [{ id: 'x', name: 'Ada', description: 'tall' }] } ], createdAt: 1, updatedAt: 1 }) assert.equal(legacy.characters.length, 1) assert.equal(legacy.characters[0].name, 'Ada') assert.deepEqual(legacy.characters[0].outfits, []) assert.equal(legacy.scenes[0].characterStates[0].on, true) assert.equal(legacy.scenes[0].characterStates[0].outfitId, null) assert.equal(legacy.scenes[1].characterStates[0].on, true) }) test('cloneSceneForNext copies toggles and outfit choices', () => { const chars = [emptyCharacter()] const first = emptyScene(1, chars) first.style = 'a' first.characterStates[0].on = false first.characterStates[0].outfitId = 'o9' const next = cloneSceneForNext(first, 2) assert.equal(next.title, 'Scene 2') assert.equal(next.style, 'a') assert.equal(next.characterStates[0].on, false) assert.equal(next.characterStates[0].outfitId, 'o9') assert.notEqual(next.id, first.id) }) test('ensureSceneStatesForCharacters turns new character on only for current scene', () => { const a = emptyCharacter() const scenes = [emptyScene(1, [a]), emptyScene(2, [a])] const b = emptyCharacter() const list = [a, b] ensureSceneStatesForCharacters(scenes, list, { 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) })