import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { afterEach, describe, expect, it } from "vitest"; import { createPrismaClient } from "@kokorone/db"; import type { PrismaService } from "../prisma/prisma.service.js"; import type { JobQueue } from "../memory/job-queue.js"; import { ControllableClock } from "../clock/controllable-clock.js"; import { EmotionService } from "../emotion/emotion.service.js"; import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js"; import { ScheduleService } from "../schedule/schedule.service.js"; import { SleepDebtService } from "../schedule/sleep-debt.service.js"; import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js"; import { LobbyService } from "./lobby.service.js"; import { RoomEventsService } from "./room-events.service.js"; import { RoomService } from "./room.service.js"; import { RoomIntervalService } from "./room-interval.service.js"; import type { SelfChatService } from "./self-chat.service.js"; import { RoomSchedulerService } from "./room-scheduler.service.js"; import { ROOM_ADVANCE_INTERVAL_UPPER_MS } from "./constants.js"; // T37/T8 驗收:重啟 api 後房間自動繼續推進、不出現爆量補齊;到期判斷改依 T22 動態間隔(本測試用的 // 空房間沒有任何在場角色/對話內容,熱度恆為 0,動態間隔會穩定落在上限值,藉此驗證排程判斷邏輯本身, // 實際自走內容生成已由 smoke K 群組覆蓋)。 const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); process.env.DATABASE_URL = `file:${join(repoRoot, "prisma", "dev.db")}`; const prisma = { client: createPrismaClient() } as PrismaService; const noopJobQueue = {} as JobQueue; // scanAndAdvance() 本身不用到 jobQueue,只有 onModuleInit 會用 const roomEvents = new RoomEventsService(new EventEmitter2()); const emotionService = new EmotionService(prisma, new RuleBasedEmotionTagger()); const scheduleService = new ScheduleService(prisma); const sleepDebtService = new SleepDebtService(prisma, scheduleService); const retriever = new KeywordMemoryRetriever(prisma); const userId = "test-scheduler-user"; afterEach(async () => { await prisma.client.room.deleteMany({ where: { userId } }); await prisma.client.user.deleteMany({ where: { id: userId } }); }); function buildScheduler(clock: ControllableClock, selfChat: SelfChatService) { const lobbyService = new LobbyService(prisma, clock, roomEvents); const roomService = new RoomService(prisma, clock, lobbyService, roomEvents); const intervalService = new RoomIntervalService(prisma, emotionService, scheduleService, retriever, sleepDebtService); return new RoomSchedulerService(prisma, noopJobQueue, clock, roomService, selfChat, intervalService); } describe("RoomSchedulerService(T37/T8)", () => { it("到期才推進一次;停機再久重新掃描也只補推進一次,不會依錯過的輪數重複補齊", async () => { await prisma.client.user.create({ data: { id: userId, name: "測試排程使用者" } }); const room = await prisma.client.room.create({ data: { userId, mode: "SELF_CHAT", status: "ACTIVE" } }); const clock = new ControllableClock("2026-08-18T12:00:00.000Z"); const advanceCalls: string[] = []; const fakeSelfChat = { advance: async (roomId: string, now?: Date) => { advanceCalls.push(roomId); await prisma.client.room.update({ where: { id: roomId }, data: { lastAdvancedAt: now ?? clock.now() } }); return { ended: false }; }, } as unknown as SelfChatService; const scheduler = buildScheduler(clock, fakeSelfChat); // 剛建立、lastAdvancedAt 為 null:立刻視為到期,推進一次。 await scheduler.scanAndAdvance(); expect(advanceCalls).toEqual([room.id]); // 間隔內再掃一次:還沒到期,不推進(空房間熱度恆為 0,動態間隔落在上限值)。 advanceCalls.length = 0; await scheduler.scanAndAdvance(); expect(advanceCalls).toEqual([]); // 模擬停機遠超過間隔上限後才重新掃描:只推進「一次」讓它接上目前時間,不依錯過的輪數補齊。 clock.advance(ROOM_ADVANCE_INTERVAL_UPPER_MS * 10); advanceCalls.length = 0; await scheduler.scanAndAdvance(); expect(advanceCalls).toEqual([room.id]); expect(advanceCalls).toHaveLength(1); // 補推進後立刻再掃一次:又還沒到期,不會連續補第二次。 advanceCalls.length = 0; await scheduler.scanAndAdvance(); expect(advanceCalls).toEqual([]); // 間隔剛好過了:正常繼續推進,不因為前面的補推進而被跳過。 clock.advance(ROOM_ADVANCE_INTERVAL_UPPER_MS + 1); advanceCalls.length = 0; await scheduler.scanAndAdvance(); expect(advanceCalls).toEqual([room.id]); }, 20000); it("同一房間的上一次 advance() 還沒跑完時,下一次 tick 不會再併發推進同一房間(T18 迴歸)", async () => { await prisma.client.user.create({ data: { id: userId, name: "測試排程使用者3" } }); const room = await prisma.client.room.create({ data: { userId, mode: "SELF_CHAT", status: "ACTIVE" } }); const clock = new ControllableClock("2026-08-18T12:00:00.000Z"); let concurrentCalls = 0; let maxConcurrentCalls = 0; const fakeSelfChat = { advance: async (roomId: string) => { concurrentCalls += 1; maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls); // 故意拖長:模擬 T18 實測中大房間單輪耗時可能超過 tick 週期(10 秒)的情況。 await new Promise((resolve) => setTimeout(resolve, 100)); concurrentCalls -= 1; return { ended: false }; }, } as unknown as SelfChatService; const scheduler = buildScheduler(clock, fakeSelfChat); // 第一次 scanAndAdvance() 還在跑(advance() 尚未完成)時,立刻疊上第二次 tick。 const first = scheduler.scanAndAdvance(); const second = scheduler.scanAndAdvance(); await Promise.all([first, second]); expect(maxConcurrentCalls).toBe(1); // 任何時刻最多只有一個 advance() 在跑同一房間 void room; // 僅供上方 create 呼叫,本測試不需要另外讀取房間內容 }, 20000); it("不掃描 LOBBY 房與 autoAdvanceEnabled=false 的房間", async () => { await prisma.client.user.create({ data: { id: userId, name: "測試排程使用者2" } }); await prisma.client.room.create({ data: { userId, mode: "LOBBY", status: "ACTIVE", autoAdvanceEnabled: false } }); await prisma.client.room.create({ data: { userId, mode: "SELF_CHAT", status: "ACTIVE", autoAdvanceEnabled: false }, }); const clock = new ControllableClock("2026-08-18T12:00:00.000Z"); const advanceCalls: string[] = []; const fakeSelfChat = { advance: async (roomId: string) => { advanceCalls.push(roomId); return { ended: false }; }, } as unknown as SelfChatService; const scheduler = buildScheduler(clock, fakeSelfChat); await scheduler.scanAndAdvance(); expect(advanceCalls).toEqual([]); }, 20000); });