import { test } from 'node:test'; import assert from 'node:assert/strict'; import { clampEdits, buildTweakPrompt, TweakResultSchema, ALLOWED_KEYS } from '../src/llm/spec.js'; import { LLM_BUDGET, getBudgetCounts, canInvoke, remainingText } from '../src/llm/budget.js'; import { LLM_DEFAULTS, LLM_STORAGE_KEY } from '../src/llm/config.js'; test('clampEdits clamps to valid ranges and drops invalid arrangement', () => { const out = clampEdits({ bpm: 300, brightness: 1.5, noise: -1, arrangement: 'nope', dynamics: 0.1234 }); assert.equal(out.bpm, 180); assert.equal(out.brightness, 1); assert.equal(out.noise, 0); assert.equal(out.arrangement, undefined); assert.equal(out.dynamics, 0.12); }); test('clampEdits preserves noiseColour null and rounds', () => { assert.deepEqual(clampEdits({ noiseColour: null }), { noiseColour: null }); assert.deepEqual(clampEdits({ noiseColour: 0.3333 }), { noiseColour: 0.33 }); assert.deepEqual(clampEdits({ arrangement: 'classic' }), { arrangement: 'classic' }); }); test('ALLOWED_KEYS covers the 7 song knobs', () => { assert.deepEqual([...ALLOWED_KEYS].sort(), ['arrangement', 'bpm', 'brightness', 'dynamics', 'noise', 'noiseColour', 'transition'].sort()); }); test('buildTweakPrompt returns system+user messages and respects hint', () => { const fakeDigest = { describe: 'seed abc · geometer · triad', director: 'geometer', paletteScheme: 'triad', sections: [{ index: 0, kind: 'intro', name: 'Shot', layers: 'Shot' }], validation: null, }; const msgs = buildTweakPrompt({ spec: { bpm: 124, brightness: 0.5, noise: 0.2, noiseColour: null, dynamics: 0.55, transition: 0.6, arrangement: 'classic', duration: 120, seed: 42 }, track: { summary: { bpm: 124, meanCentroid: 0.5, meanFlatness: 0.2, dynamicRange: 0.55, meanLoudness: 0.4, tempoConfidence: 0.9 }, sections: [{ kind: 'intro', start: 0, end: 15, energy: 0.2 }], duration: 120 }, lookDigest: fakeDigest, hint: 'make the drop calmer', }); assert.equal(msgs.length, 2); assert.equal(msgs[0].role, 'system'); assert.equal(msgs[1].role, 'user'); assert.ok(msgs[0].content.includes('make the drop calmer') || msgs[0].content.includes('User hint')); assert.ok(msgs[1].content.includes('songSpec')); }); test('buildTweakPrompt truncates long hint in system', () => { const long = 'x'.repeat(1000); const msgs = buildTweakPrompt({ spec: null, track: null, lookDigest: null, hint: long }); assert.ok(msgs[0].content.length < 5000); }); test('TweakResultSchema validates a minimal valid result', () => { const parsed = TweakResultSchema.parse({ rationale: 'Lower noise to calm the drop.', edits: { bpm: null, brightness: null, noise: 0.3, noiseColour: null, dynamics: null, transition: null, arrangement: null }, confidence: 0.8, }); assert.equal(parsed.edits.noise, 0.3); assert.equal(parsed.edits.bpm, null); assert.ok(parsed.rationale.length > 0); }); test('budget defaults are sane and canInvoke reports not-configured without a key', () => { assert.equal(LLM_BUDGET.perLoad, 3); assert.equal(LLM_BUDGET.perSession, 10); assert.equal(LLM_STORAGE_KEY, 'flow-state:llm'); assert.equal(LLM_DEFAULTS.gatewayUrl, 'https://ai-gateway.vercel.sh/v1'); const counts = getBudgetCounts(); assert.ok(counts.perLoad === 3 && counts.perSession === 10); assert.ok(typeof counts.loadCount === 'number' && typeof counts.sessionCount === 'number'); const gate = canInvoke(); assert.equal(gate.ok, false); assert.equal(gate.reason, 'not-configured'); assert.ok(typeof remainingText() === 'string'); });