merge: 최신 main을 첩보 명령 복원에 반영

This commit is contained in:
2026-08-20 16:08:06 +00:00
25 changed files with 2041 additions and 251 deletions
+74 -1
View File
@@ -1231,7 +1231,9 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오
const actions = element.querySelector<HTMLElement>('.title-actions')!.getBoundingClientRect();
const navigation = element.querySelector<HTMLElement>('.navigation-actions')!.getBoundingClientRect();
const back = element.querySelector<HTMLAnchorElement>('.navigation-actions a')!.getBoundingClientRect();
const refresh = element.querySelector<HTMLButtonElement>('.navigation-actions button')!.getBoundingClientRect();
const refresh = element
.querySelector<HTMLButtonElement>('.navigation-actions button')!
.getBoundingClientRect();
const past = element.querySelector<HTMLAnchorElement>('.past-plays-link')!.getBoundingClientRect();
const pastStyle = getComputedStyle(element.querySelector<HTMLAnchorElement>('.past-plays-link')!);
return {
@@ -1269,6 +1271,77 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오
}
});
test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 Ref 순서로 복원한다', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('my-page');
await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
await expect(dialog).toBeVisible();
const readOrder = () =>
dialog
.locator('[data-mobile-layout-id]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id')));
const defaultOrder = [
'commands',
'nation-menu',
'nation',
'general',
'city',
'map',
'records',
'global-menu',
'messages',
];
await expect.poll(readOrder).toEqual(defaultOrder);
await dialog
.locator('[data-mobile-layout-id="messages"]')
.dragTo(dialog.locator('[data-mobile-layout-id="commands"]'));
await expect
.poll(readOrder)
.toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'city', 'map', 'records', 'global-menu']);
await dialog.getByRole('button', { name: '지도 위로' }).click();
await expect
.poll(readOrder)
.toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']);
const dialogGeometry = await dialog.evaluate((element) => {
const rect = element.getBoundingClientRect();
const firstItem = element.querySelector<HTMLElement>('[data-mobile-layout-id]')?.getBoundingClientRect();
const moveButton = element.querySelector<HTMLButtonElement>('[aria-label$="아래로"]')?.getBoundingClientRect();
return {
rect: rect.toJSON(),
firstItem: firstItem?.toJSON() ?? null,
moveButton: moveButton?.toJSON() ?? null,
overflowX: getComputedStyle(element).overflowX,
documentWidth: document.documentElement.scrollWidth,
};
});
expect(dialogGeometry.rect.left).toBeGreaterThanOrEqual(0);
expect(dialogGeometry.rect.right).toBeLessThanOrEqual(390);
expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44);
expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36);
expect(dialogGeometry.documentWidth).toBe(390);
await persistParityArtifact(page, 'core-my-page-mobile-layout-order-dialog', dialogGeometry);
await dialog.getByRole('button', { name: '적용', exact: true }).click();
await expect(dialog).toBeHidden();
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]')))
.toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']);
await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
await dialog.getByRole('button', { name: 'Ref 초깃값' }).click();
await expect.poll(readOrder).toEqual(defaultOrder);
await dialog.getByRole('button', { name: '적용', exact: true }).click();
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]')))
.toEqual(defaultOrder);
});
for (const [label, failure] of [
['daemon timeout', 'TIMEOUT'],
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
@@ -11,6 +11,7 @@ const isLegacyRequest = (route: Route): boolean =>
const installArchiveViews = async (page: Page) => {
const hallRequests: string[] = [];
const dynastyRequests: string[] = [];
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_archive_views');
localStorage.setItem('sammo-game-profile', profile);
@@ -21,6 +22,9 @@ const installArchiveViews = async (page: Page) => {
if (operations.some((operation) => operation.startsWith('ranking.getHallOfFame'))) {
hallRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`));
}
if (operations.some((operation) => operation.startsWith('dynasty.'))) {
dynastyRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`));
}
const results = operations.map((operation) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '기록장수' } });
@@ -67,8 +71,8 @@ const installArchiveViews = async (page: Page) => {
{
id: legacy ? 101 : 1,
source: legacy ? 'legacy' : 'current',
sourceProfile: legacy ? 'hwe' : 'che',
serverId: legacy ? 'hwe-old-1' : 'che-current-1',
sourceProfile: 'che',
serverId: legacy ? 'che-old-1' : 'che-current-1',
phase: legacy ? '이전 1기' : '현재 1기',
name: '촉',
year: 215,
@@ -93,10 +97,10 @@ const installArchiveViews = async (page: Page) => {
if (operation === 'dynasty.getDetail') {
return response({
source: 'legacy',
sourceProfile: 'hwe',
sourceProfile: 'che',
emperor: {
id: 101,
serverId: 'hwe-old-1',
serverId: 'che-old-1',
winnerNationId: 1,
phase: '이전 1기',
nationCount: '1 / 2',
@@ -189,7 +193,7 @@ const installArchiveViews = async (page: Page) => {
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
return { hallRequests };
return { dynastyRequests, hallRequests };
};
test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조회한다', async ({ page }, testInfo) => {
@@ -217,19 +221,36 @@ test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조
await page.screenshot({ path: testInfo.outputPath('hall-profile-scope-mobile.png'), fullPage: true });
});
test('왕조 일람과 상세는 이전 서버 source와 profile을 유지한다', async ({ page }) => {
await installArchiveViews(page);
test('왕조 일람과 상세는 현재 profile의 이전 서버 기록만 조회한다', async ({ page }, testInfo) => {
const state = await installArchiveViews(page);
await page.setViewportSize({ width: 1200, height: 800 });
await page.goto('dynasty');
await expect(page.getByText('현재 1기')).toBeVisible();
await page.getByLabel('기록 구분').focus();
await expect(page.getByLabel('기록 구분')).toBeFocused();
await page.getByLabel('기록 구분').selectOption('legacy');
await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible();
await expect(page.getByText(/이전 1기.*이전 서버/)).toBeVisible();
await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0);
await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px');
await expect(page.locator('.dynasty-table')).toHaveCSS('height', '139px');
await expect(page.locator('.dynasty-table .phase-heading')).toHaveCSS('background-color', 'rgb(135, 206, 235)');
await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px');
await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-mobile.png'), fullPage: true });
await page.setViewportSize({ width: 1200, height: 800 });
const detailLink = page.getByRole('link', { name: '자세히' });
await expect(detailLink).toHaveAttribute('href', /dynasty\/101\?source=legacy$/);
await detailLink.click();
await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible();
await expect(page.getByText(/이전 1기.*이전 서버/)).toBeVisible();
await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0);
await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px');
expect(state.dynastyRequests.some((request) => request.includes('legacy'))).toBe(true);
expect(state.dynastyRequests.every((request) => !request.includes('sourceProfile'))).toBe(true);
await page.screenshot({ path: testInfo.outputPath('dynasty-detail-profile-scope-desktop.png'), fullPage: true });
});
test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰 글자를 사용한다', async ({ page }, testInfo) => {
+78 -39
View File
@@ -95,11 +95,10 @@ type DashboardBundleInput = {
const operationInput = (route: Route, index: number): DashboardBundleInput => {
const request = route.request();
const queryInput = new URL(request.url()).searchParams.get('input');
const parsed = (request.postData()
? request.postDataJSON()
: queryInput
? JSON.parse(queryInput)
: {}) as Record<string, unknown>;
const parsed = (request.postData() ? request.postDataJSON() : queryInput ? JSON.parse(queryInput) : {}) as Record<
string,
unknown
>;
const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput };
return entry.json ?? (entry as DashboardBundleInput);
};
@@ -253,32 +252,32 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f
general: draftCommands
? draftCommandGroups
: refCategories
? refCommandCategoryFixture
: large
? ['내정', '군사', '계략'].map((category, categoryIndex) => ({
category,
values: Array.from({ length: 16 }, (_, localIndex) => {
const index = categoryIndex * 16 + localIndex;
return {
key: `command-${index}`,
name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`,
reqArg: index % 2 === 0,
possible: index >= blockedCount,
status: index >= blockedCount ? 'available' : 'blocked',
inputFields: [
{
key: 'amount',
label: '수량',
kind: 'number',
required: true,
min: 1,
max: 10_000,
},
],
};
}),
}))
: [],
? refCommandCategoryFixture
: large
? ['내정', '군사', '계략'].map((category, categoryIndex) => ({
category,
values: Array.from({ length: 16 }, (_, localIndex) => {
const index = categoryIndex * 16 + localIndex;
return {
key: `command-${index}`,
name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`,
reqArg: index % 2 === 0,
possible: index >= blockedCount,
status: index >= blockedCount ? 'available' : 'blocked',
inputFields: [
{
key: 'amount',
label: '수량',
kind: 'number',
required: true,
min: 1,
max: 10_000,
},
],
};
}),
}))
: [],
nation: [],
inputOptions: {
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
@@ -500,7 +499,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' })
: operation === 'navigation.get'
? response(runtimeNavigation)
: response({ ok: true })
: response({ ok: true })
);
await route.fulfill({
status: 200,
@@ -1558,10 +1557,7 @@ test('message targets keep reply behavior and use nation-color contrast in label
await page.setViewportSize({ width: 500, height: 900 });
const mobilePanel = page.locator('.mobile-message-panel');
await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS(
'color',
'rgb(255, 255, 255)'
);
await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS('color', 'rgb(255, 255, 255)');
await expect(mobilePanel.locator('.msg-plate[data-id="103"] .msg-target')).toHaveCSS('color', 'rgb(0, 0, 0)');
await expect(mobilePanel.locator('#mailbox_list optgroup[label="밝은국"]')).toHaveCSS('color', 'rgb(0, 0, 0)');
await persistArtifact(page, `${basePath.slice(1)}-message-nation-contrast-mobile-500`);
@@ -2217,6 +2213,49 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
]) {
await expect(page.locator(selector)).toBeVisible();
}
const readMobilePanelOrder = () =>
page
.locator('.layout-mobile > [data-mobile-panel-id]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-panel-id')));
await expect
.poll(readMobilePanelOrder)
.toEqual(['commands', 'nation-menu', 'nation', 'general', 'city', 'map', 'records', 'global-menu', 'messages']);
await page.evaluate(() => {
localStorage.setItem(
'sam.mobileMainPanelOrder.v1',
JSON.stringify([
'messages',
'map',
'commands',
'nation-menu',
'nation',
'general',
'city',
'records',
'global-menu',
])
);
document.dispatchEvent(new CustomEvent('sam-mobile-main-panel-order-changed'));
});
await expect
.poll(readMobilePanelOrder)
.toEqual(['messages', 'map', 'commands', 'nation-menu', 'nation', 'general', 'city', 'records', 'global-menu']);
const customPanelGeometry = await page.locator('.layout-mobile > [data-mobile-panel-id]').evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return {
id: element.getAttribute('data-mobile-panel-id'),
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
};
})
);
expect(customPanelGeometry.every(({ left, right }) => left >= 0 && right <= 500)).toBe(true);
expect(
customPanelGeometry.every((panel, index) => index === 0 || panel.top >= customPanelGeometry[index - 1]!.bottom)
).toBe(true);
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
});
@@ -3040,9 +3079,9 @@ for (const viewport of [
await refreshActivityAndCommands();
await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon');
await expect(picker.getByLabel('장비', { exact: true })).toHaveValue('청룡언월도');
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
viewport.width
);
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(viewport.width);
});
}
@@ -0,0 +1,420 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
type Role = 'head' | 'member';
type AppointmentInput = { destGeneralId: number; destCityId: number; officerLevel: number };
type FixtureState = {
role: Role;
appointed: boolean;
secretForbidden?: boolean;
appointmentInputs: AppointmentInput[];
};
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({
error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } },
});
const operations = (route: Route): string[] =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const requestInput = (route: Route, index: number): Record<string, unknown> => {
const body: unknown = route.request().postData() ? route.request().postDataJSON() : {};
const record = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const raw = record[String(index)] ?? record;
const payload = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const input = payload.input && typeof payload.input === 'object' ? (payload.input as Record<string, unknown>) : {};
const json = payload.json ?? input.json ?? payload;
return json && typeof json === 'object' ? (json as Record<string, unknown>) : {};
};
const cities = [
{
id: 1,
name: '허창',
level: 7,
region: 2,
population: 99_000,
populationMax: 100_000,
agriculture: 9_500,
agricultureMax: 10_000,
commerce: 8_000,
commerceMax: 10_000,
security: 8_000,
securityMax: 10_000,
trust: 80,
trade: 100,
defence: 4_500,
defenceMax: 5_000,
wall: 4_500,
wallMax: 5_000,
supplyState: 1,
frontState: 0,
incomes: { gold: 1000, rice: 900, wall: 800 },
},
{
id: 2,
name: '낙양',
level: 6,
region: 2,
population: 60_000,
populationMax: 100_000,
agriculture: 5_000,
agricultureMax: 10_000,
commerce: 5_000,
commerceMax: 10_000,
security: 5_000,
securityMax: 10_000,
trust: 70,
trade: 90,
defence: 2_500,
defenceMax: 5_000,
wall: 2_500,
wallMax: 5_000,
supplyState: 1,
frontState: 0,
incomes: { gold: 800, rice: 700, wall: 600 },
},
] as const;
const overviewFixture = (state: FixtureState) => ({
me: { id: state.role === 'head' ? 20 : 21, officerLevel: state.role === 'head' ? 5 : 1 },
nation: {
id: 1,
name: '위',
color: '#008000',
level: 3,
typeCode: 'che_법가',
capitalCityId: 1,
rate: 20,
},
chiefStatMin: 65,
cities: cities.map((city) => ({
...city,
officers: {
4: state.appointed
? { id: 21, name: '장료', npcState: 0, officerLevel: 4, cityId: 1, cityName: '허창' }
: null,
3: null,
2: null,
},
})),
generals: [
{
id: 1,
name: '조조',
npcState: 0,
officerLevel: 12,
cityId: 1,
officerCity: 0,
stats: { leadership: 90, strength: 80, intelligence: 90 },
},
{
id: 20,
name: '순욱',
npcState: 0,
officerLevel: 5,
cityId: 1,
officerCity: 0,
stats: { leadership: 75, strength: 70, intelligence: 90 },
},
{
id: 21,
name: '장료',
npcState: 0,
officerLevel: state.appointed ? 4 : 1,
cityId: 1,
officerCity: state.appointed ? 1 : 0,
stats: { leadership: 80, strength: 70, intelligence: 50 },
},
{
id: 22,
name: '조홍',
npcState: 2,
officerLevel: 1,
cityId: 2,
officerCity: 0,
stats: { leadership: 60, strength: 65, intelligence: 40 },
},
],
});
const secretGeneral = (id: number, name: string, cityId: number, overrides: Record<string, unknown> = {}) => ({
id,
name,
npcState: 0,
injury: 0,
stats: { leadership: 70, strength: 70, intelligence: 70 },
leadershipBonus: 0,
experienceLevel: 9,
troopId: 0,
troopName: null,
gold: 1000,
rice: 2000,
cityId,
cityName: cityId === 1 ? '허창' : '낙양',
defenceTrain: 90,
defenceTrainText: '☆',
crewTypeId: 1,
crewTypeName: '보병',
crew: 300,
train: 90,
atmos: 90,
killTurn: 7,
turnTime: '2026-01-01T01:02:00.000Z',
reservedCommands: ['농지 개간', '훈련'],
...overrides,
});
const secretFixture = () => ({
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 20, permission: 1 },
summary: {
gold: 4000,
rice: 8000,
crew: 1200,
generalCount: 4,
averageGold: 1000,
averageRice: 2000,
readiness: {
90: { crew: 1200, generals: 4 },
80: { crew: 1200, generals: 4 },
60: { crew: 1200, generals: 4 },
},
},
generals: [
secretGeneral(1, '조조', 1, { leadershipBonus: 6 }),
secretGeneral(20, '순욱', 1, { leadershipBonus: 3 }),
secretGeneral(21, '장료', 1, {
stats: { leadership: 80, strength: 70, intelligence: 50 },
}),
secretGeneral(22, '조홍', 2, { npcState: 2, reservedCommands: [] }),
],
});
const personnelGeneral = (id: number, name: string, officerLevel: number, overrides: Record<string, unknown> = {}) => ({
id,
name,
npcState: 0,
officerLevel,
cityId: 1,
cityName: '허창',
troopId: 0,
troopName: null,
picture: null,
imageServer: 0,
officerCity: officerLevel >= 2 && officerLevel <= 4 ? 1 : 0,
officerCityName: officerLevel >= 2 && officerLevel <= 4 ? '허창' : null,
stats: { leadership: 70, strength: 70, intelligence: 70 },
experience: 100,
dedication: 200,
injury: 0,
gold: 1000,
rice: 1000,
crew: 100,
personality: null,
specialDomestic: null,
specialWar: null,
belong: 10,
permission: 'normal',
...overrides,
});
const personnelFixture = (state: FixtureState) => {
const allGenerals = [
personnelGeneral(1, '조조', 12),
personnelGeneral(20, '순욱', 5, { stats: { leadership: 75, strength: 70, intelligence: 90 } }),
personnelGeneral(21, '장료', state.appointed ? 4 : 1, {
stats: { leadership: 80, strength: 70, intelligence: 50 },
}),
personnelGeneral(22, '조홍', 1, {
npcState: 2,
cityId: 2,
cityName: '낙양',
stats: { leadership: 60, strength: 65, intelligence: 40 },
}),
];
const canManage = state.role === 'head';
return {
me: {
id: canManage ? 20 : 21,
officerLevel: canManage ? 5 : 1,
canManage,
canChangePermissions: false,
canKick: canManage,
},
nation: {
id: 1,
name: '위',
color: '#008000',
level: 3,
typeCode: 'che_법가',
capitalCityId: 1,
chiefSet: 0,
},
chiefStatMin: 65,
generals: canManage ? allGenerals : [],
chiefAssignments: { 12: allGenerals[0], 5: allGenerals[1] },
cityAssignments: cities.map((city) => ({
id: city.id,
name: city.name,
level: city.level,
region: city.region,
officerSet: city.id === 1 && state.appointed ? 1 << 4 : 0,
officers: {
4: city.id === 1 && state.appointed ? allGenerals[2] : null,
3: null,
2: null,
},
})),
awards: { tigers: [], eagles: [] },
permissionCandidates: { ambassadors: [], auditors: [] },
};
};
const install = async (page: Page, state: FixtureState): Promise<void> => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_city_office');
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route(gameTrpcRoute, async (route) => {
const result = operations(route).map((operation, index) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 20, name: '순욱' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'nation.getCityOverview') return response(overviewFixture(state));
if (operation === 'nation.getSecretGeneralList') {
return state.secretForbidden
? errorResponse(
operation,
'권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.',
'FORBIDDEN'
)
: response(secretFixture());
}
if (operation === 'nation.getPersonnelInfo') return response(personnelFixture(state));
if (operation === 'nation.appoint') {
const input = requestInput(route, index);
state.appointmentInputs.push({
destGeneralId: Number(input.destGeneralId),
destCityId: Number(input.destCityId),
officerLevel: Number(input.officerLevel),
});
state.appointed = true;
return response({ ok: true });
}
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(result) });
});
};
test('암행부 행을 도시별로 나누고 수뇌의 인사부 즉시 임명을 반영한다', async ({ page }, testInfo) => {
const state: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] };
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/cities');
await expect(page.locator('.nation-cities-page')).toBeVisible();
await expect(page.locator('.city-user-table')).toHaveCount(0);
await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0);
await page.getByRole('button', { name: '암행부 연동' }).click();
await expect(page.locator('.city-user-table')).toHaveCount(2);
await expect(page.locator('.city[data-city-id="1"] .city-user-table tr[data-general-id="21"]')).toContainText(
'장료'
);
await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="22"]')).toContainText(
'조홍'
);
await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="21"]')).toHaveCount(0);
await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .command-attention')).toHaveText(
'농지 개간'
);
const integratedBox = await page.locator('.city[data-city-id="1"] .city-user-table').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return { width: rect.width, borderCollapse: style.borderCollapse, fontSize: style.fontSize };
});
expect(integratedBox).toEqual({ width: 941, borderCollapse: 'collapse', fontSize: '14px' });
await page.getByRole('button', { name: '인사부 연동' }).click();
const ordinaryRow = page.locator('.city[data-city-id="1"] tr[data-general-id="21"]');
await expect(ordinaryRow.locator('.appointment-button')).toHaveCount(3);
await expect(ordinaryRow.locator('.mode-4')).toBeEnabled();
await expect(ordinaryRow.locator('.mode-3')).toBeDisabled();
await expect(ordinaryRow.locator('.mode-2')).toBeEnabled();
await expect(page.locator('tr[data-general-id="1"] .appointment-button')).toHaveCount(0);
const disabledStyle = await ordinaryRow.locator('.mode-3').evaluate((button) => {
const style = getComputedStyle(button);
return { borderTopWidth: style.borderTopWidth, backgroundColor: style.backgroundColor };
});
expect(disabledStyle).toEqual({ borderTopWidth: '0px', backgroundColor: 'rgba(0, 0, 0, 0)' });
const appointButton = page.getByRole('button', { name: '장료을(를) 허창 태수로 임명' });
await appointButton.hover();
expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer');
await appointButton.focus();
await expect(appointButton).toBeFocused();
await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-desktop.png'), fullPage: true });
await appointButton.click();
await expect.poll(() => state.appointmentInputs).toEqual([{ destGeneralId: 21, destCityId: 1, officerLevel: 4 }]);
await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveText('장료');
await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveClass(/effective-officer/u);
await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .mode-4')).toBeDisabled();
await page.setViewportSize({ width: 500, height: 900 });
expect(await page.locator('.nation-cities-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(
1000
);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-mobile.png'), fullPage: true });
});
test('수뇌 대상은 재확인하고 일반 장수에게는 임명 버튼을 열지 않는다', async ({ page }) => {
const headState: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] };
await install(page, headState);
await page.goto('nation/cities');
await page.getByRole('button', { name: '암행부 연동' }).click();
await page.getByRole('button', { name: '인사부 연동' }).click();
const chiefButton = page.getByRole('button', { name: '순욱을(를) 허창 태수로 임명' });
expect(await chiefButton.evaluate((button) => getComputedStyle(button).color)).toBe('rgb(255, 0, 0)');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('수뇌입니다. 임명할까요?');
await dialog.dismiss();
});
await chiefButton.click();
await expect.poll(() => headState.appointmentInputs.length).toBe(0);
await page.unroute(gameTrpcRoute);
const memberState: FixtureState = { role: 'member', appointed: false, appointmentInputs: [] };
await install(page, memberState);
await page.reload();
await page.getByRole('button', { name: '암행부 연동' }).click();
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('수뇌가 아닙니다!');
await dialog.accept();
});
await page.getByRole('button', { name: '인사부 연동' }).click();
await expect(page.locator('.appointment-button')).toHaveCount(0);
expect(memberState.appointmentInputs).toEqual([]);
});
test('암행부 권한 거부는 도시 기밀 행과 인사부 연동을 열지 않는다', async ({ page }) => {
const state: FixtureState = {
role: 'member',
appointed: false,
secretForbidden: true,
appointmentInputs: [],
};
await install(page, state);
await page.goto('nation/cities');
await page.getByRole('button', { name: '암행부 연동' }).click();
await expect(page.locator('.integration-error')).toContainText('권한이 부족합니다.');
await expect(page.locator('.city-user-table')).toHaveCount(0);
await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0);
expect(state.appointmentInputs).toEqual([]);
});
@@ -21,6 +21,7 @@ export default defineConfig({
'troop.spec.ts',
'board.spec.ts',
'inGameInfo.spec.ts',
'nationCityOfficeIntegration.spec.ts',
'inGameMenus.spec.ts',
'nationOffices.spec.ts',
'diplomacy.spec.ts',