merge: 최신 main을 시나리오 비급 감사 브랜치에 통합

This commit is contained in:
2026-08-21 01:26:31 +00:00
17 changed files with 772 additions and 73 deletions
+110
View File
@@ -260,6 +260,116 @@ describe('messages router missing-flow compatibility', () => {
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
}); });
it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => {
const ruler = { ...general, officerLevel: 12 } as GeneralRow;
const queryRaw = vi.fn(async () => [{ id: 53 }]);
const changeJournal = new ChangeJournal();
const { caller } = buildContext(
{
$queryRaw: queryRaw,
general: {
findUnique: vi.fn(async () => ruler),
findMany: vi.fn(async () => []),
},
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })),
},
},
{ changeJournal }
);
const result = await caller.messages.send({
generalId: ruler.id,
mailbox: 9000,
text: '우리 나라로 와주세요',
});
expect(result.msgType).toBe('diplomacy');
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(changeJournal.snapshot()).toEqual([
{ domain: 'messages.mailbox', entityId: 9000 },
{ domain: 'messages.mailbox', entityId: 9001 },
]);
});
it('keeps the wanderer mailbox unavailable to a non-diplomat on the server', async () => {
const queryRaw = vi.fn(async () => [{ id: 54 }]);
const { caller } = buildContext({
$queryRaw: queryRaw,
nation: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })),
},
});
const result = await caller.messages.send({
generalId: general.id,
mailbox: 9000,
text: '권한 없는 재야 광고',
});
expect(result.msgType).toBe('national');
expect(queryRaw).toHaveBeenCalledTimes(1);
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
});
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
const wanderer = { ...general, nationId: 0, officerLevel: 0 } as GeneralRow;
const advertisementRow = {
id: 55,
mailbox: 9000,
type: 'diplomacy',
src: 9001,
dest: 9000,
time: new Date(),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
src: {
generalId: 1,
generalName: '위왕',
nationId: 1,
nationName: '위',
color: '#112233',
icon: '',
},
dest: {
generalId: 0,
generalName: '',
nationId: 0,
nationName: '재야',
color: '#000000',
icon: '',
},
text: '우리 나라로 와주세요',
option: {},
},
};
const queryRaw = vi.fn(async (...args: unknown[]) => {
const values = args.slice(1);
return values.includes(9000) && values.includes('diplomacy') ? [advertisementRow] : [];
});
const { caller } = buildContext({
$queryRaw: queryRaw,
general: {
findUnique: vi.fn(async () => wanderer),
findMany: vi.fn(async () => []),
},
});
const result = await caller.messages.getRecent({ generalId: wanderer.id });
expect(result.permission).toBe(-1);
expect(result.diplomacy).toEqual([
expect.objectContaining({
text: '우리 나라로 와주세요',
dest: expect.objectContaining({ nationId: 0, nationName: '재야' }),
option: {},
}),
]);
});
it('blocks private messages between foreign ambassadors', async () => { it('blocks private messages between foreign ambassadors', async () => {
const ambassador = { const ambassador = {
...general, ...general,
+140 -7
View File
@@ -885,9 +885,9 @@ const expectMobilePanelVisualOrder = async (page: Page, expectedOrder: readonly
expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder); expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder);
expect(audit.visualOrder).toEqual(expectedOrder); expect(audit.visualOrder).toEqual(expectedOrder);
expect(audit.panels.every(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true); expect(audit.panels.every(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true);
expect( expect(audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)).toBe(
audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom) true
).toBe(true); );
for (const panel of audit.panels) { for (const panel of audit.panels) {
expect(panel.display, `${panel.id}: display`).not.toBe('none'); expect(panel.display, `${panel.id}: display`).not.toBe('none');
expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position); expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position);
@@ -1125,9 +1125,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.main-mobile-bottom')).toBeHidden();
await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-desktop')).toBeVisible();
await expect(page.locator('.layout-mobile')).toHaveCount(0); await expect(page.locator('.layout-mobile')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount( await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1);
1
);
await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0);
await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월');
await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분');
@@ -1279,7 +1277,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
}); });
test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => { test('shows the persisted official game index beside the scenario title without viewport overflow', async ({
page,
}) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 5, officerLevel: 5,
permission: 2, permission: 2,
@@ -2794,6 +2794,139 @@ test('real mobile devices initially fit the complete 500px game canvas', async (
} }
}); });
test('automatic screen mode switches wide mobile screens to the 1000px layout at the Ref boundary', async ({
browser,
}, testInfo) => {
test.setTimeout(60_000);
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the automatic screen-mode contract');
}
const measurements: Record<string, unknown> = {};
for (const deviceWidth of [699, 700, 820]) {
const context = await browser.newContext({
baseURL: configuredBaseUrl,
viewport: { width: deviceWidth, height: 1180 },
screen: { width: deviceWidth, height: 1180 },
deviceScaleFactor: 1,
isMobile: true,
hasTouch: true,
colorScheme: 'dark',
});
const mobilePage = await context.newPage();
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 6,
npcMode: 1,
generalMeCalls: 0,
operations: [],
};
await installFixture(mobilePage, state);
await waitForMain(mobilePage);
const expectedWideLayout = deviceWidth >= 700;
await expect(mobilePage.locator(expectedWideLayout ? '.layout-desktop' : '.layout-mobile')).toBeVisible();
expect(
await mobilePage.evaluate(() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content)
).toBe(expectedWideLayout ? 'width=1000' : 'width=device-width, initial-scale=1');
const modeMeasurements: Record<string, unknown> = {
auto: await mobilePage.locator('.main-page').evaluate((element) => {
const rect = element.getBoundingClientRect();
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
screenWidth: screen.availWidth,
innerWidth: window.innerWidth,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
visualViewportScale: window.visualViewport?.scale ?? null,
mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null,
desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null,
canvas: { left: rect.left, right: rect.right, width: rect.width },
};
}),
};
if (deviceWidth === 820) {
await mobilePage.evaluate(() => {
localStorage.setItem('sam.screenMode', '500px');
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
await expect(mobilePage.locator('.layout-mobile')).toBeVisible();
expect(
await mobilePage.evaluate(
() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content
)
).toBe('width=500');
modeMeasurements.forced500 = await mobilePage.locator('.main-page').evaluate(() => {
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null,
desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null,
};
});
await mobilePage.evaluate(() => {
localStorage.setItem('sam.screenMode', '1000px');
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
await expect(mobilePage.locator('.layout-desktop')).toBeVisible();
expect(
await mobilePage.evaluate(
() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content
)
).toBe('width=1000');
modeMeasurements.forced1000 = await mobilePage.locator('.main-page').evaluate(() => {
const mobileLayout = document.querySelector<HTMLElement>('.layout-mobile');
const desktopLayout = document.querySelector<HTMLElement>('.layout-desktop');
return {
viewportMeta: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content,
layoutViewportWidth: document.documentElement.clientWidth,
visualViewportWidth: window.visualViewport?.width ?? null,
mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null,
desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null,
};
});
await mobilePage.evaluate(() => {
localStorage.setItem('sam.screenMode', 'auto');
document.dispatchEvent(new CustomEvent('tryChangeScreenMode'));
});
await expect(mobilePage.locator('.layout-desktop')).toBeVisible();
expect(
await mobilePage.evaluate(
() => document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content
)
).toBe('width=1000');
}
measurements[String(deviceWidth)] = modeMeasurements;
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await mobilePage.screenshot({
path: resolve(artifactRoot, `auto-screen-mode-${deviceWidth}.png`),
fullPage: true,
});
}
await context.close();
}
if (artifactRoot) {
await writeFile(
resolve(artifactRoot, 'auto-screen-mode-computed-dom.json'),
`${JSON.stringify(measurements, null, 2)}\n`
);
}
});
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => { test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 1, officerLevel: 1,
@@ -294,8 +294,19 @@ test('nation generals filter buttons open Ref operator menus and apply compound
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000); expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000);
const generalSearch = page.getByLabel('장수명 필터');
await expect(generalSearch).toHaveCSS('touch-action', 'manipulation');
const viewportContract = await page.evaluate(() => ({
content: document.querySelector<HTMLMetaElement>('meta[name="viewport"]')?.content ?? '',
scale: window.visualViewport?.scale ?? 1,
}));
expect(viewportContract.content).not.toMatch(/(?:user-scalable|minimum-scale|maximum-scale)/u);
await generalSearch.focus();
await expect(generalSearch).toBeFocused();
expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(viewportContract.scale);
await nameMenuButton.click(); await nameMenuButton.click();
await expect(namePopup).toBeVisible(); await expect(namePopup).toBeVisible();
await expect(page.getByLabel('장수명 첫 번째 필터 값')).toHaveCSS('touch-action', 'manipulation');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true });
}); });
+9
View File
@@ -63,6 +63,15 @@ textarea {
font: inherit; font: inherit;
} }
/*
* Firefox for Android may zoom to a focused search field. `manipulation`
* suppresses that focus-only zoom while retaining ordinary pan and pinch zoom.
*/
input[type='search'],
input[inputmode='search'] {
touch-action: manipulation;
}
/* /*
* Ref's `.bg0/.bg1/.bg2` set a background image and nothing else, so the * Ref's `.bg0/.bg1/.bg2` set a background image and nothing else, so the
* element stays transparent where the texture does not cover it. Adding a * element stays transparent where the texture does not cover it. Adding a
+2
View File
@@ -4,8 +4,10 @@ import App from './App.vue';
import router from './router'; import router from './router';
import './assets/main.css'; import './assets/main.css';
import { installImageAssetCssVariables } from './utils/imageAssets'; import { installImageAssetCssVariables } from './utils/imageAssets';
import { installScreenModeViewport } from './utils/screenModeViewport';
installImageAssetCssVariables(); installImageAssetCssVariables();
installScreenModeViewport();
const app = createApp(App); const app = createApp(App);
@@ -220,7 +220,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
label: '외교메시지', label: '외교메시지',
color: '#000000', color: '#000000',
options: contacts options: contacts
.filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0) .filter((nation) => nation.mailbox !== ownMailbox)
.map((nation) => ({ .map((nation) => ({
label: nation.name, label: nation.name,
value: nation.mailbox, value: nation.mailbox,
@@ -0,0 +1,78 @@
export const SCREEN_MODE_KEY = 'sam.screenMode';
export const SCREEN_MODE_CHANGE_EVENT = 'tryChangeScreenMode';
export type ScreenMode = 'auto' | '500px' | '1000px';
export type AutoViewportMeasurements = {
deviceWidth: number;
viewportHeight: number;
targetHeight?: number;
};
export const normalizeScreenMode = (value: string | null): ScreenMode =>
value === '500px' || value === '1000px' ? value : 'auto';
export const resolveAutoViewportContent = ({
deviceWidth,
viewportHeight,
targetHeight = 700,
}: AutoViewportMeasurements): string => {
if (deviceWidth < 500) {
return 'width=500';
}
if (viewportHeight < targetHeight) {
const widthAtTargetHeight = (deviceWidth / viewportHeight) * targetHeight;
return widthAtTargetHeight >= 700 ? 'width=1000' : `height=${Math.ceil(targetHeight)}`;
}
return deviceWidth >= 700 ? 'width=1000' : 'width=device-width, initial-scale=1';
};
export const resolveViewportContent = (mode: ScreenMode, measurements: AutoViewportMeasurements): string => {
if (mode === '500px') return 'width=500';
if (mode === '1000px') return 'width=1000';
return resolveAutoViewportContent(measurements);
};
const findOrCreateViewportMeta = (): HTMLMetaElement => {
const existing = document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
if (existing) return existing;
const viewportMeta = document.createElement('meta');
viewportMeta.name = 'viewport';
document.head.appendChild(viewportMeta);
return viewportMeta;
};
export const installScreenModeViewport = (targetHeight = 700): void => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const viewportMeta = findOrCreateViewportMeta();
let previousMode: ScreenMode | null = null;
let previousDeviceWidth: number | null = null;
const adjustViewport = () => {
const mode = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
const deviceWidth = window.screen.availWidth;
if (mode === previousMode && mode === 'auto' && deviceWidth === previousDeviceWidth) return;
if (mode === previousMode && mode !== 'auto') return;
previousMode = mode;
previousDeviceWidth = deviceWidth;
viewportMeta.content = resolveViewportContent(mode, {
deviceWidth,
viewportHeight: window.innerHeight,
targetHeight,
});
};
adjustViewport();
window.addEventListener('resize', adjustViewport);
window.addEventListener('orientationchange', adjustViewport);
window.addEventListener('storage', (event) => {
if (event.key === SCREEN_MODE_KEY) adjustViewport();
});
document.addEventListener(SCREEN_MODE_CHANGE_EVENT, adjustViewport);
};
+2 -3
View File
@@ -10,6 +10,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
import { import {
DEFAULT_MOBILE_MAIN_PANEL_ORDER, DEFAULT_MOBILE_MAIN_PANEL_ORDER,
loadMobileMainPanelOrder, loadMobileMainPanelOrder,
@@ -19,11 +20,9 @@ import {
type MobileMainPanelId, type MobileMainPanelId,
} from '../utils/mobileMainPanelOrder'; } from '../utils/mobileMainPanelOrder';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS'; const CUSTOM_CSS_KEY = 'sam_customCSS';
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart'; const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback(); const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback();
type ScreenMode = 'auto' | '500px' | '1000px';
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item'; type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>; type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
@@ -373,7 +372,7 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin
watch(screenMode, (mode) => { watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode); localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
}); });
watch(customCss, (text) => { watch(customCss, (text) => {
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeScreenMode,
resolveAutoViewportContent,
resolveViewportContent,
} from '../src/utils/screenModeViewport.ts';
void test('automatic mode follows the Ref physical-screen thresholds', () => {
assert.equal(resolveAutoViewportContent({ deviceWidth: 390, viewportHeight: 844 }), 'width=500');
assert.equal(
resolveAutoViewportContent({ deviceWidth: 699, viewportHeight: 900 }),
'width=device-width, initial-scale=1'
);
assert.equal(resolveAutoViewportContent({ deviceWidth: 700, viewportHeight: 900 }), 'width=1000');
assert.equal(resolveAutoViewportContent({ deviceWidth: 820, viewportHeight: 1180 }), 'width=1000');
});
void test('automatic mode preserves the Ref short-viewport aspect-ratio branch', () => {
assert.equal(resolveAutoViewportContent({ deviceWidth: 600, viewportHeight: 650 }), 'height=700');
assert.equal(resolveAutoViewportContent({ deviceWidth: 650, viewportHeight: 600 }), 'width=1000');
});
void test('explicit modes override automatic measurements and invalid storage falls back to auto', () => {
const phone = { deviceWidth: 390, viewportHeight: 844 };
const tablet = { deviceWidth: 820, viewportHeight: 1180 };
assert.equal(resolveViewportContent('1000px', phone), 'width=1000');
assert.equal(resolveViewportContent('500px', tablet), 'width=500');
assert.equal(normalizeScreenMode('1000px'), '1000px');
assert.equal(normalizeScreenMode('500px'), '500px');
assert.equal(normalizeScreenMode('unexpected'), 'auto');
assert.equal(normalizeScreenMode(null), 'auto');
});
@@ -158,6 +158,20 @@ export const planProfileReconcile = (
}; };
}; };
export const resolveResetLifecycleStatus = (
now: Date,
preopenAt: Date | null,
openAt: Date | null
): Extract<GatewayProfileStatus, 'RESERVED' | 'PREOPEN' | 'RUNNING'> => {
if (preopenAt && preopenAt.getTime() > now.getTime()) {
return 'RESERVED';
}
if (openAt && openAt.getTime() > now.getTime()) {
return 'PREOPEN';
}
return 'RUNNING';
};
type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED'; type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED';
interface GatewayAdminActionRecord { interface GatewayAdminActionRecord {
@@ -880,7 +894,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const now = this.now(); const now = this.now();
const due = await this.repository.listReservedToStart(now); const due = await this.repository.listReservedToStart(now);
for (const profile of due) { for (const profile of due) {
if (!profile.preopenAt || !profile.openAt) { const preopenAt = parseDateTime(profile.preopenAt);
const openAt = parseDateTime(profile.openAt);
if (!preopenAt || !openAt) {
await this.repository.updateLastError( await this.repository.updateLastError(
profile.profileName, profile.profileName,
'Reserved profile is missing preopen/open schedule.' 'Reserved profile is missing preopen/open schedule.'
@@ -894,6 +910,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
); );
continue; continue;
} }
if (profile.currentScenario !== null && profile.buildStatus === 'SUCCEEDED' && profile.buildWorkspace) {
await this.repository.updateStatus(
profile.profileName,
resolveResetLifecycleStatus(now, preopenAt, openAt),
{
preopenAt: profile.preopenAt,
openAt: profile.openAt,
}
);
await this.repository.updateLastError(profile.profileName, null);
continue;
}
const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING'; const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING';
if (!queued) { if (!queued) {
await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', { await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', {
@@ -1884,8 +1912,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await assertLease?.(); await assertLease?.();
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
const now = this.now(); const now = this.now();
const shouldPreopen = openAt ? openAt.getTime() > now.getTime() : false; const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt);
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
const publishedProfile = await updateClaimedProfile( const publishedProfile = await updateClaimedProfile(
{ {
currentScenario: String(scenarioId), currentScenario: String(scenarioId),
@@ -1917,30 +1944,37 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
} }
); );
releasePrepared = true; releasePrepared = true;
const builtProfile = publishedProfile ?? { if (desiredStatus === 'RESERVED') {
...profile, await appendLog(
currentScenario: String(scenarioId), 'schedule',
scenario: String(scenarioId), `${preopenAt?.toISOString() ?? '가오픈 시각'}까지 RESERVED 상태로 접속을 차단합니다.`
status: desiredStatus,
buildWorkspace: workspace.root,
};
await appendLog('switch', '초기화된 profile process를 시작합니다.');
const started = await this.startProfile(builtProfile, assertLease);
await appendLog('readiness', 'profile process readiness를 확인합니다.');
const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease));
if (!ready) {
if (started) {
await this.stopProfile(builtProfile, assertLease);
}
const detail = started
? 'reset completed but profile processes failed readiness'
: 'reset completed but profile processes failed to start';
await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
); );
return { status: 'FAILED', detail }; } else {
const builtProfile = publishedProfile ?? {
...profile,
currentScenario: String(scenarioId),
scenario: String(scenarioId),
status: desiredStatus,
buildWorkspace: workspace.root,
};
await appendLog('switch', '초기화된 profile process를 시작합니다.');
const started = await this.startProfile(builtProfile, assertLease);
await appendLog('readiness', 'profile process readiness를 확인합니다.');
const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease));
if (!ready) {
if (started) {
await this.stopProfile(builtProfile, assertLease);
}
const detail = started
? 'reset completed but profile processes failed readiness'
: 'reset completed but profile processes failed to start';
await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
);
return { status: 'FAILED', detail };
}
await appendLog('readiness', 'profile readiness 확인을 통과했습니다.');
} }
await appendLog('readiness', 'profile readiness 확인을 통과했습니다.');
await updateClaimedProfile({ lastError: null }, async () => { await updateClaimedProfile({ lastError: null }, async () => {
await this.repository.updateLastError(profile.profileName, null); await this.repository.updateLastError(profile.profileName, null);
return this.repository.getProfile(profile.profileName); return this.repository.getProfile(profile.profileName);
@@ -700,6 +700,63 @@ describe('admin operation API', () => {
}); });
}); });
it('keeps reset start, preopen, and formal open as an ordered lifecycle', async () => {
const harness = await buildCaller(async (input) => ({
id: '77777777-7777-4777-8777-777777777777',
profileName: input.profileName,
type: 'RESET',
status: 'QUEUED',
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: input.payload ?? {},
requestedBy: input.requestedBy,
scheduledAt: input.scheduledAt,
createdAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
}));
const install = {
scenarioId: 1010,
turnTermMinutes: 60,
sync: false,
fiction: 1 as const,
extend: false,
blockGeneralCreate: 0 as const,
npcMode: 0 as const,
showImgLevel: 0 as const,
tournamentTrig: false,
joinMode: 'full' as const,
preopenAt: '2099-01-01T01:00:00.000Z',
openAt: '2099-01-01T02:00:00.000Z',
};
await harness.caller.admin.operations.requestReset({
profileName: 'che:2',
sourceMode: 'COMMIT',
sourceRef: 'HEAD',
scheduledAt: '2099-01-01T00:00:00.000Z',
install,
});
expect(harness.createdInputs[0]).toMatchObject({
type: 'RESET',
scheduledAt: '2099-01-01T00:00:00.000Z',
payload: { install },
});
await expect(
harness.caller.admin.operations.requestReset({
profileName: 'che:2',
sourceMode: 'COMMIT',
sourceRef: 'HEAD',
scheduledAt: '2099-01-01T01:30:00.000Z',
install,
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'preopenAt cannot be earlier than scheduledAt.',
});
});
it('returns validated profile reset defaults to a scenario-only operator', async () => { it('returns validated profile reset defaults to a scenario-only operator', async () => {
const harness = await buildCaller( const harness = await buildCaller(
async () => { async () => {
@@ -48,6 +48,9 @@ const createHarness = (
startGate?: Promise<void>, startGate?: Promise<void>,
options: { options: {
profile?: GatewayProfileRecord; profile?: GatewayProfileRecord;
profiles?: GatewayProfileRecord[];
reservedToStart?: GatewayProfileRecord[];
now?: () => Date;
cancelGame?: GatewayOrchestratorOptions['cancelGame']; cancelGame?: GatewayOrchestratorOptions['cancelGame'];
} = {} } = {}
) => { ) => {
@@ -59,10 +62,11 @@ const createHarness = (
const started: ProcessDefinition[] = []; const started: ProcessDefinition[] = [];
const stopped: string[] = []; const stopped: string[] = [];
const deleted: string[] = []; const deleted: string[] = [];
const buildStatuses: string[] = [];
const logs: Array<{ phase: string; message: string; level: string }> = []; const logs: Array<{ phase: string; message: string; level: string }> = [];
const repository: GatewayProfileRepository = { const repository: GatewayProfileRepository = {
listProfiles: async () => [harnessProfile], listProfiles: async () => options.profiles ?? [harnessProfile],
getProfile: async () => harnessProfile, getProfile: async () => harnessProfile,
upsertProfile: async () => harnessProfile, upsertProfile: async () => harnessProfile,
updateCurrentScenario: async () => harnessProfile, updateCurrentScenario: async () => harnessProfile,
@@ -70,9 +74,12 @@ const createHarness = (
statuses.push(status); statuses.push(status);
return { ...harnessProfile, status }; return { ...harnessProfile, status };
}, },
updateBuildStatus: async () => harnessProfile, updateBuildStatus: async (_profileName, status) => {
buildStatuses.push(status);
return { ...harnessProfile, buildStatus: status };
},
updateMeta: async () => harnessProfile, updateMeta: async () => harnessProfile,
listReservedToStart: async () => [], listReservedToStart: async () => options.reservedToStart ?? [],
findQueuedBuild: async () => null, findQueuedBuild: async () => null,
updateLastError: async () => {}, updateLastError: async () => {},
updateWorkspaceUsage: async () => {}, updateWorkspaceUsage: async () => {},
@@ -167,10 +174,11 @@ const createHarness = (
scheduleIntervalMs: 60_000, scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000, buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000, adminActionIntervalMs: 60_000,
now: options.now,
cancelGame: options.cancelGame, cancelGame: options.cancelGame,
}); });
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs }; return { orchestrator, statuses, buildStatuses, completions, completionFields, started, stopped, deleted, logs };
}; };
describe('GatewayOrchestrator first-class operations', () => { describe('GatewayOrchestrator first-class operations', () => {
@@ -267,6 +275,81 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.deleted).toEqual([]); expect(harness.deleted).toEqual([]);
}); });
it('opens a prepared reserved profile without rebuilding it again', async () => {
const now = new Date('2030-01-01T01:00:00.000Z');
const reservedProfile: GatewayProfileRecord = {
...profile,
status: 'RESERVED',
currentScenario: '1010',
scenario: '1010',
buildStatus: 'SUCCEEDED',
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
preopenAt: now.toISOString(),
openAt: '2030-01-01T02:00:00.000Z',
};
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
profile: reservedProfile,
profiles: [],
reservedToStart: [reservedProfile],
now: () => now,
});
await harness.orchestrator.runScheduleNow();
expect(harness.statuses).toEqual(['PREOPEN']);
expect(harness.buildStatuses).toEqual([]);
});
it('starts turns when a prepared reserved profile is handled after formal open', async () => {
const now = new Date('2030-01-01T02:00:00.000Z');
const reservedProfile: GatewayProfileRecord = {
...profile,
status: 'RESERVED',
currentScenario: '1010',
scenario: '1010',
buildStatus: 'SUCCEEDED',
buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef',
preopenAt: '2030-01-01T01:00:00.000Z',
openAt: now.toISOString(),
};
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
profile: reservedProfile,
profiles: [],
reservedToStart: [reservedProfile],
now: () => now,
});
await harness.orchestrator.runScheduleNow();
expect(harness.statuses).toEqual(['RUNNING']);
expect(harness.buildStatuses).toEqual([]);
});
it('retains the legacy build queue for an unprepared reserved profile', async () => {
const now = new Date('2030-01-01T01:00:00.000Z');
const reservedProfile: GatewayProfileRecord = {
...profile,
status: 'RESERVED',
currentScenario: null,
scenario: 'default',
buildStatus: 'IDLE',
buildWorkspace: undefined,
preopenAt: now.toISOString(),
openAt: '2030-01-01T02:00:00.000Z',
};
const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, {
profile: reservedProfile,
profiles: [],
reservedToStart: [reservedProfile],
now: () => now,
});
await harness.orchestrator.runScheduleNow();
expect(harness.statuses).toEqual([]);
expect(harness.buildStatuses).toEqual(['QUEUED']);
});
it('starts every profile process and records success', async () => { it('starts every profile process and records success', async () => {
const harness = createHarness(buildOperation('START')); const harness = createHarness(buildOperation('START'));
@@ -8,6 +8,7 @@ import {
buildProcessDefinitions, buildProcessDefinitions,
buildWorkspaceCommands, buildWorkspaceCommands,
planProfileReconcile, planProfileReconcile,
resolveResetLifecycleStatus,
} from '../src/orchestrator/gatewayOrchestrator.js'; } from '../src/orchestrator/gatewayOrchestrator.js';
import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js'; import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js';
import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js'; import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js';
@@ -108,6 +109,29 @@ describe('planProfileReconcile', () => {
}); });
}); });
describe('resolveResetLifecycleStatus', () => {
const now = new Date('2030-01-01T00:00:00.000Z');
it('keeps an initialized profile reserved until the configured preopen time', () => {
expect(
resolveResetLifecycleStatus(now, new Date('2030-01-01T01:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z'))
).toBe('RESERVED');
});
it('moves through preopen before the formal open time', () => {
expect(
resolveResetLifecycleStatus(now, new Date('2029-12-31T23:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z'))
).toBe('PREOPEN');
});
it('runs immediately when no future lifecycle boundary remains', () => {
expect(resolveResetLifecycleStatus(now, null, null)).toBe('RUNNING');
expect(
resolveResetLifecycleStatus(now, new Date('2029-12-31T22:00:00.000Z'), new Date('2029-12-31T23:00:00.000Z'))
).toBe('RUNNING');
});
});
describe('buildProcessDefinitions', () => { describe('buildProcessDefinitions', () => {
const processConfig = { const processConfig = {
workspaceRoot: '/srv/sammo/main', workspaceRoot: '/srv/sammo/main',
@@ -600,7 +600,15 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567'); await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
await page.getByTestId('load-scenarios').click(); await page.getByTestId('load-scenarios').click();
await page.getByTestId('scenario-select').selectOption('5'); await page.getByTestId('scenario-select').selectOption('5');
await page.getByLabel('작업 예약 (서버 시간 UTC+9)').fill('2026-08-13T09:30'); await expect(page.getByText('초기화 시작 → 가오픈 시작 → 정식 오픈 순서입니다.')).toBeVisible();
await page.getByTestId('reset-scheduled-at').fill('2030-08-13T09:30');
await page.getByTestId('reset-preopen-at').fill('2030-08-13T10:00');
await page.getByTestId('reset-open-at').fill('2030-08-13T11:00');
const scheduledHelp = page.getByTestId('reset-help-scheduled-at');
await scheduledHelp.hover();
await expect(page.getByTestId('reset-help-scheduled-at-tooltip')).toContainText(
'완료되어도 가오픈 전에는 접속을 차단합니다.'
);
await page.getByTestId('request-reset').hover(); await page.getByTestId('request-reset').hover();
await page.getByTestId('request-reset').click(); await page.getByTestId('request-reset').click();
@@ -655,7 +663,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"'); expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567'); expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5'); expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2026-08-13T00:30:00.000Z"'); expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2030-08-13T00:30:00.000Z"');
expect(JSON.stringify(resetRequest?.body)).toContain('"preopenAt":"2030-08-13T01:00:00.000Z"');
expect(JSON.stringify(resetRequest?.body)).toContain('"openAt":"2030-08-13T02:00:00.000Z"');
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
@@ -1046,7 +1056,7 @@ test('uses ref reset terms with compact hover, focus, and mobile help', async ({
]); ]);
const helpButtons = page.getByRole('button', { name: /도움말$/ }); const helpButtons = page.getByRole('button', { name: /도움말$/ });
await expect(helpButtons).toHaveCount(10); await expect(helpButtons).toHaveCount(13);
const fictionHelp = page.getByTestId('reset-help-fiction'); const fictionHelp = page.getByTestId('reset-help-fiction');
const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip'); const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip');
await expect(fictionTooltip).toBeHidden(); await expect(fictionTooltip).toBeHidden();
@@ -162,6 +162,20 @@ const RESET_AUTORUN_FORM_KEYS = {
battle: 'autorunBattle', battle: 'autorunBattle',
chief: 'autorunChief', chief: 'autorunChief',
} as const satisfies Record<ResetAutorunOption, keyof typeof form>; } as const satisfies Record<ResetAutorunOption, keyof typeof form>;
const RESET_SCHEDULE_COPY = {
scheduledAt: {
label: '초기화 시작',
help: 'Gateway가 빌드, DB 초기화와 시나리오 생성을 시작합니다. 비우면 즉시 시작하며, 완료되어도 가오픈 전에는 접속을 차단합니다.',
},
preopenAt: {
label: '가오픈 시작',
help: '게임 접속과 장수 생성, 예약턴 입력을 허용하지만 턴은 진행하지 않습니다. 가오픈을 비우고 정식 오픈만 지정하면 초기화 완료 후 바로 가오픈합니다.',
},
openAt: {
label: '정식 오픈',
help: '턴 진행을 시작합니다. 비우면 초기화가 완료되는 즉시 정식 오픈합니다.',
},
} as const;
const gatewayForm = reactive({ const gatewayForm = reactive({
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT', sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
sourceRef: 'main', sourceRef: 'main',
@@ -1313,31 +1327,66 @@ onBeforeUnmount(() => {
</div> </div>
</details> </details>
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3"> <div v-if="mode === 'scenario'" class="space-y-2 rounded border border-zinc-800 p-3">
<label class="text-xs text-zinc-400" <p class="text-xs leading-5 text-zinc-400">
>작업 예약 (서버 시간 UTC+9) 초기화 시작 가오픈 시작 정식 오픈 순서입니다. 초기화 시작을 비우면 바로 작업합니다.
<input </p>
v-model="form.scheduledAt" <div class="grid gap-4 md:grid-cols-3">
type="datetime-local" <div class="space-y-1">
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" <div class="flex items-center gap-1.5 text-xs text-zinc-400">
/> <label for="reset-scheduled-at">{{ RESET_SCHEDULE_COPY.scheduledAt.label }}</label>
</label> <CompactHelp
<label class="text-xs text-zinc-400" :label="RESET_SCHEDULE_COPY.scheduledAt.label"
>가오픈 (서버 시간 UTC+9) :text="RESET_SCHEDULE_COPY.scheduledAt.help"
<input test-id="reset-help-scheduled-at"
v-model="form.preopenAt" />
type="datetime-local" <span>(선택 · UTC+9)</span>
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" </div>
/> <input
</label> id="reset-scheduled-at"
<label class="text-xs text-zinc-400" v-model="form.scheduledAt"
>정식 오픈 (서버 시간 UTC+9) type="datetime-local"
<input class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
v-model="form.openAt" data-testid="reset-scheduled-at"
type="datetime-local" />
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white" </div>
/> <div class="space-y-1">
</label> <div class="flex items-center gap-1.5 text-xs text-zinc-400">
<label for="reset-preopen-at">{{ RESET_SCHEDULE_COPY.preopenAt.label }}</label>
<CompactHelp
:label="RESET_SCHEDULE_COPY.preopenAt.label"
:text="RESET_SCHEDULE_COPY.preopenAt.help"
test-id="reset-help-preopen-at"
/>
<span>(선택 · UTC+9)</span>
</div>
<input
id="reset-preopen-at"
v-model="form.preopenAt"
type="datetime-local"
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
data-testid="reset-preopen-at"
/>
</div>
<div class="space-y-1">
<div class="flex items-center gap-1.5 text-xs text-zinc-400">
<label for="reset-open-at">{{ RESET_SCHEDULE_COPY.openAt.label }}</label>
<CompactHelp
:label="RESET_SCHEDULE_COPY.openAt.label"
:text="RESET_SCHEDULE_COPY.openAt.help"
test-id="reset-help-open-at"
/>
<span>(선택 · UTC+9)</span>
</div>
<input
id="reset-open-at"
v-model="form.openAt"
type="datetime-local"
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-white"
data-testid="reset-open-at"
/>
</div>
</div>
</div> </div>
<input <input
@@ -1,8 +1,11 @@
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';
import { canonicalFrontendFixture as fixture } from './fixtures/canonical.js'; import { canonicalFrontendFixture as fixture } from './fixtures/canonical.js';
const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'; const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102';
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
const response = (data: unknown) => ({ result: { data } }); const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({ const errorResponse = (path: string, message: string) => ({
error: { error: {
@@ -42,7 +45,35 @@ const general = {
const generalContext = { const generalContext = {
general, general,
city: null, city: null,
nation: null, nation: {
id: 1,
name: '테스트국',
color: '#d32f2f',
level: 1,
levelName: '군벌',
gold: 1000,
rice: 1000,
tech: 1000,
typeCode: 'test',
typeName: '테스트',
typePros: '-',
typeCons: '-',
capitalCityId: 1,
capitalCityName: '낙양',
population: { cityCount: 1, current: 10000, max: 20000 },
crew: { generalCount: 2, current: 1000, max: 16000 },
power: 100,
bill: 10,
taxRate: 10,
strategicCommandLimit: 0,
diplomaticLimit: 0,
prohibitScout: false,
prohibitWar: false,
techLevel: 1,
techLimited: false,
topChiefs: { 12: null, 11: null },
impossibleStrategicCommands: [],
},
settings: {}, settings: {},
penalties: {}, penalties: {},
}; };
@@ -373,14 +404,25 @@ for (const viewport of [
boxShadow: getComputedStyle(element).boxShadow, boxShadow: getComputedStyle(element).boxShadow,
})) }))
).toEqual({ outlineWidth: '0px', boxShadow: 'none' }); ).toEqual({ outlineWidth: '0px', boxShadow: 'none' });
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await page.locator('.MessagePanel').screenshot({
path: resolve(artifactRoot, `message-panel-${viewport.width}.png`),
animations: 'disabled',
});
}
}); });
} }
test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => { test('exposes nation targets including wanderers, reply, read, delete, and successful send interactions', async ({
page,
}) => {
const mutations = await installFixture(page, { permission: 4 }); const mutations = await installFixture(page, { permission: 4 });
await openMessages(page, { width: 500, height: 900 }); await openMessages(page, { width: 500, height: 900 });
const select = page.getByLabel('메시지 수신 대상'); const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('optgroup[label="외교메시지"] option[value="9000"]')).toHaveText('재야');
await expect(select.locator('option[value="9002"]')).toHaveCount(1); await expect(select.locator('option[value="9002"]')).toHaveCount(1);
await expect(select.locator('option[value="8"]')).toBeDisabled(); await expect(select.locator('option[value="8"]')).toBeDisabled();
await expect(select.locator('option[value="9"]')).toBeEnabled(); await expect(select.locator('option[value="9"]')).toBeEnabled();
@@ -396,11 +438,21 @@ test('exposes ambassador targets, reply, read, delete, and successful send inter
await deleteButton.click(); await deleteButton.click();
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1);
await select.selectOption('9999'); await select.selectOption('9000');
await page.getByLabel('메시지 입력').fill('전송 성공'); await page.getByLabel('메시지 입력').fill('우리 나라로 와주세요');
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await page.locator('.MessageInputForm').screenshot({
path: resolve(artifactRoot, 'wanderer-recruitment-target-500.png'),
animations: 'disabled',
});
}
await page.getByRole('button', { name: '서신전달&갱신' }).click(); await page.getByRole('button', { name: '서신전달&갱신' }).click();
await expect(page.getByLabel('메시지 입력')).toHaveValue(''); await expect(page.getByLabel('메시지 입력')).toHaveValue('');
await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1);
expect(JSON.stringify(mutations.find((entry) => entry.operation === 'messages.send')?.body)).toContain(
'"mailbox":9000'
);
}); });
test('accepts recruitment and declines invader prompts through private-message controls', async ({ page }) => { test('accepts recruitment and declines invader prompts through private-message controls', async ({ page }) => {
@@ -442,6 +494,7 @@ test('redacts diplomacy for a low-permission general and preserves the failed-se
await openMessages(page, { width: 500, height: 900 }); await openMessages(page, { width: 500, height: 900 });
const select = page.getByLabel('메시지 수신 대상'); const select = page.getByLabel('메시지 수신 대상');
await expect(select.locator('option[value="9000"]')).toHaveCount(0);
await expect(select.locator('option[value="9002"]')).toHaveCount(0); await expect(select.locator('option[value="9002"]')).toHaveCount(0);
await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다'); await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다');
await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문'); await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문');
@@ -65,6 +65,14 @@ const measure = async (browser, name, viewport) => {
await ensureGeneral(page); await ensureGeneral(page);
await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 }); await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.BoardHeader').first().waitFor({ state: 'visible' }); await page.locator('.BoardHeader').first().waitFor({ state: 'visible' });
const mailboxOptions = await page.locator('.MessageInputForm select option').evaluateAll((options) =>
options.map((option) => ({
value: option.value,
label: option.textContent?.trim() ?? '',
group: option.parentElement?.tagName === 'OPTGROUP' ? option.parentElement.label : '',
disabled: option.disabled,
}))
);
const marker = `computed-dom-${name}-${Date.now()}`; const marker = `computed-dom-${name}-${Date.now()}`;
await page.locator('.MessageInputForm select').selectOption('9999'); await page.locator('.MessageInputForm select').selectOption('9999');
await page.locator('.MessageInputForm input').fill(marker); await page.locator('.MessageInputForm input').fill(marker);
@@ -160,7 +168,11 @@ const measure = async (browser, name, viewport) => {
page.once('dialog', (dialog) => dialog.accept()); page.once('dialog', (dialog) => dialog.accept());
await deleteButton.click(); await deleteButton.click();
} }
return { ...result, interaction: { hover, focus } }; return {
...result,
mailboxOptions,
interaction: { hover, focus },
};
} finally { } finally {
await context.close(); await context.close();
} }