feat: 예약 오픈 일정을 빌드 전에 공개
초기화 예약의 공개 선택을 operation payload에 저장하고 로비에서 준비 단계별로 표시합니다. RESERVED 인계와 STOPPED 무요청 경계를 테스트합니다.
This commit is contained in:
@@ -7,7 +7,12 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||
import type { GatewayPrisma } from '@sammo-ts/infra';
|
||||
|
||||
import { procedure, router } from './trpc.js';
|
||||
import { listScenarioPreviews, resolveGitBranchCommitSha, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
||||
import {
|
||||
listScenarioPreviews,
|
||||
resolveGitBranchCommitSha,
|
||||
resolveGitCommitSha,
|
||||
type ScenarioPreview,
|
||||
} from './scenario/scenarioCatalog.js';
|
||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||
import { toPublicUser } from './auth/userRepository.js';
|
||||
import type { AdminAuthContext } from './adminAuth.js';
|
||||
@@ -499,6 +504,20 @@ const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
|
||||
joinMode: 'full',
|
||||
autorunUser: null,
|
||||
};
|
||||
|
||||
const buildResetOtherTextInfo = (install: z.infer<typeof zOperationInstallOptions>): string => {
|
||||
const settings: string[] = [];
|
||||
if (!install.sync) settings.push('시간동기화 없음');
|
||||
if (!install.extend) settings.push('확장 NPC 미포함');
|
||||
if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가');
|
||||
if (install.blockGeneralCreate === 2) settings.push('장수명 무작위');
|
||||
if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관');
|
||||
if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
|
||||
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시');
|
||||
}
|
||||
if (!install.tournamentTrig) settings.push('토너먼트 수동 시작');
|
||||
return settings.join(', ');
|
||||
};
|
||||
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
|
||||
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
|
||||
|
||||
@@ -1151,6 +1170,7 @@ export const adminRouter = router({
|
||||
sourceRef: z.string().min(1).max(128).optional(),
|
||||
install: zOperationInstallOptions,
|
||||
scheduledAt: z.string().datetime().optional(),
|
||||
publishSchedule: z.boolean().optional().default(false),
|
||||
reason: z.string().max(200).optional(),
|
||||
})
|
||||
)
|
||||
@@ -1176,6 +1196,15 @@ export const adminRouter = router({
|
||||
const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null;
|
||||
const openAt = input.install.openAt ? new Date(input.install.openAt) : null;
|
||||
const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null;
|
||||
if (
|
||||
input.publishSchedule &&
|
||||
(!input.scheduledAt || !input.install.preopenAt || !input.install.openAt)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '로비 일정 공개에는 초기화 시작, 가오픈 시작과 정식 오픈이 모두 필요합니다.',
|
||||
});
|
||||
}
|
||||
if (preopenAt && !openAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@@ -1225,6 +1254,7 @@ export const adminRouter = router({
|
||||
: 'sourceRef is required.',
|
||||
});
|
||||
}
|
||||
let selectedScenario: ScenarioPreview | undefined;
|
||||
try {
|
||||
const resolved =
|
||||
sourceMode === 'BRANCH'
|
||||
@@ -1234,7 +1264,8 @@ export const adminRouter = router({
|
||||
sourceRef = resolved;
|
||||
}
|
||||
const scenarios = await listScenarioPreviews({ gitRef: resolved });
|
||||
if (!scenarios.some((scenario) => scenario.id === input.install.scenarioId)) {
|
||||
selectedScenario = scenarios.find((scenario) => scenario.id === input.install.scenarioId);
|
||||
if (!selectedScenario) {
|
||||
throw new Error('Scenario not found at source.');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1257,6 +1288,24 @@ export const adminRouter = router({
|
||||
install: input.install,
|
||||
requestedSource: input.sourceMode,
|
||||
releaseSource: { mode: sourceMode, ref: sourceRef },
|
||||
...(input.publishSchedule && selectedScenario
|
||||
? {
|
||||
publicAnnouncement: {
|
||||
enabled: true,
|
||||
scenarioId: selectedScenario.id,
|
||||
scenarioTitle: selectedScenario.title,
|
||||
scheduledAt: input.scheduledAt,
|
||||
preopenAt: input.install.preopenAt,
|
||||
openAt: input.install.openAt,
|
||||
turnTermMinutes: input.install.turnTermMinutes,
|
||||
fictionMode: input.install.fiction === 1 ? '가상' : '사실',
|
||||
npcMode: input.install.npcMode,
|
||||
defaultStatTotal: selectedScenario.defaultStatTotal,
|
||||
otherTextInfo: buildResetOtherTextInfo(input.install),
|
||||
autorunUser: input.install.autorunUser ?? null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as GatewayPrisma.JsonObject,
|
||||
reason: input.reason,
|
||||
requestedBy: adminAuth.user.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { gatewayProfileCapabilities, type GatewayProfileCapabilities } from '@sammo-ts/common';
|
||||
import type { GatewayOrchestratorHandle } from '../orchestrator/gatewayOrchestrator.js';
|
||||
import type {
|
||||
GatewayOperationRecord,
|
||||
GatewayProfileRecord,
|
||||
GatewayProfileRepository,
|
||||
GatewayProfileStatus,
|
||||
@@ -19,6 +20,27 @@ export type LobbyGeneralStatus = {
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
const PUBLIC_AUTORUN_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
|
||||
type PublicAutorunOption = (typeof PUBLIC_AUTORUN_OPTIONS)[number];
|
||||
|
||||
export type LobbyUpcomingReset = {
|
||||
phase: 'SCHEDULED' | 'PREPARING' | 'READY' | 'DELAYED';
|
||||
scheduledAt: string;
|
||||
preopenAt: string;
|
||||
openAt: string;
|
||||
scenarioId: number;
|
||||
scenarioTitle: string;
|
||||
turnTermMinutes: number;
|
||||
fictionMode: string;
|
||||
npcMode: number;
|
||||
defaultStatTotal: number;
|
||||
otherTextInfo: string;
|
||||
autorunUser: {
|
||||
limitMinutes: number;
|
||||
options: PublicAutorunOption[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type LobbyProfileStatus = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
@@ -38,6 +60,7 @@ export type LobbyProfileStatus = {
|
||||
battleSimRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
};
|
||||
upcomingReset?: LobbyUpcomingReset | null;
|
||||
korName: string;
|
||||
color: string;
|
||||
};
|
||||
@@ -67,14 +90,32 @@ export class InMemoryProfileStatusService implements GatewayProfileStatusService
|
||||
export class RepositoryProfileStatusService implements GatewayProfileStatusService {
|
||||
constructor(
|
||||
private readonly profiles: GatewayProfileRepository,
|
||||
private readonly orchestrator: GatewayOrchestratorHandle
|
||||
private readonly orchestrator: GatewayOrchestratorHandle,
|
||||
private readonly now: () => Date = () => new Date()
|
||||
) {}
|
||||
|
||||
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
|
||||
const rows = orderGatewayProfiles(await this.profiles.listProfiles());
|
||||
const [profileRows, recentResetOperations] = await Promise.all([
|
||||
this.profiles.listProfiles(),
|
||||
this.profiles.listOperations({
|
||||
statuses: ['QUEUED', 'RUNNING', 'SUCCEEDED'],
|
||||
types: ['RESET'],
|
||||
limit: 200,
|
||||
}),
|
||||
]);
|
||||
const rows = orderGatewayProfiles(profileRows);
|
||||
const runtimeStates = await this.orchestrator.listRuntimeStates(rows.map((profile) => profile.profileName));
|
||||
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
|
||||
return rows.map((row) => this.mapProfile(row, runtimeMap));
|
||||
const announcementMap = new Map<string, LobbyUpcomingReset>();
|
||||
const profileStatusMap = new Map(rows.map((row) => [row.profileName, row.status]));
|
||||
const now = this.now();
|
||||
for (const operation of recentResetOperations) {
|
||||
if (announcementMap.has(operation.profileName)) continue;
|
||||
if (!shouldExposeUpcomingReset(operation, profileStatusMap.get(operation.profileName))) continue;
|
||||
const announcement = resolveUpcomingResetAnnouncement(operation, now);
|
||||
if (announcement) announcementMap.set(operation.profileName, announcement);
|
||||
}
|
||||
return rows.map((row) => this.mapProfile(row, runtimeMap, announcementMap));
|
||||
}
|
||||
|
||||
private mapProfile(
|
||||
@@ -88,7 +129,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
battleSimRunning: boolean;
|
||||
tournamentRunning: boolean;
|
||||
}
|
||||
>
|
||||
>,
|
||||
announcementMap: Map<string, LobbyUpcomingReset>
|
||||
): LobbyProfileStatus {
|
||||
const meta = row.meta;
|
||||
return {
|
||||
@@ -110,8 +152,107 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
battleSimRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
upcomingReset: announcementMap.get(row.profileName) ?? null,
|
||||
korName: resolveGatewayProfileKoreanName(row.profile, meta.korName),
|
||||
color: (meta.color as string | undefined) ?? '#ffffff',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
|
||||
const readDateTime = (value: unknown): string | null =>
|
||||
typeof value === 'string' && Number.isFinite(new Date(value).getTime()) ? value : null;
|
||||
|
||||
const readFiniteNumber = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
|
||||
const readAutorun = (value: unknown): LobbyUpcomingReset['autorunUser'] | undefined => {
|
||||
if (value === null) return null;
|
||||
const autorun = asRecord(value);
|
||||
const limitMinutes = readFiniteNumber(autorun?.limitMinutes);
|
||||
if (!autorun || !Number.isInteger(limitMinutes) || (limitMinutes ?? 0) <= 0 || !Array.isArray(autorun.options)) {
|
||||
return undefined;
|
||||
}
|
||||
const allowed = new Set<string>(PUBLIC_AUTORUN_OPTIONS);
|
||||
if (
|
||||
!autorun.options.every(
|
||||
(option): option is PublicAutorunOption => typeof option === 'string' && allowed.has(option)
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
limitMinutes: limitMinutes as number,
|
||||
options: [...autorun.options],
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldExposeUpcomingReset = (
|
||||
operation: GatewayOperationRecord,
|
||||
profileStatus: GatewayProfileStatus | undefined
|
||||
): boolean =>
|
||||
operation.type === 'RESET' &&
|
||||
(operation.status === 'QUEUED' ||
|
||||
operation.status === 'RUNNING' ||
|
||||
(operation.status === 'SUCCEEDED' && profileStatus === 'RESERVED'));
|
||||
|
||||
export const resolveUpcomingResetAnnouncement = (
|
||||
operation: GatewayOperationRecord,
|
||||
now: Date
|
||||
): LobbyUpcomingReset | null => {
|
||||
if (operation.type !== 'RESET' || !['QUEUED', 'RUNNING', 'SUCCEEDED'].includes(operation.status)) return null;
|
||||
const payload = asRecord(operation.payload);
|
||||
const announcement = asRecord(payload?.publicAnnouncement);
|
||||
if (!announcement || announcement.enabled !== true) return null;
|
||||
|
||||
const scheduledAt = readDateTime(announcement.scheduledAt);
|
||||
const preopenAt = readDateTime(announcement.preopenAt);
|
||||
const openAt = readDateTime(announcement.openAt);
|
||||
const scenarioId = readFiniteNumber(announcement.scenarioId);
|
||||
const turnTermMinutes = readFiniteNumber(announcement.turnTermMinutes);
|
||||
const npcMode = readFiniteNumber(announcement.npcMode);
|
||||
const defaultStatTotal = readFiniteNumber(announcement.defaultStatTotal);
|
||||
const autorunUser = readAutorun(announcement.autorunUser);
|
||||
if (
|
||||
!scheduledAt ||
|
||||
!preopenAt ||
|
||||
!openAt ||
|
||||
!Number.isInteger(scenarioId) ||
|
||||
!Number.isInteger(turnTermMinutes) ||
|
||||
!Number.isInteger(npcMode) ||
|
||||
!Number.isInteger(defaultStatTotal) ||
|
||||
typeof announcement.scenarioTitle !== 'string' ||
|
||||
!announcement.scenarioTitle.trim() ||
|
||||
typeof announcement.fictionMode !== 'string' ||
|
||||
typeof announcement.otherTextInfo !== 'string' ||
|
||||
autorunUser === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nowMs = now.getTime();
|
||||
const phase =
|
||||
nowMs >= new Date(preopenAt).getTime()
|
||||
? 'DELAYED'
|
||||
: operation.status === 'SUCCEEDED'
|
||||
? 'READY'
|
||||
: operation.status === 'RUNNING' || nowMs >= new Date(scheduledAt).getTime()
|
||||
? 'PREPARING'
|
||||
: 'SCHEDULED';
|
||||
return {
|
||||
phase,
|
||||
scheduledAt,
|
||||
preopenAt,
|
||||
openAt,
|
||||
scenarioId: scenarioId as number,
|
||||
scenarioTitle: announcement.scenarioTitle.trim(),
|
||||
turnTermMinutes: turnTermMinutes as number,
|
||||
fictionMode: announcement.fictionMode,
|
||||
npcMode: npcMode as number,
|
||||
defaultStatTotal: defaultStatTotal as number,
|
||||
otherTextInfo: announcement.otherTextInfo,
|
||||
autorunUser,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -158,7 +158,12 @@ export interface GatewayProfileRepository {
|
||||
updateLastError(profileName: string, lastError: string | null): Promise<void>;
|
||||
updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void>;
|
||||
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
|
||||
listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]>;
|
||||
listOperations(options?: {
|
||||
profileName?: string;
|
||||
statuses?: GatewayOperationStatus[];
|
||||
types?: GatewayOperationType[];
|
||||
limit?: number;
|
||||
}): Promise<GatewayOperationRecord[]>;
|
||||
listActiveOperationProfileNames?(now: Date): Promise<string[]>;
|
||||
getOperation(id: string): Promise<GatewayOperationRecord | null>;
|
||||
listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise<GatewayOperationLogRecord[]>;
|
||||
@@ -546,9 +551,21 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
},
|
||||
});
|
||||
},
|
||||
async listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]> {
|
||||
async listOperations(options?: {
|
||||
profileName?: string;
|
||||
statuses?: GatewayOperationStatus[];
|
||||
types?: GatewayOperationType[];
|
||||
limit?: number;
|
||||
}): Promise<GatewayOperationRecord[]> {
|
||||
const rows = await prisma.gatewayOperation.findMany({
|
||||
where: options?.profileName ? { profileName: options.profileName } : undefined,
|
||||
where:
|
||||
options?.profileName || options?.statuses?.length || options?.types?.length
|
||||
? {
|
||||
...(options.profileName ? { profileName: options.profileName } : {}),
|
||||
...(options.statuses?.length ? { status: { in: options.statuses } } : {}),
|
||||
...(options.types?.length ? { type: { in: options.types } } : {}),
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: Math.min(Math.max(options?.limit ?? 50, 1), 200),
|
||||
});
|
||||
|
||||
@@ -734,13 +734,30 @@ describe('admin operation API', () => {
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: 'HEAD',
|
||||
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||
publishSchedule: true,
|
||||
install,
|
||||
});
|
||||
|
||||
expect(harness.createdInputs[0]).toMatchObject({
|
||||
type: 'RESET',
|
||||
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||
payload: { install },
|
||||
payload: {
|
||||
install,
|
||||
publicAnnouncement: {
|
||||
enabled: true,
|
||||
scenarioId: 1010,
|
||||
scenarioTitle: expect.any(String),
|
||||
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||
preopenAt: install.preopenAt,
|
||||
openAt: install.openAt,
|
||||
turnTermMinutes: 60,
|
||||
fictionMode: '가상',
|
||||
npcMode: 0,
|
||||
defaultStatTotal: expect.any(Number),
|
||||
otherTextInfo: expect.any(String),
|
||||
autorunUser: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -755,6 +772,20 @@ describe('admin operation API', () => {
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'preopenAt cannot be earlier than scheduledAt.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.operations.requestReset({
|
||||
profileName: 'che:2',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: 'HEAD',
|
||||
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||
publishSchedule: true,
|
||||
install: { ...install, preopenAt: undefined },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '로비 일정 공개에는 초기화 시작, 가오픈 시작과 정식 오픈이 모두 필요합니다.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns validated profile reset defaults to a scenario-only operator', async () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveUpcomingResetAnnouncement, shouldExposeUpcomingReset } from '../src/lobby/profileStatusService.js';
|
||||
import type { GatewayOperationRecord } from '../src/orchestrator/profileRepository.js';
|
||||
|
||||
const buildOperation = (status: GatewayOperationRecord['status'] = 'QUEUED'): GatewayOperationRecord => ({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
profileName: 'che:2',
|
||||
type: 'RESET',
|
||||
status,
|
||||
sourceMode: 'BRANCH',
|
||||
sourceRef: 'private/source-ref',
|
||||
payload: {
|
||||
install: { scenarioId: 1010 },
|
||||
requestedSource: 'CURRENT',
|
||||
publicAnnouncement: {
|
||||
enabled: true,
|
||||
scenarioId: 1010,
|
||||
scenarioTitle: '황건적의 난',
|
||||
scheduledAt: '2026-08-27T05:00:00.000Z',
|
||||
preopenAt: '2026-08-27T05:30:00.000Z',
|
||||
openAt: '2026-08-27T11:00:00.000Z',
|
||||
turnTermMinutes: 60,
|
||||
fictionMode: '가상',
|
||||
npcMode: 1,
|
||||
defaultStatTotal: 70,
|
||||
otherTextInfo: '랜덤 임관',
|
||||
autorunUser: {
|
||||
limitMinutes: 1440,
|
||||
options: ['develop', 'battle'],
|
||||
},
|
||||
requestedBy: 'must-not-leak',
|
||||
reason: 'must-not-leak',
|
||||
},
|
||||
},
|
||||
reason: 'private reason',
|
||||
requestedBy: 'admin-id',
|
||||
scheduledAt: '2026-08-27T05:00:00.000Z',
|
||||
error: 'private error',
|
||||
createdAt: '2026-08-23T00:00:00.000Z',
|
||||
updatedAt: '2026-08-23T00:00:00.000Z',
|
||||
});
|
||||
|
||||
describe('resolveUpcomingResetAnnouncement', () => {
|
||||
it('projects only the public snapshot while the delayed build is queued', () => {
|
||||
const result = resolveUpcomingResetAnnouncement(buildOperation(), new Date('2026-08-27T04:00:00.000Z'));
|
||||
|
||||
expect(result).toEqual({
|
||||
phase: 'SCHEDULED',
|
||||
scheduledAt: '2026-08-27T05:00:00.000Z',
|
||||
preopenAt: '2026-08-27T05:30:00.000Z',
|
||||
openAt: '2026-08-27T11:00:00.000Z',
|
||||
scenarioId: 1010,
|
||||
scenarioTitle: '황건적의 난',
|
||||
turnTermMinutes: 60,
|
||||
fictionMode: '가상',
|
||||
npcMode: 1,
|
||||
defaultStatTotal: 70,
|
||||
otherTextInfo: '랜덤 임관',
|
||||
autorunUser: {
|
||||
limitMinutes: 1440,
|
||||
options: ['develop', 'battle'],
|
||||
},
|
||||
});
|
||||
expect(result).not.toHaveProperty('sourceRef');
|
||||
expect(result).not.toHaveProperty('requestedBy');
|
||||
expect(result).not.toHaveProperty('reason');
|
||||
expect(result).not.toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('moves from preparation to a truthful delay state without changing the profile lifecycle', () => {
|
||||
expect(
|
||||
resolveUpcomingResetAnnouncement(buildOperation('RUNNING'), new Date('2026-08-27T05:10:00.000Z'))
|
||||
).toMatchObject({ phase: 'PREPARING' });
|
||||
expect(
|
||||
resolveUpcomingResetAnnouncement(buildOperation('RUNNING'), new Date('2026-08-27T05:31:00.000Z'))
|
||||
).toMatchObject({ phase: 'DELAYED' });
|
||||
});
|
||||
|
||||
it('keeps a completed build ready for RESERVED handoff and removes cancelled or failed announcements', () => {
|
||||
const succeeded = buildOperation('SUCCEEDED');
|
||||
expect(resolveUpcomingResetAnnouncement(succeeded, new Date('2026-08-27T05:20:00.000Z'))).toMatchObject({
|
||||
phase: 'READY',
|
||||
});
|
||||
expect(shouldExposeUpcomingReset(succeeded, 'RESERVED')).toBe(true);
|
||||
expect(shouldExposeUpcomingReset(succeeded, 'PREOPEN')).toBe(false);
|
||||
expect(shouldExposeUpcomingReset(succeeded, 'RUNNING')).toBe(false);
|
||||
for (const status of ['CANCELLED', 'FAILED'] as const) {
|
||||
expect(
|
||||
resolveUpcomingResetAnnouncement(buildOperation(status), new Date('2026-08-27T04:00:00.000Z'))
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed for an unpublished or incomplete snapshot', () => {
|
||||
const unpublished = buildOperation();
|
||||
unpublished.payload = { publicAnnouncement: { enabled: false } };
|
||||
expect(resolveUpcomingResetAnnouncement(unpublished, new Date('2026-08-27T04:00:00.000Z'))).toBeNull();
|
||||
|
||||
const incomplete = buildOperation();
|
||||
incomplete.payload = { publicAnnouncement: { enabled: true, scenarioTitle: '황건적의 난' } };
|
||||
expect(resolveUpcomingResetAnnouncement(incomplete, new Date('2026-08-27T04:00:00.000Z'))).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user