400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type {
|
|
Character,
|
|
CharacterMemory,
|
|
ContinuityCheck,
|
|
Episode,
|
|
NovelChapter,
|
|
PlotMemory,
|
|
PlotThread,
|
|
Project,
|
|
StoryBible
|
|
} from '@prisma/client';
|
|
import type { AuthRequestUser } from '../auth/auth.types';
|
|
import type { PrismaService } from '../prisma/prisma.service';
|
|
import { MemoriesService } from './memories.service';
|
|
|
|
const user: AuthRequestUser = {
|
|
id: '1',
|
|
email: 'user@example.com',
|
|
role: 'user'
|
|
};
|
|
|
|
const now = new Date('2026-05-31T00:00:00.000Z');
|
|
|
|
function createProject(overrides: Partial<Project> = {}): Project {
|
|
return {
|
|
id: 10n,
|
|
user_id: 1n,
|
|
title: '重生归来,我只搞事业',
|
|
input_mode: 'ai_original',
|
|
genre: 'urban_rebirth',
|
|
style_code: 'korean_comic',
|
|
output_type: 'short_video',
|
|
output_mode: 'image_manga',
|
|
visual_mode: 'korean_manga',
|
|
video_generation_level: 'standard',
|
|
target_episode_count: 5,
|
|
episode_duration: 60,
|
|
status: 'character_confirmed',
|
|
copyright_status: 'ai_original',
|
|
payment_status: 'unpaid',
|
|
quality_level: 'mvp',
|
|
is_long_series: false,
|
|
created_at: now,
|
|
updated_at: now,
|
|
completed_at: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createStoryBible(overrides: Partial<StoryBible> = {}): StoryBible {
|
|
return {
|
|
id: 40n,
|
|
project_id: 10n,
|
|
title: '重生归来,我只搞事业',
|
|
logline: '林晚重回命运转折点,用证据夺回项目。',
|
|
main_plot: '林晚夺回原创项目控制权,周启持续制造阻碍。',
|
|
core_conflict: '林晚必须在资本压力中守住原创项目。',
|
|
selling_points: '重生归来\n证据反杀',
|
|
tone: '克制、锋利、连续反转',
|
|
world_summary: '现代都市内容公司,不得突然加入超能力。',
|
|
ending_direction: '幕后真相继续推进。',
|
|
taboo_rules: '不得改变主角姓名。',
|
|
version: 1,
|
|
status: 'confirmed',
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createChapter(overrides: Partial<NovelChapter> = {}): NovelChapter {
|
|
return {
|
|
id: 30n,
|
|
project_id: 10n,
|
|
novel_source_id: 20n,
|
|
chapter_no: 1,
|
|
title: '第1章 暴雨重启',
|
|
content: '林晚站在暴雨夜里醒来,决定重新夺回项目。',
|
|
summary: '林晚确认重生并整理证据。',
|
|
visual_summary: '暴雨夜,林晚醒来,手机录音亮起。',
|
|
word_count: 22,
|
|
status: 'generated',
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createCharacter(overrides: Partial<Character> = {}): Character {
|
|
return {
|
|
id: 50n,
|
|
project_id: 10n,
|
|
global_character_id: null,
|
|
name: '林晚',
|
|
alias_names: [],
|
|
role_type: 'protagonist',
|
|
gender_label: '女',
|
|
age_group: '青年',
|
|
identity_desc: '故事主角',
|
|
appearance_desc: '眼神坚定',
|
|
face_desc: '精致脸型',
|
|
hair_desc: '深色中长发',
|
|
eye_desc: '深色眼睛',
|
|
body_desc: '身形修长',
|
|
costume_rules: '现代都市通勤装',
|
|
special_props: '手机、合同、录音证据',
|
|
personality_desc: '冷静克制',
|
|
speech_style: '短句明确',
|
|
relationship_desc: '与周启围绕项目控制权对抗',
|
|
character_arc: '从被动到主动',
|
|
negative_rules: '不得改名',
|
|
anchor_asset_id: null,
|
|
wardrobe_variant: null,
|
|
voice_provider_code: null,
|
|
voice_model: null,
|
|
voice_id: null,
|
|
voice_style: null,
|
|
performance_style: null,
|
|
importance_level: 100,
|
|
status: 'locked',
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createPlotMemory(overrides: Partial<PlotMemory> = {}): PlotMemory {
|
|
return {
|
|
id: 60n,
|
|
project_id: 10n,
|
|
episode_id: null,
|
|
chapter_id: 30n,
|
|
memory_type: 'foreshadowing',
|
|
content: '录音证据会在后续揭开幕后真相。',
|
|
importance_level: 90,
|
|
status: 'active',
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createCharacterMemory(overrides: Partial<CharacterMemory> = {}): CharacterMemory {
|
|
return {
|
|
id: 70n,
|
|
project_id: 10n,
|
|
character_id: 50n,
|
|
episode_id: null,
|
|
memory_type: 'current_state',
|
|
content: '林晚当前持有录音证据。',
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createPlotThread(overrides: Partial<PlotThread> = {}): PlotThread {
|
|
return {
|
|
id: 80n,
|
|
project_id: 10n,
|
|
thread_name: '主线目标',
|
|
thread_type: 'main_plot',
|
|
description: '林晚夺回原创项目控制权。',
|
|
start_episode_no: 1,
|
|
expected_resolve_episode_no: 5,
|
|
resolved_episode_no: null,
|
|
status: 'open',
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createEpisode(overrides: Partial<Episode> = {}): Episode {
|
|
return {
|
|
id: 90n,
|
|
project_id: 10n,
|
|
episode_no: 2,
|
|
source_chapter_ids: [],
|
|
title: '第2集 会议反击',
|
|
summary: '林晚带着录音证据进入会议室。',
|
|
opening_hook: '录音证据被投到大屏。',
|
|
middle_conflict: '周启试图转移责任。',
|
|
ending_hook: '幕后投资人的名字第一次出现。',
|
|
target_duration: 60,
|
|
status: 'draft',
|
|
created_at: now,
|
|
updated_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function createContinuityCheck(overrides: Partial<ContinuityCheck> = {}): ContinuityCheck {
|
|
return {
|
|
id: 100n,
|
|
project_id: 10n,
|
|
episode_id: 90n,
|
|
check_type: 'character_name',
|
|
result_status: 'pass',
|
|
issue_text: null,
|
|
suggestion_text: null,
|
|
created_at: now,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
describe('MemoriesService', () => {
|
|
let prisma: any;
|
|
let tx: any;
|
|
let service: MemoriesService;
|
|
|
|
beforeEach(() => {
|
|
tx = {
|
|
plotMemory: {
|
|
createMany: vi.fn().mockResolvedValue({ count: 6 })
|
|
},
|
|
characterMemory: {
|
|
createMany: vi.fn().mockResolvedValue({ count: 8 })
|
|
},
|
|
plotThread: {
|
|
createMany: vi.fn().mockResolvedValue({ count: 3 })
|
|
}
|
|
};
|
|
let continuityId = 100n;
|
|
prisma = {
|
|
project: {
|
|
findUnique: vi.fn().mockResolvedValue(createProject())
|
|
},
|
|
storyBible: {
|
|
findFirst: vi.fn().mockResolvedValue(createStoryBible())
|
|
},
|
|
novelChapter: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
createChapter(),
|
|
createChapter({
|
|
id: 31n,
|
|
chapter_no: 2,
|
|
title: '第2章 会议反击',
|
|
summary: '林晚在会议上用证据反击周启。'
|
|
})
|
|
]),
|
|
findUnique: vi.fn().mockResolvedValue(createChapter())
|
|
},
|
|
character: {
|
|
findMany: vi.fn().mockResolvedValue([
|
|
createCharacter(),
|
|
createCharacter({
|
|
id: 51n,
|
|
name: '周启',
|
|
role_type: 'antagonist',
|
|
importance_level: 80
|
|
})
|
|
]),
|
|
findUnique: vi.fn().mockResolvedValue(createCharacter())
|
|
},
|
|
plotMemory: {
|
|
findMany: vi.fn().mockResolvedValue([createPlotMemory()]),
|
|
findUnique: vi.fn().mockResolvedValue(createPlotMemory()),
|
|
create: vi.fn().mockResolvedValue(createPlotMemory({ memory_type: 'event' })),
|
|
update: vi.fn().mockResolvedValue(createPlotMemory({ status: 'resolved' }))
|
|
},
|
|
characterMemory: {
|
|
findMany: vi.fn().mockResolvedValue([createCharacterMemory()])
|
|
},
|
|
plotThread: {
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
findUnique: vi.fn().mockResolvedValue(createPlotThread()),
|
|
create: vi.fn().mockResolvedValue(createPlotThread({ thread_name: '新伏笔线' })),
|
|
update: vi.fn().mockResolvedValue(createPlotThread({ status: 'resolved' }))
|
|
},
|
|
episode: {
|
|
findUnique: vi.fn().mockResolvedValue(createEpisode()),
|
|
findMany: vi.fn().mockResolvedValue([
|
|
createEpisode({
|
|
id: 87n,
|
|
episode_no: 2,
|
|
ending_hook: '录音证据被周启抢走。'
|
|
}),
|
|
createEpisode({
|
|
id: 88n,
|
|
episode_no: 3,
|
|
ending_hook: '林晚发现幕后投资人。'
|
|
}),
|
|
createEpisode({
|
|
id: 89n,
|
|
episode_no: 4,
|
|
ending_hook: '顾南带来新线索。'
|
|
})
|
|
])
|
|
},
|
|
continuityCheck: {
|
|
create: vi.fn(async ({ data }: { data: Partial<ContinuityCheck> }) =>
|
|
createContinuityCheck({
|
|
id: continuityId++,
|
|
...data
|
|
})
|
|
)
|
|
},
|
|
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
|
|
};
|
|
service = new MemoriesService(prisma as PrismaService);
|
|
});
|
|
|
|
it('generates plot, character, and thread memories from confirmed bibles', async () => {
|
|
const result = await service.generatePlotMemories(user, '10', {});
|
|
|
|
expect(tx.plotMemory.createMany).toHaveBeenCalled();
|
|
expect(tx.characterMemory.createMany).toHaveBeenCalled();
|
|
expect(tx.plotThread.createMany).toHaveBeenCalled();
|
|
expect(tx.plotMemory.createMany.mock.calls[0][0].data).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ memory_type: 'unresolved_conflict' }),
|
|
expect.objectContaining({ memory_type: 'foreshadowing' }),
|
|
expect.objectContaining({ memory_type: 'world_rule' })
|
|
])
|
|
);
|
|
expect(result.created_count.plot_threads).toBe(3);
|
|
expect(result.next_step).toBe('episode_plan_generate');
|
|
});
|
|
|
|
it('requires locked characters before memory generation', async () => {
|
|
prisma.character.findMany.mockResolvedValue([createCharacter({ status: 'generated' })]);
|
|
|
|
await expect(service.generatePlotMemories(user, '10', {})).rejects.toBeInstanceOf(
|
|
BadRequestException
|
|
);
|
|
});
|
|
|
|
it('builds an episode 5 memory context with the previous three summaries', async () => {
|
|
prisma.plotThread.findMany.mockResolvedValue([createPlotThread()]);
|
|
|
|
const result = await service.getMemoryContext(user, '10', '5');
|
|
|
|
expect(result.episode_no).toBe(5);
|
|
expect(result.previous_episodes).toHaveLength(3);
|
|
expect(result.previous_episode_ending_hook).toBe('顾南带来新线索。');
|
|
expect(result.generation_inputs).toContain('前 3 集摘要');
|
|
});
|
|
|
|
it('creates and resolves a manual plot memory', async () => {
|
|
const created = await service.createPlotMemory(user, '10', {
|
|
memory_type: 'foreshadowing',
|
|
content: '第2集出现的旧照片需要在第5集回收。',
|
|
importance_level: 80
|
|
});
|
|
const updated = await service.updatePlotMemory(user, '60', { status: 'resolved' });
|
|
|
|
expect(prisma.plotMemory.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
project_id: 10n,
|
|
memory_type: 'foreshadowing',
|
|
status: 'active'
|
|
})
|
|
});
|
|
expect(updated.status).toBe('resolved');
|
|
expect(created.memory_type).toBe('event');
|
|
});
|
|
|
|
it('creates and updates plot threads', async () => {
|
|
const created = await service.createPlotThread(user, '10', {
|
|
thread_name: '新伏笔线',
|
|
thread_type: 'mystery',
|
|
description: '旧照片来源需要持续推进。'
|
|
});
|
|
const updated = await service.updatePlotThread(user, '80', {
|
|
status: 'resolved',
|
|
resolved_episode_no: 5
|
|
});
|
|
|
|
expect(created.thread_name).toBe('新伏笔线');
|
|
expect(prisma.plotThread.update).toHaveBeenCalledWith({
|
|
where: { id: 80n },
|
|
data: expect.objectContaining({
|
|
status: 'resolved',
|
|
resolved_episode_no: 5
|
|
})
|
|
});
|
|
expect(updated.status).toBe('resolved');
|
|
});
|
|
|
|
it('detects continuity failures', async () => {
|
|
prisma.plotThread.findMany.mockResolvedValue([createPlotThread()]);
|
|
|
|
const result = await service.runContinuityCheck(user, '90', {
|
|
script_text: '林晚拿出录音证据,却突然觉醒超能力,直接让周启认输。下一集真相出现。'
|
|
});
|
|
|
|
expect(result.result_status).toBe('fail');
|
|
expect(prisma.continuityCheck.create).toHaveBeenCalled();
|
|
expect(result.checks.some((check) => check.result_status === 'fail')).toBe(true);
|
|
});
|
|
|
|
it('rejects access to another user project', async () => {
|
|
prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n }));
|
|
|
|
await expect(service.listPlotMemories(user, '10', {})).rejects.toBeInstanceOf(
|
|
ForbiddenException
|
|
);
|
|
});
|
|
});
|