Files
Kokorone/apps/api/src/room/room-scheduler.service.test.ts
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

143 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});