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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 }) => 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 ); }); });