新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
143 lines
7.1 KiB
TypeScript
143 lines
7.1 KiB
TypeScript
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);
|
||
});
|