import { describe, expect, it } from 'vitest'; import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json'; import { BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS, BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS, BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS, BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS, canCompleteBackgroundMusicPrompt, canGenerateBackgroundMusicFromPrompt, canonicalizeBackgroundMusicPrompt, canSimplifyBackgroundMusicPrompt, countEffectivePromptCodePoints, countPromptCodePoints, createBackgroundMusicPromptStateModel, } from './ImageCanvasBackgroundMusicPromptModel'; describe('ImageCanvasBackgroundMusicPromptModel', () => { it.each(backgroundMusicPromptCanonicalizationCases)( 'canonicalizes shared fixture case $name', ({ input, prompt, charCount, effectiveCharCount }) => { expect(canonicalizeBackgroundMusicPrompt(input)).toBe(prompt); expect(countPromptCodePoints(input)).toBe(charCount); expect(countEffectivePromptCodePoints(input)).toBe(effectiveCharCount); expect(canonicalizeBackgroundMusicPrompt(prompt)).toBe(prompt); }, ); it('allows formal generation for 1-200 code points with effective content', () => { expect(BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS).toBe(200); expect(BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS).toBe( 1, ); expect(canGenerateBackgroundMusicFromPrompt('')).toBe(false); expect(canGenerateBackgroundMusicFromPrompt(' \r\n\u0085')).toBe(false); expect(canGenerateBackgroundMusicFromPrompt('乐')).toBe(true); expect(canGenerateBackgroundMusicFromPrompt('😀'.repeat(199))).toBe(true); expect(canGenerateBackgroundMusicFromPrompt('😀'.repeat(200))).toBe(true); expect(canGenerateBackgroundMusicFromPrompt('😀'.repeat(201))).toBe(false); }); it('requires 2 effective code points and at most 200 total for AI completion', () => { expect(BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS).toBe( 2, ); expect(canCompleteBackgroundMusicPrompt(' \r\n\u0085')).toBe(false); expect(canCompleteBackgroundMusicPrompt('乐')).toBe(false); expect(canCompleteBackgroundMusicPrompt('乐 ')).toBe(false); expect(canCompleteBackgroundMusicPrompt('乐曲')).toBe(true); expect(canCompleteBackgroundMusicPrompt('😀'.repeat(200))).toBe(true); expect(canCompleteBackgroundMusicPrompt('😀'.repeat(201))).toBe(false); }); it('allows simplification only for a non-empty canonical prompt with 201-2000 code points', () => { expect(BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CODE_POINTS).toBe(2000); expect(canSimplifyBackgroundMusicPrompt(' \r\n\u0085')).toBe(false); expect(canSimplifyBackgroundMusicPrompt('A'.repeat(200))).toBe(false); expect(canSimplifyBackgroundMusicPrompt('A'.repeat(201))).toBe(true); expect(canSimplifyBackgroundMusicPrompt('A'.repeat(2000))).toBe(true); expect(canSimplifyBackgroundMusicPrompt('A'.repeat(2001))).toBe(false); }); it.each([200, 201])( 'canonicalizes %i Unicode White_Space code points to an empty disabled prompt', (length) => { const prompt = '\u0085'.repeat(length); expect(canonicalizeBackgroundMusicPrompt(prompt)).toBe(''); expect(countPromptCodePoints(prompt)).toBe(0); expect(countEffectivePromptCodePoints(prompt)).toBe(0); expect(canGenerateBackgroundMusicFromPrompt(prompt)).toBe(false); expect(canCompleteBackgroundMusicPrompt(prompt)).toBe(false); expect(canSimplifyBackgroundMusicPrompt(prompt)).toBe(false); }, ); it('uses the canonical prompt for generation, completion, and simplification states', () => { const boundaryWhiteSpaceAroundA = `${'\u0085'.repeat(201)}A${'\u3000'.repeat(201)}`; const overLimitWithInternalWhiteSpace = `A${' '.repeat(199)}B`; const boundaryWhiteSpaceAround200Characters = `${'\u0085'.repeat(201)}${'A'.repeat(200)}${'\u3000'.repeat(201)}`; expect(canonicalizeBackgroundMusicPrompt(boundaryWhiteSpaceAroundA)).toBe( 'A', ); expect(countPromptCodePoints(boundaryWhiteSpaceAroundA)).toBe(1); expect(countEffectivePromptCodePoints(boundaryWhiteSpaceAroundA)).toBe(1); expect( canGenerateBackgroundMusicFromPrompt(boundaryWhiteSpaceAroundA), ).toBe(true); expect(canCompleteBackgroundMusicPrompt(boundaryWhiteSpaceAroundA)).toBe( false, ); expect(canSimplifyBackgroundMusicPrompt(boundaryWhiteSpaceAroundA)).toBe( false, ); expect(countPromptCodePoints(overLimitWithInternalWhiteSpace)).toBe(201); expect( countEffectivePromptCodePoints(overLimitWithInternalWhiteSpace), ).toBe(2); expect( canGenerateBackgroundMusicFromPrompt(overLimitWithInternalWhiteSpace), ).toBe(false); expect( canCompleteBackgroundMusicPrompt(overLimitWithInternalWhiteSpace), ).toBe(false); expect( canSimplifyBackgroundMusicPrompt(overLimitWithInternalWhiteSpace), ).toBe(true); expect(countPromptCodePoints(boundaryWhiteSpaceAround200Characters)).toBe( 200, ); expect( countEffectivePromptCodePoints(boundaryWhiteSpaceAround200Characters), ).toBe(200); expect( canGenerateBackgroundMusicFromPrompt( boundaryWhiteSpaceAround200Characters, ), ).toBe(true); expect( canCompleteBackgroundMusicPrompt(boundaryWhiteSpaceAround200Characters), ).toBe(true); expect( canSimplifyBackgroundMusicPrompt(boundaryWhiteSpaceAround200Characters), ).toBe(false); }); it.each(['\u200B', '\uFEFF'])( 'keeps 201 %s code points and enables only simplification', (codePoint) => { const prompt = codePoint.repeat(201); expect(canonicalizeBackgroundMusicPrompt(prompt)).toBe(prompt); expect(countPromptCodePoints(prompt)).toBe(201); expect(countEffectivePromptCodePoints(prompt)).toBe(201); expect(canGenerateBackgroundMusicFromPrompt(prompt)).toBe(false); expect(canCompleteBackgroundMusicPrompt(prompt)).toBe(false); expect(canSimplifyBackgroundMusicPrompt(prompt)).toBe(true); }, ); }); describe('background music Prompt dialog state model', () => { it('claims an operation synchronously, ignores same-action double clicks, and invalidates an older operation', () => { const model = createBackgroundMusicPromptStateModel(); const completion = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: ' \u0085森林冒险\u3000', }); expect(completion).toMatchObject({ started: true, reason: 'started', canonicalPrompt: '森林冒险', state: { dialogId: 'dialog-a', status: 'completing', undoPromptSnapshot: null, temporaryPromptSnapshot: '森林冒险', }, }); expect(completion.operation).not.toBeNull(); const duplicate = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '另一份有效输入', }); expect(duplicate).toMatchObject({ started: false, reason: 'duplicate', canonicalPrompt: '森林冒险', operation: null, }); expect(duplicate.state.operationId).toBe(completion.operation?.operationId); const simplification = model.beginOperation({ dialogId: 'dialog-a', status: 'simplifying', prompt: '长'.repeat(201), }); expect(simplification).toMatchObject({ started: true, reason: 'started', canonicalPrompt: '长'.repeat(201), state: { status: 'simplifying', temporaryPromptSnapshot: '长'.repeat(201), }, }); expect(simplification.operation?.operationId).not.toBe( completion.operation?.operationId, ); if (!completion.operation) { throw new Error('expected completion operation'); } expect( model.resolveAiOperation(completion.operation, '迟到的补全结果'), ).toMatchObject({ applied: false, prompt: null, state: { status: 'simplifying', operationId: simplification.operation?.operationId, }, }); }); it('isolates active operations and snapshots by dialog ID', () => { const model = createBackgroundMusicPromptStateModel(); const completion = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '森林冒险', }); const simplification = model.beginOperation({ dialogId: 'dialog-b', status: 'simplifying', prompt: '长'.repeat(201), }); if (!completion.operation || !simplification.operation) { throw new Error('expected both dialog operations'); } expect( model.resolveAiOperation(completion.operation, '森林冒险补全结果'), ).toMatchObject({ applied: true, prompt: '森林冒险补全结果', state: { dialogId: 'dialog-a', status: 'idle', undoPromptSnapshot: '森林冒险', }, }); expect(model.getDialogState('dialog-b')).toMatchObject({ status: 'simplifying', operationId: simplification.operation.operationId, temporaryPromptSnapshot: '长'.repeat(201), }); expect( model.resolveAiOperation( { ...completion.operation, dialogId: 'dialog-b' }, '不能写入另一个面板', ), ).toMatchObject({ applied: false, prompt: null, state: { dialogId: 'dialog-b', status: 'simplifying', operationId: simplification.operation.operationId, }, }); }); it('keeps a canonical temporary snapshot and promotes it only after a successful AI writeback', () => { const model = createBackgroundMusicPromptStateModel(); const start = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: ' \u0085森林\r\n冒险\u3000', }); if (!start.operation) { throw new Error('expected completion operation'); } expect(start.canonicalPrompt).toBe('森林\r\n冒险'); expect(start.state).toMatchObject({ status: 'completing', undoPromptSnapshot: null, temporaryPromptSnapshot: '森林\r\n冒险', }); expect( model.resolveAiOperation(start.operation, ' \u0085温暖森林冒险\u3000'), ).toEqual({ applied: true, prompt: '温暖森林冒险', state: { dialogId: 'dialog-a', status: 'idle', operationId: null, undoPromptSnapshot: '森林\r\n冒险', temporaryPromptSnapshot: null, }, }); }); it('clears the current temporary snapshot on failure without restoring the replaced undo snapshot', () => { const model = createBackgroundMusicPromptStateModel(); const first = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '初始描述', }); if (!first.operation) { throw new Error('expected first completion operation'); } model.resolveAiOperation(first.operation, '第一次补全结果'); expect(model.getDialogState('dialog-a').undoPromptSnapshot).toBe( '初始描述', ); const second = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: ' \u0085用户手动修改\u3000', }); if (!second.operation) { throw new Error('expected second completion operation'); } expect(second.state).toMatchObject({ undoPromptSnapshot: null, temporaryPromptSnapshot: '用户手动修改', }); expect(model.rejectOperation(second.operation)).toEqual({ applied: true, prompt: '用户手动修改', state: { dialogId: 'dialog-a', status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, }, }); }); it('treats an invalid AI success payload as a failed writeback and never exposes it', () => { const model = createBackgroundMusicPromptStateModel(); const start = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '森林冒险', }); if (!start.operation) { throw new Error('expected completion operation'); } expect(model.resolveAiOperation(start.operation, ' \u0085\u3000')).toEqual({ applied: false, prompt: null, state: { dialogId: 'dialog-a', status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, }, }); }); it('swaps one canonical undo snapshot repeatedly after manual edits', () => { const model = createBackgroundMusicPromptStateModel(); const start = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '原始描述', }); if (!start.operation) { throw new Error('expected completion operation'); } model.resolveAiOperation(start.operation, 'AI 补全结果'); const firstUndo = model.swapUndoSnapshot( 'dialog-a', ' \u0085用户修改后的结果\u3000', ); expect(firstUndo).toEqual({ applied: true, prompt: '原始描述', state: { dialogId: 'dialog-a', status: 'idle', operationId: null, undoPromptSnapshot: '用户修改后的结果', temporaryPromptSnapshot: null, }, }); const secondUndo = model.swapUndoSnapshot( 'dialog-a', ' \u0085原始描述\u3000', ); expect(secondUndo).toEqual({ applied: true, prompt: '用户修改后的结果', state: { dialogId: 'dialog-a', status: 'idle', operationId: null, undoPromptSnapshot: '原始描述', temporaryPromptSnapshot: null, }, }); }); it('clears the undo snapshot at the preset boundary', () => { const model = createBackgroundMusicPromptStateModel(); const first = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '原始描述', }); if (!first.operation) { throw new Error('expected first completion operation'); } model.resolveAiOperation(first.operation, '第一次补全结果'); expect(model.getDialogState('dialog-a').undoPromptSnapshot).toBe( '原始描述', ); expect( model.preparePreset('dialog-a', ' \u0085第一次补全结果\u3000'), ).toEqual({ applied: true, prompt: '第一次补全结果', state: { dialogId: 'dialog-a', status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, }, }); const pending = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '第一次补全结果', }); if (!pending.operation) { throw new Error('expected pending completion operation'); } expect(model.preparePreset('dialog-a', '处理期间应用新预设')).toMatchObject( { applied: true, prompt: '处理期间应用新预设', state: { status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, }, }, ); expect( model.resolveAiOperation(pending.operation, '不应写回的迟到结果'), ).toMatchObject({ applied: false, prompt: null, }); }); it('preserves the existing undo snapshot when a formal submission is rejected', () => { const model = createBackgroundMusicPromptStateModel(); const completion = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '原始描述', }); if (!completion.operation) { throw new Error('expected completion operation'); } model.resolveAiOperation(completion.operation, 'AI 补全结果'); const submission = model.beginOperation({ dialogId: 'dialog-a', status: 'submitting', prompt: ' \u0085AI 补全结果\u3000', }); if (!submission.operation) { throw new Error('expected submission operation'); } expect(submission).toMatchObject({ started: true, canonicalPrompt: 'AI 补全结果', state: { status: 'submitting', undoPromptSnapshot: '原始描述', temporaryPromptSnapshot: null, }, }); expect( model.beginOperation({ dialogId: 'dialog-a', status: 'submitting', prompt: 'AI 补全结果', }), ).toMatchObject({ started: false, reason: 'duplicate', }); expect( model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '不能覆盖正式提交', }), ).toMatchObject({ started: false, reason: 'locked', canonicalPrompt: 'AI 补全结果', operation: null, state: { status: 'submitting', operationId: submission.operation.operationId, undoPromptSnapshot: '原始描述', }, }); expect( model.beginOperation({ dialogId: 'dialog-a', status: 'simplifying', prompt: '长'.repeat(201), }), ).toMatchObject({ started: false, reason: 'locked', canonicalPrompt: 'AI 补全结果', operation: null, state: { status: 'submitting', operationId: submission.operation.operationId, }, }); expect( model.preparePreset('dialog-a', '不能用预设取消正式提交'), ).toMatchObject({ applied: false, prompt: 'AI 补全结果', state: { status: 'submitting', operationId: submission.operation.operationId, undoPromptSnapshot: '原始描述', }, }); expect(model.rejectOperation(submission.operation)).toEqual({ applied: true, prompt: 'AI 补全结果', state: { dialogId: 'dialog-a', status: 'idle', operationId: null, undoPromptSnapshot: '原始描述', temporaryPromptSnapshot: null, }, }); const acceptedSubmission = model.beginOperation({ dialogId: 'dialog-a', status: 'submitting', prompt: 'AI 补全结果', }); if (!acceptedSubmission.operation) { throw new Error('expected accepted submission operation'); } expect( model.completeSubmittingOperation(acceptedSubmission.operation), ).toEqual({ applied: true, prompt: 'AI 补全结果', state: { dialogId: 'dialog-a', status: 'idle', operationId: null, undoPromptSnapshot: '原始描述', temporaryPromptSnapshot: null, }, }); }); it('accepts simplification at 2000 code points and rejects 2001 without deleting the canonical prompt', () => { const model = createBackgroundMusicPromptStateModel(); const atLimitPrompt = '长'.repeat(2000); const overLimitPrompt = '长'.repeat(2001); expect( model.beginOperation({ dialogId: 'dialog-at-limit', status: 'simplifying', prompt: ` \u0085${atLimitPrompt}\u3000`, }), ).toMatchObject({ started: true, reason: 'started', canonicalPrompt: atLimitPrompt, state: { status: 'simplifying', temporaryPromptSnapshot: atLimitPrompt, }, }); expect( model.beginOperation({ dialogId: 'dialog-over-limit', status: 'simplifying', prompt: ` \u0085${overLimitPrompt}\u3000`, }), ).toEqual({ started: false, reason: 'ineligible', canonicalPrompt: overLimitPrompt, operation: null, state: { dialogId: 'dialog-over-limit', status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, }, }); expect(canCompleteBackgroundMusicPrompt(overLimitPrompt)).toBe(false); expect(canGenerateBackgroundMusicFromPrompt(overLimitPrompt)).toBe(false); expect(canSimplifyBackgroundMusicPrompt(overLimitPrompt)).toBe(false); }); it('invalidates a closed dialog operation even if the same dialog ID is reopened', () => { const model = createBackgroundMusicPromptStateModel(); const closedOperation = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '关闭前描述', }); if (!closedOperation.operation) { throw new Error('expected completion operation'); } model.closeDialog('dialog-a'); expect(model.getDialogState('dialog-a')).toEqual({ dialogId: 'dialog-a', status: 'idle', operationId: null, undoPromptSnapshot: null, temporaryPromptSnapshot: null, }); const reopenedOperation = model.beginOperation({ dialogId: 'dialog-a', status: 'completing', prompt: '重新打开描述', }); expect(reopenedOperation.operation?.operationId).not.toBe( closedOperation.operation.operationId, ); expect( model.resolveAiOperation(closedOperation.operation, '关闭后的迟到结果'), ).toMatchObject({ applied: false, prompt: null, state: { status: 'completing', operationId: reopenedOperation.operation?.operationId, }, }); }); });