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, emptyFraming, emptyFeature, cloneSceneForNext, normalizePromptSequence, normalizeSectionOrder, DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER, ensureSceneStatesForCharacters, stripBangs, extractBangPhrases, compileCharacterForFacing, defaultVisibilityForGroup } = await import(pathToFileURL(tmp).href) unlinkSync(tmp) test('formatCharacterLine copies 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' ) assert.equal(formatCharacterLine({ name: '', description: 'tall' }), 'tall') assert.ok(!formatCharacterLine({ name: '', description: 'tall' }).includes(':')) assert.equal(formatCharacterLine({ name: 'Ada', description: '' }), 'Ada') }) test('stripBangs removes inline ignore spans and cleans commas', () => { assert.equal(stripBangs('young woman, !brown eyes!, linen robe'), 'young woman, linen robe') assert.equal(stripBangs('a, !x!, b'), 'a, b') assert.deepEqual(extractBangPhrases('young woman, !brown eyes!, linen robe'), ['brown eyes']) }) test('selecting a framing copies only that framing text; labels never appear', () => { const wide = { ...emptyFraming('wide'), id: 'f-wide', text: 'open courtyard at dawn' } const medium = { ...emptyFraming('medium'), id: 'f-med', text: 'courtyard from the arch' } const narrow = { ...emptyFraming('narrow'), id: 'f-nar', text: 'door latch detail' } const set = { ...emptySet(), id: 's1', name: 'Courtyard', framings: [wide, medium, narrow] } const base = { style: 'style', settings: 'settings', characterStates: [], prompt: 'prompt' } const mediumText = assembleSceneText({ ...base, setStates: [{ setId: 's1', on: true, framingId: 'f-med' }] }, [], [set]) assert.equal(mediumText, 'style\n\nsettings\n\ncourtyard from the arch\n\nprompt') assert.ok(!mediumText.includes('open courtyard')) assert.ok(!mediumText.includes('door latch')) assert.ok(!mediumText.includes('Courtyard')) assert.ok(!mediumText.includes('wide')) assert.ok(!mediumText.includes('medium')) assert.ok(!mediumText.includes('narrow')) const narrowText = assembleSceneText({ ...base, setStates: [{ setId: 's1', on: true, framingId: 'f-nar' }] }, [], [set]) assert.equal(narrowText, 'style\n\nsettings\n\ndoor latch detail\n\nprompt') assert.ok(!narrowText.includes('open courtyard')) assert.ok(!narrowText.includes('from the arch')) const noneText = assembleSceneText({ ...base, setStates: [{ setId: 's1', on: true, framingId: null }] }, [], [set]) assert.equal(noneText, 'style\n\nsettings\n\nprompt') }) test('off set is omitted; sequence join is double blank', () => { const framing = { ...emptyFraming('wide'), id: 'f1', text: 'set text' } const set = { ...emptySet(), id: 's1', name: 'Place', framings: [framing] } const scene1 = { style: 'a', settings: '', setStates: [{ setId: 's1', on: true, framingId: 'f1' }], characterStates: [], prompt: 'p1' } const scene2 = { style: 'b', settings: '', setStates: [{ setId: 's1', on: false, framingId: 'f1' }], 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 text\n\np1\n\n\nb\n\np2') assert.ok(!full.includes('Place')) assert.ok(!full.includes('wide')) }) test('legacy beat-only set still opens as one wide framing', () => { const legacy = normalizePromptSequence({ id: 'seq1', name: 'Old beats', sets: [{ id: 's1', name: 'Hall', description: 'stone walls', beats: [ { id: 'b1', name: 'Beat1', text: 'first beat', framings: ['wide'] }, { id: 'b2', name: 'Beat2', text: 'second beat', framings: ['medium'] } ] }], scenes: [{ id: 'sc1', title: 'Scene 1', style: 'oil', settings: '', shotSize: 'wide', setStates: [{ setId: 's1', on: true }], prompt: 'walk', characters: [] }], characters: [{ id: 'x', name: 'Ada', description: 'tall', outfits: [] }], createdAt: 1, updatedAt: 1 }) assert.equal(legacy.sets.length, 1) assert.equal(legacy.sets[0].framings.length, 1) assert.equal(legacy.sets[0].framings[0].label, 'wide') assert.equal(legacy.sets[0].framings[0].facing, 'toward') assert.ok(legacy.sets[0].framings[0].text.includes('stone walls')) assert.ok(legacy.sets[0].framings[0].text.includes('first beat')) assert.ok(legacy.sets[0].framings[0].text.includes('second beat')) assert.equal(legacy.scenes[0].setStates[0].framingId, legacy.sets[0].framings[0].id) const text = assembleSequenceText(legacy) assert.ok(text.includes('stone walls')) assert.ok(text.includes('first beat')) assert.ok(!text.includes('Hall')) assert.ok(!text.includes('Beat1')) }) 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(assembleSequenceText(legacy), 'oil\n\nAda: tall\n\nwalk') }) test('cloneSceneForNext copies setStates including framingId', () => { const sets = [emptySet()] const first = emptyScene(1, [], sets) first.setStates[0].on = false first.setStates[0].framingId = sets[0].framings[0].id const next = cloneSceneForNext(first, 2) assert.equal(next.setStates[0].on, false) assert.equal(next.setStates[0].framingId, sets[0].framings[0].id) 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: '', 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) }) test('sectionOrder settings then prompt then characters; empty style and set omitted', () => { const ada = { ...emptyCharacter(), id: 'c1', name: 'Ada', description: 'tall', outfits: [] } const set = { ...emptySet(), id: 's1', name: 'Courtyard', framings: [{ ...emptyFraming('wide'), id: 'f1', text: 'open yard' }] } const scene = { style: '', settings: 'soft light', setStates: [{ setId: 's1', on: true, framingId: null }], characterStates: [{ characterId: 'c1', on: true, outfitId: null }], prompt: 'looks up' } const order = ['settings', 'prompt', 'characters'] const text = assembleSceneText(scene, [ada], [set], order) assert.equal(text, 'soft light\n\nlooks up\n\nAda: tall') assert.ok(!text.includes('open yard')) assert.ok(!text.includes('Courtyard')) assert.ok(!text.includes('wide')) }) test('legacy sequence without sectionOrder keeps default order and \\n\\n\\n join', () => { const legacy = normalizePromptSequence({ id: 'seq1', name: 'Old order', characters: [{ id: 'c1', name: 'Ada', description: 'tall', outfits: [] }], sets: [{ id: 's1', name: 'Hall', framings: [{ id: 'f1', label: 'wide', text: 'stone hall' }] }], scenes: [ { id: 'sc1', title: 'One', style: 'oil', settings: 'day', setStates: [{ setId: 's1', on: true, framingId: 'f1' }], characterStates: [{ characterId: 'c1', on: true, outfitId: null }], prompt: 'walk' }, { id: 'sc2', title: 'Two', style: 'ink', settings: '', setStates: [{ setId: 's1', on: false, framingId: 'f1' }], characterStates: [{ characterId: 'c1', on: true, outfitId: null }], prompt: 'stop' } ], createdAt: 1, updatedAt: 1 }) assert.deepEqual(legacy.sectionOrder, [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]) assert.deepEqual(normalizeSectionOrder(undefined), [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]) assert.deepEqual(normalizeSectionOrder(['prompt', 'prompt', 'bogus', 'settings']), [ 'prompt', 'settings', 'style', 'set', 'characters' ]) const full = assembleSequenceText(legacy) assert.equal( full, 'oil\n\nday\n\nstone hall\n\nAda: tall\n\nwalk\n\n\nink\n\nAda: tall\n\nstop' ) assert.ok(!full.includes('Hall')) assert.ok(!full.includes('wide')) }) test('bang strip applies to set framing and character text before join', () => { const eyes = { ...emptyFeature('eyes'), id: 'feat-eyes', text: '!brown eyes!', enabled: true, visibility: defaultVisibilityForGroup('eyes') } const ada = { ...emptyCharacter(), id: 'c1', name: 'Ada', description: 'young woman, !scar!, linen robe', features: [eyes] } const set = { ...emptySet(), id: 's1', name: 'Yard', framings: [{ ...emptyFraming('wide'), id: 'f1', text: 'courtyard, !prop note!, dusk', facing: 'toward' }] } const text = assembleSceneText({ style: '', settings: '', setStates: [{ setId: 's1', on: true, framingId: 'f1' }], characterStates: [{ characterId: 'c1', on: true, outfitId: null }], prompt: '' }, [ada], [set]) assert.equal(text, 'courtyard, dusk\n\nAda: young woman, linen robe') assert.ok(!text.includes('brown eyes')) assert.ok(!text.includes('scar')) assert.ok(!text.includes('prop note')) assert.ok(!text.includes('!')) assert.ok(!text.includes('Yard')) }) test('facing away drops eyes; feature stays omitted with camera reason; out emits no character', () => { const eyes = { ...emptyFeature('eyes'), id: 'feat-eyes', text: 'brown eyes', enabled: true, visibility: defaultVisibilityForGroup('eyes') } const hairBack = { ...emptyFeature('hair back'), id: 'feat-hair', text: 'long braid', enabled: true, visibility: defaultVisibilityForGroup('hair back') } const clothing = { ...emptyFeature('clothing'), id: 'feat-clothes', text: 'linen robe', enabled: true, visibility: defaultVisibilityForGroup('clothing') } const ada = { ...emptyCharacter(), id: 'c1', name: 'Ada', description: 'young woman', features: [eyes, hairBack, clothing] } const away = compileCharacterForFacing(ada, 'away') assert.equal(away.text, 'Ada: young woman, long braid, linen robe') assert.equal(away.omittedFeatures.length, 1) assert.equal(away.omittedFeatures[0].text, 'brown eyes') assert.equal(away.omittedFeatures[0].reason, 'camera') const toward = compileCharacterForFacing(ada, 'toward') assert.equal(toward.text, 'Ada: young woman, brown eyes, linen robe') assert.ok(toward.omittedFeatures.some(item => item.text === 'long braid')) const hands = compileCharacterForFacing(ada, 'hands') assert.equal(hands.text, 'Ada: young woman, linen robe') assert.ok(hands.omittedFeatures.some(item => item.text === 'brown eyes')) const out = compileCharacterForFacing(ada, 'out') assert.equal(out.text, '') assert.equal(out.omittedFeatures.length, 3) const set = { ...emptySet(), id: 's1', name: 'Hall', framings: [{ ...emptyFraming('away'), id: 'f-away', text: 'stone hall', facing: 'away' }] } const sceneText = assembleSceneText({ style: '', settings: '', setStates: [{ setId: 's1', on: true, framingId: 'f-away' }], characterStates: [{ characterId: 'c1', on: true, outfitId: null }], prompt: 'walks' }, [ada], [set]) assert.equal(sceneText, 'stone hall\n\nAda: young woman, long braid, linen robe\n\nwalks') assert.ok(!sceneText.includes('brown eyes')) assert.ok(!sceneText.includes('Hall')) }) test('Britt line includes name, colon, description, and outfit; nameless has no stray colon', () => { const britt = { ...emptyCharacter(), id: 'c-britt', name: 'Britt', description: 'a beautiful 40 year old woman with strawberry blond hair', features: [] } const gown = { ...emptyOutfit(), id: 'o1', name: 'gown', description: 'a purple evening gown' } const line = compileCharacterForFacing(britt, 'toward', gown).text assert.equal( line, 'Britt: a beautiful 40 year old woman with strawberry blond hair. Outfit: a purple evening gown' ) assert.ok(line.includes('Britt')) assert.ok(line.includes(':')) assert.ok(line.includes('strawberry blond hair')) assert.ok(line.includes('Outfit: a purple evening gown')) const nameless = compileCharacterForFacing({ ...emptyCharacter(), name: '', description: 'a figure in shadow' }, 'toward').text assert.equal(nameless, 'a figure in shadow') assert.ok(!nameless.includes(':')) })