Files
Genarrative/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts
T
lhk229 281c84b7bf
Project CI / Repository checks (push) Successful in 1m15s
Project CI / Frontend tests (push) Failing after 2m11s
Project CI / Backend tests (push) Successful in 3m33s
Project CI / Native shell tests (push) Successful in 13m57s
BGM生成路径优化 (#142)
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/142
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-08-06 17:54:40 +08:00

667 lines
21 KiB
TypeScript

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,
},
});
});
});