적국과 공백지를 포함한 다른 도시 선택지는 유지하고 기존 지도 선택 흐름을 보존한다. 발령 전용 정렬 단위 테스트와 데스크톱·모바일 Chromium 회귀를 추가한다.
2337 lines
112 KiB
TypeScript
2337 lines
112 KiB
TypeScript
import { expect, test, type Page, type Route } from '@playwright/test';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
|
|
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
const imageRoots = [
|
|
resolve(repositoryRoot, '../image'),
|
|
resolve(repositoryRoot, '../../image'),
|
|
resolve(repositoryRoot, '../sam_rebuild/image'),
|
|
resolve(repositoryRoot, '../../sam_rebuild/image'),
|
|
];
|
|
const readImage = async (relativePath: string): Promise<Buffer> => {
|
|
if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`);
|
|
for (const root of imageRoots) {
|
|
try {
|
|
return await readFile(resolve(root, relativePath));
|
|
} catch {
|
|
// Product checkout and feature worktrees have different image-root parents.
|
|
}
|
|
}
|
|
throw new Error(`Fixture image not found: ${relativePath}`);
|
|
};
|
|
const imageContentType = (relativePath: string): string => {
|
|
if (relativePath.endsWith('.png')) return 'image/png';
|
|
if (relativePath.endsWith('.gif')) return 'image/gif';
|
|
return 'image/jpeg';
|
|
};
|
|
|
|
const response = (data: unknown) => ({ result: { data } });
|
|
const errorResponse = (path: string, message: string) => ({
|
|
error: {
|
|
message,
|
|
code: -32000,
|
|
data: { code: 'BAD_REQUEST', httpStatus: 400, path },
|
|
},
|
|
});
|
|
const operations = (route: Route) =>
|
|
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
|
|
|
const inputOptions = {
|
|
cities: [
|
|
{ value: 1, label: '업 (아국)' },
|
|
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
|
|
{ value: 3, label: '단양 (오)' },
|
|
],
|
|
nations: [
|
|
{ value: 1, label: '아국', color: '#008000' },
|
|
{ value: 2, label: '적국', color: '#800000', description: '수도 허창' },
|
|
{ value: 3, label: '불가침국', color: '#000080', description: '수도 단양' },
|
|
],
|
|
nationTargets: {
|
|
che_물자원조: [
|
|
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 원조 대상 · 교역' },
|
|
{
|
|
value: 3,
|
|
label: '불가침국',
|
|
color: '#000080',
|
|
availableNow: true,
|
|
description: '현재 원조 대상 · 불가침 12턴',
|
|
},
|
|
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
|
],
|
|
che_불가침제의: [
|
|
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 제의 가능 · 교역' },
|
|
{
|
|
value: 3,
|
|
label: '불가침국',
|
|
color: '#000080',
|
|
availableNow: true,
|
|
description: '현재 제의 가능 · 불가침 12턴',
|
|
},
|
|
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
|
],
|
|
che_선전포고: [
|
|
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 선전포고 가능 · 교역' },
|
|
{
|
|
value: 3,
|
|
label: '불가침국',
|
|
color: '#000080',
|
|
availableNow: false,
|
|
description: '현재 외교 관계에서는 선전포고 불가',
|
|
},
|
|
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
|
],
|
|
che_종전제의: [
|
|
{
|
|
value: 2,
|
|
label: '적국',
|
|
color: '#800000',
|
|
availableNow: true,
|
|
description: '현재 종전 제의 가능 · 전쟁 6턴',
|
|
},
|
|
{
|
|
value: 3,
|
|
label: '불가침국',
|
|
color: '#000080',
|
|
availableNow: false,
|
|
description: '전쟁·선포 중인 국가가 아닙니다.',
|
|
},
|
|
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
|
],
|
|
che_불가침파기제의: [
|
|
{
|
|
value: 3,
|
|
label: '불가침국',
|
|
color: '#000080',
|
|
availableNow: true,
|
|
description: '현재 불가침 파기 제의 가능 · 불가침 12턴',
|
|
},
|
|
{
|
|
value: 2,
|
|
label: '적국',
|
|
color: '#800000',
|
|
availableNow: false,
|
|
description: '불가침 중인 국가가 아닙니다.',
|
|
},
|
|
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
|
],
|
|
},
|
|
generals: [
|
|
{ value: 1, label: '장수 (아국 · 업)' },
|
|
{ value: 2, label: '관우 (아국 · 업)' },
|
|
],
|
|
generalTargets: {
|
|
che_포상: [
|
|
{
|
|
value: 1,
|
|
label: '장수 (아국 · 업)',
|
|
gold: 5000,
|
|
rice: 400,
|
|
crew: 500,
|
|
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
|
|
},
|
|
{
|
|
value: 2,
|
|
label: '관우 (아국 · 업)',
|
|
gold: 100,
|
|
rice: 4000,
|
|
crew: 1200,
|
|
troopId: 2,
|
|
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
|
|
},
|
|
{
|
|
value: 3,
|
|
label: '여포NPC (아국 · 업)',
|
|
gold: 3000,
|
|
rice: 500,
|
|
crew: 1500,
|
|
troopId: 2,
|
|
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
|
|
},
|
|
],
|
|
che_몰수: [
|
|
{
|
|
value: 1,
|
|
label: '장수 (아국 · 업)',
|
|
gold: 5000,
|
|
rice: 400,
|
|
crew: 500,
|
|
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
|
|
},
|
|
{
|
|
value: 2,
|
|
label: '관우 (아국 · 업)',
|
|
gold: 100,
|
|
rice: 4000,
|
|
crew: 1200,
|
|
troopId: 2,
|
|
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
|
|
},
|
|
{
|
|
value: 3,
|
|
label: '여포NPC (아국 · 업)',
|
|
gold: 3000,
|
|
rice: 500,
|
|
crew: 1500,
|
|
troopId: 2,
|
|
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
|
|
},
|
|
],
|
|
che_발령: [
|
|
{
|
|
value: 1,
|
|
label: '장수 (아국 · 업)',
|
|
crew: 500,
|
|
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
|
|
},
|
|
{
|
|
value: 2,
|
|
label: '관우 (아국 · 업)',
|
|
crew: 1200,
|
|
troopId: 2,
|
|
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
|
|
},
|
|
{
|
|
value: 3,
|
|
label: '여포NPC (아국 · 업)',
|
|
crew: 1500,
|
|
troopId: 2,
|
|
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
|
|
},
|
|
],
|
|
che_부대탈퇴지시: [
|
|
{
|
|
value: 3,
|
|
label: '여포NPC (아국 · 업)',
|
|
availableNow: true,
|
|
crew: 1500,
|
|
troopId: 2,
|
|
description: '현재 탈퇴 지시 가능 · 병력 1,500 · 탑승 부대 청룡대',
|
|
},
|
|
{
|
|
value: 1,
|
|
label: '장수 (아국 · 업)',
|
|
availableNow: false,
|
|
crew: 500,
|
|
description: '현재 탈퇴 지시 불가 · 탑승 부대 없음',
|
|
},
|
|
{
|
|
value: 2,
|
|
label: '관우 (아국 · 업)',
|
|
availableNow: false,
|
|
crew: 1200,
|
|
troopId: 2,
|
|
description: '현재 탈퇴 지시 불가 · 탑승 부대 청룡대 (부대장)',
|
|
},
|
|
],
|
|
},
|
|
crewTypes: [{ value: 1100, label: '보병' }],
|
|
armTypes: [{ value: 1, label: '보병' }],
|
|
nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
|
|
colors: [{ value: 0, label: '색상 1', color: '#ff0000' }],
|
|
items: { horse: [{ value: 'None', label: '판매/해제' }] },
|
|
recruitment: {
|
|
techLevel: 1,
|
|
leadership: 68,
|
|
fullLeadership: 70,
|
|
currentCrewTypeId: 1100,
|
|
currentCrewTypeName: '보병',
|
|
crew: 500,
|
|
gold: 12_345,
|
|
groups: [
|
|
{
|
|
armType: 1,
|
|
armName: '보병',
|
|
values: [
|
|
{
|
|
id: 1100,
|
|
armType: 1,
|
|
name: '보병',
|
|
available: true,
|
|
special: false,
|
|
attack: 125,
|
|
defence: 175,
|
|
speed: 7,
|
|
avoid: 10,
|
|
baseCost: 10.35,
|
|
baseRice: 10.35,
|
|
info: ['표준적인 보병입니다.', '보병은 방어특화입니다.'],
|
|
},
|
|
{
|
|
id: 1101,
|
|
armType: 1,
|
|
name: '정예병',
|
|
available: false,
|
|
special: true,
|
|
attack: 175,
|
|
defence: 225,
|
|
speed: 8,
|
|
avoid: 20,
|
|
baseCost: 13.8,
|
|
baseRice: 11.5,
|
|
info: ['강력하지만 기술이 필요합니다.'],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
amountPresets: {
|
|
che_포상: { values: [100, 500, 1000, 5000, 10000], defaultValue: 1000, min: 100, max: 10000, step: 1 },
|
|
che_몰수: { values: [100, 500, 1000, 5000, 10000], defaultValue: 1000, min: 100, max: 10000, step: 1 },
|
|
che_물자원조: { values: [10000, 20000, 30000], defaultValue: 1000, min: 1000, max: 30000, step: 10 },
|
|
},
|
|
context: {
|
|
actorGold: 1000,
|
|
actorRice: 1000,
|
|
citySecurity: 500,
|
|
nationGold: 5000,
|
|
nationRice: 6000,
|
|
nationLevel: 1,
|
|
},
|
|
};
|
|
const buildCityCommand = (key: string, name: string) => ({
|
|
key,
|
|
name,
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [{ key: 'destCityId', label: '대상 도시', kind: 'select', required: true, optionSource: 'cities' }],
|
|
});
|
|
const buildNationCommand = (key: string, name: string) => ({
|
|
key,
|
|
name,
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [{ key: 'destNationId', label: '대상 국가', kind: 'select', required: true, optionSource: 'nations' }],
|
|
});
|
|
const buildGeneralCommand = (key: string, name: string) => ({
|
|
key,
|
|
name,
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
{ key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' },
|
|
],
|
|
});
|
|
const buildSimpleCommand = (key: string, name: string) => ({
|
|
key,
|
|
name,
|
|
reqArg: false,
|
|
possible: true,
|
|
status: 'available',
|
|
inputFields: [],
|
|
});
|
|
const commandTable = {
|
|
general: [
|
|
{
|
|
category: '계략',
|
|
values: [
|
|
{
|
|
key: 'che_화계',
|
|
name: '화계',
|
|
reqArg: true,
|
|
possible: false,
|
|
status: 'blocked',
|
|
reason: '현재 조건에서는 실행할 수 없습니다.',
|
|
inputFields: [
|
|
{
|
|
key: 'destCityId',
|
|
label: '대상 도시',
|
|
kind: 'select',
|
|
required: true,
|
|
optionSource: 'cities',
|
|
},
|
|
],
|
|
},
|
|
...[
|
|
{ key: 'che_선동', name: '선동' },
|
|
{ key: 'che_탈취', name: '탈취' },
|
|
{ key: 'che_파괴', name: '파괴' },
|
|
].map(({ key, name }) => ({
|
|
key,
|
|
name,
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
{
|
|
key: 'destCityId',
|
|
label: '대상 도시',
|
|
kind: 'select',
|
|
required: true,
|
|
optionSource: 'cities',
|
|
},
|
|
],
|
|
})),
|
|
],
|
|
},
|
|
{
|
|
category: '내정',
|
|
values: [
|
|
{
|
|
key: 'che_징병',
|
|
name: '징병',
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
{ key: 'crewType', label: '병종', kind: 'select', required: true, optionSource: 'crewTypes' },
|
|
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
|
],
|
|
},
|
|
{
|
|
key: 'che_모병',
|
|
name: '모병',
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
{ key: 'crewType', label: '병종', kind: 'select', required: true, optionSource: 'crewTypes' },
|
|
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
category: '군사',
|
|
values: [
|
|
{
|
|
key: 'che_출병',
|
|
name: '출병',
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
{
|
|
key: 'destCityId',
|
|
label: '대상 도시',
|
|
kind: 'select',
|
|
required: true,
|
|
optionSource: 'cities',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'che_첩보',
|
|
name: '첩보',
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
{
|
|
key: 'destCityId',
|
|
label: '대상 도시',
|
|
kind: 'select',
|
|
required: true,
|
|
optionSource: 'cities',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
nation: [
|
|
{
|
|
category: '인사',
|
|
values: [
|
|
{
|
|
...buildGeneralCommand('che_발령', '발령'),
|
|
inputFields: [
|
|
...buildGeneralCommand('che_발령', '발령').inputFields,
|
|
...buildCityCommand('che_발령', '발령').inputFields,
|
|
],
|
|
},
|
|
{
|
|
key: 'che_포상',
|
|
name: '포상',
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
{ key: 'isGold', label: '물자', kind: 'boolean', required: true },
|
|
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
|
{
|
|
key: 'destGeneralId',
|
|
label: '대상 장수',
|
|
kind: 'select',
|
|
required: true,
|
|
optionSource: 'generals',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'che_몰수',
|
|
name: '몰수',
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
{ key: 'isGold', label: '물자', kind: 'boolean', required: true },
|
|
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
|
...buildGeneralCommand('che_몰수', '몰수').inputFields,
|
|
],
|
|
},
|
|
buildGeneralCommand('che_부대탈퇴지시', '부대 탈퇴 지시'),
|
|
],
|
|
},
|
|
{
|
|
category: '외교',
|
|
values: [
|
|
{
|
|
key: 'che_물자원조',
|
|
name: '원조',
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
...buildNationCommand('che_물자원조', '원조').inputFields,
|
|
{
|
|
key: 'amountList',
|
|
label: '지원 물자',
|
|
kind: 'numberTuple',
|
|
required: true,
|
|
min: 0,
|
|
step: 1,
|
|
tupleLabels: ['금', '쌀'],
|
|
},
|
|
],
|
|
},
|
|
buildNationCommand('che_불가침제의', '불가침 제의'),
|
|
buildNationCommand('che_선전포고', '선전포고'),
|
|
buildNationCommand('che_종전제의', '종전 제의'),
|
|
buildNationCommand('che_불가침파기제의', '불가침 파기 제의'),
|
|
],
|
|
},
|
|
{
|
|
category: '특수',
|
|
values: [
|
|
buildCityCommand('che_초토화', '초토화'),
|
|
buildCityCommand('che_천도', '천도'),
|
|
buildSimpleCommand('che_증축', '증축'),
|
|
buildSimpleCommand('che_감축', '감축'),
|
|
],
|
|
},
|
|
{
|
|
category: '전략',
|
|
values: [
|
|
buildCityCommand('che_백성동원', '백성동원'),
|
|
buildCityCommand('che_수몰', '수몰'),
|
|
buildCityCommand('che_허보', '허보'),
|
|
],
|
|
},
|
|
],
|
|
inputOptions,
|
|
};
|
|
const basicRecruitmentCrewTypes = [
|
|
{
|
|
id: 1100,
|
|
armType: 1,
|
|
name: '보병',
|
|
attack: 100,
|
|
defence: 150,
|
|
speed: 7,
|
|
avoid: 10,
|
|
baseCost: 9,
|
|
baseRice: 9,
|
|
info: ['표준적인 보병입니다.'],
|
|
},
|
|
{
|
|
id: 1200,
|
|
armType: 2,
|
|
name: '궁병',
|
|
attack: 100,
|
|
defence: 100,
|
|
speed: 7,
|
|
avoid: 20,
|
|
baseCost: 10,
|
|
baseRice: 10,
|
|
info: ['표준적인 궁병입니다.'],
|
|
},
|
|
{
|
|
id: 1300,
|
|
armType: 3,
|
|
name: '기병',
|
|
attack: 150,
|
|
defence: 100,
|
|
speed: 7,
|
|
avoid: 5,
|
|
baseCost: 11,
|
|
baseRice: 11,
|
|
info: ['표준적인 기병입니다.'],
|
|
},
|
|
{
|
|
id: 1400,
|
|
armType: 4,
|
|
name: '귀병',
|
|
attack: 80,
|
|
defence: 80,
|
|
speed: 7,
|
|
avoid: 5,
|
|
baseCost: 9,
|
|
baseRice: 9,
|
|
info: ['계략을 사용하는 병종입니다.'],
|
|
},
|
|
].map((crewType) => ({ ...crewType, available: true, special: false }));
|
|
const fourArmRecruitmentCommandTable = {
|
|
...commandTable,
|
|
inputOptions: {
|
|
...inputOptions,
|
|
crewTypes: basicRecruitmentCrewTypes.map((crewType) => ({ value: crewType.id, label: crewType.name })),
|
|
recruitment: {
|
|
...inputOptions.recruitment,
|
|
groups: basicRecruitmentCrewTypes.map((crewType) => ({
|
|
armType: crewType.armType,
|
|
armName: crewType.name,
|
|
values: [crewType],
|
|
})),
|
|
},
|
|
},
|
|
};
|
|
const refChiefCommandTable = {
|
|
general: [],
|
|
nation: [
|
|
{ category: '휴식', values: [buildSimpleCommand('휴식', '휴식')] },
|
|
{
|
|
category: '인사',
|
|
values: [
|
|
buildSimpleCommand('che_발령', '발령'),
|
|
buildSimpleCommand('che_포상', '포상'),
|
|
buildSimpleCommand('che_몰수', '몰수'),
|
|
buildSimpleCommand('che_부대탈퇴지시', '부대 탈퇴 지시'),
|
|
],
|
|
},
|
|
{
|
|
category: '외교',
|
|
values: [
|
|
buildSimpleCommand('che_물자원조', '원조'),
|
|
buildSimpleCommand('che_불가침제의', '불가침 제의'),
|
|
buildSimpleCommand('che_선전포고', '선전포고'),
|
|
buildSimpleCommand('che_종전제의', '종전 제의'),
|
|
buildSimpleCommand('che_불가침파기제의', '불가침 파기 제의'),
|
|
],
|
|
},
|
|
{
|
|
category: '특수',
|
|
values: [
|
|
buildSimpleCommand('che_초토화', '초토화'),
|
|
buildSimpleCommand('che_천도', '천도'),
|
|
buildSimpleCommand('che_증축', '증축'),
|
|
buildSimpleCommand('che_감축', '감축'),
|
|
],
|
|
},
|
|
{
|
|
category: '전략',
|
|
values: [
|
|
buildSimpleCommand('che_필사즉생', '필사즉생'),
|
|
buildSimpleCommand('che_백성동원', '백성동원'),
|
|
buildSimpleCommand('che_수몰', '수몰'),
|
|
buildSimpleCommand('che_허보', '허보'),
|
|
buildSimpleCommand('che_의병모집', '의병모집'),
|
|
buildSimpleCommand('che_이호경식', '이호경식'),
|
|
buildSimpleCommand('che_급습', '급습'),
|
|
buildSimpleCommand('che_피장파장', '피장파장'),
|
|
],
|
|
},
|
|
{
|
|
category: '기타',
|
|
values: [buildSimpleCommand('che_국기변경', '국기변경'), buildSimpleCommand('che_국호변경', '국호변경')],
|
|
},
|
|
],
|
|
inputOptions,
|
|
};
|
|
const generalContext = {
|
|
general: {
|
|
id: 1,
|
|
name: '장수',
|
|
nationId: 1,
|
|
cityId: 1,
|
|
officerLevel: 5,
|
|
npcState: 0,
|
|
troopId: 0,
|
|
picture: null,
|
|
imageServer: 0,
|
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
|
gold: 1000,
|
|
rice: 1000,
|
|
crew: 500,
|
|
train: 90,
|
|
atmos: 90,
|
|
injury: 0,
|
|
experience: 0,
|
|
dedication: 0,
|
|
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
|
|
turnTime: '2026-08-17T12:00:00.000Z',
|
|
},
|
|
city: {
|
|
id: 1,
|
|
name: '업',
|
|
level: 8,
|
|
levelName: '특',
|
|
region: 1,
|
|
regionName: '하북',
|
|
nationId: 1,
|
|
nationName: '아국',
|
|
population: 1000,
|
|
populationMax: 2000,
|
|
agriculture: 100,
|
|
agricultureMax: 200,
|
|
commerce: 100,
|
|
commerceMax: 200,
|
|
security: 100,
|
|
securityMax: 200,
|
|
trust: 70,
|
|
trade: 100,
|
|
defence: 100,
|
|
defenceMax: 200,
|
|
wall: 100,
|
|
wallMax: 200,
|
|
supplyState: 1,
|
|
frontState: 0,
|
|
},
|
|
nation: {
|
|
id: 1,
|
|
name: '아국',
|
|
color: '#008000',
|
|
level: 1,
|
|
gold: 5000,
|
|
rice: 6000,
|
|
tech: 100,
|
|
typeName: '중립',
|
|
typePros: '',
|
|
typeCons: '',
|
|
population: { cityCount: 1, current: 1000, max: 2000 },
|
|
crew: { generalCount: 2, current: 500, max: 7000 },
|
|
power: 1234,
|
|
bill: 100,
|
|
taxRate: 20,
|
|
strategicCommandLimit: 0,
|
|
diplomaticLimit: 0,
|
|
prohibitScout: false,
|
|
prohibitWar: false,
|
|
techLevel: 1,
|
|
techLimited: false,
|
|
topChiefs: {},
|
|
impossibleStrategicCommands: [],
|
|
},
|
|
settings: {},
|
|
penalties: {},
|
|
};
|
|
const turns = (count: number) => Array.from({ length: count }, (_, index) => ({ index, action: '휴식', args: {} }));
|
|
const chiefCenter = {
|
|
me: { id: 1, officerLevel: 5, nationId: 1 },
|
|
nation: { id: 1, name: '아국', level: 1 },
|
|
currentYear: 200,
|
|
currentMonth: 1,
|
|
turnTermMinutes: 10,
|
|
maxTurns: 12,
|
|
chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({
|
|
officerLevel,
|
|
name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`,
|
|
npcState: officerLevel === 8 ? 2 : 0,
|
|
turnTime: null,
|
|
revision: 0,
|
|
turns: turns(12),
|
|
})),
|
|
};
|
|
|
|
const install = async (page: Page, rejectGeneral = false, commandTableResponse: unknown = commandTable) => {
|
|
const requests: unknown[] = [];
|
|
const generalTurns = turns(30);
|
|
const nationTurns = turns(12);
|
|
let generalRevision = 0;
|
|
let nationRevision = 0;
|
|
let dashboardLoaded = false;
|
|
page.on('framenavigated', (frame) => {
|
|
if (frame === page.mainFrame()) {
|
|
dashboardLoaded = false;
|
|
}
|
|
});
|
|
await page.addInitScript((profile) => {
|
|
localStorage.setItem('sammo-game-token', 'ga_commands');
|
|
localStorage.setItem('sammo-game-profile', profile);
|
|
}, gameProfile);
|
|
await page.route('**/image/**', async (route) => {
|
|
const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? '');
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: imageContentType(relativePath),
|
|
body: await readImage(relativePath),
|
|
});
|
|
});
|
|
await page.route('**/game/**', async (route) => {
|
|
const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/game/')[1] ?? '');
|
|
try {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: imageContentType(relativePath),
|
|
body: await readImage(`game/${relativePath}`),
|
|
});
|
|
} catch {
|
|
await route.fulfill({ status: 404, body: '' });
|
|
}
|
|
});
|
|
await page.route(gameTrpcRoute, async (route) => {
|
|
const names = operations(route);
|
|
const body = route.request().postDataJSON();
|
|
const results = names.map((name) => {
|
|
if (name === 'dashboard.getContextBundleDelta') {
|
|
const initial = !dashboardLoaded;
|
|
dashboardLoaded = true;
|
|
return response({
|
|
context: initial
|
|
? {
|
|
kind: 'snapshot',
|
|
revision: 'AAAAAAAAAAAAAAAAAAAAAA',
|
|
data: generalContext,
|
|
}
|
|
: { kind: 'unchanged', revision: 'AAAAAAAAAAAAAAAAAAAAAA' },
|
|
commandTable: initial
|
|
? {
|
|
kind: 'snapshot',
|
|
revision: 'BBBBBBBBBBBBBBBBBBBBBB',
|
|
data: commandTableResponse,
|
|
}
|
|
: { kind: 'unchanged', revision: 'BBBBBBBBBBBBBBBBBBBBBB' },
|
|
boardAccess: initial
|
|
? {
|
|
kind: 'snapshot',
|
|
revision: 'CCCCCCCCCCCCCCCCCCCCCC',
|
|
data: { permission: 4, canMeeting: true, canSecret: true },
|
|
}
|
|
: { kind: 'unchanged', revision: 'CCCCCCCCCCCCCCCCCCCCCC' },
|
|
});
|
|
}
|
|
if (name === 'general.me') return response(generalContext);
|
|
if (name === 'world.getMapLayout')
|
|
return response({
|
|
mapName: 'che',
|
|
cityList: [
|
|
{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [2] },
|
|
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
|
|
],
|
|
regionMap: { 1: '하북', 2: '예주' },
|
|
levelMap: { 8: '특' },
|
|
});
|
|
if (name === 'auth.status') return response({ ok: true });
|
|
if (name === 'lobby.info')
|
|
return response({
|
|
myGeneral: { id: 1, name: '장수' },
|
|
year: 200,
|
|
month: 1,
|
|
turnTerm: 10,
|
|
userCnt: 1,
|
|
maxUserCnt: 100,
|
|
npcCnt: 0,
|
|
nationCnt: 2,
|
|
});
|
|
if (name === 'join.getConfig') return response({});
|
|
if (name === 'world.getMap')
|
|
return response({
|
|
result: true,
|
|
version: 0,
|
|
startYear: 180,
|
|
year: 200,
|
|
month: 1,
|
|
cityList: [
|
|
[1, 8, 0, 1, 1, 1],
|
|
[2, 7, 41, 2, 2, 1],
|
|
],
|
|
nationList: [
|
|
[1, '아국', '#008000', 1],
|
|
[2, '적국', '#800000', 2],
|
|
],
|
|
spyList: {},
|
|
shownByGeneralList: [],
|
|
myCity: 1,
|
|
myNation: 1,
|
|
});
|
|
if (name === 'turns.getCommandTable') return response(commandTableResponse);
|
|
if (name === 'nation.getChiefCenter') return response(chiefCenter);
|
|
if (name === 'turns.reserved.getGeneral')
|
|
return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 });
|
|
if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision });
|
|
if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
|
|
if (name === 'general.getFrontStatus')
|
|
return response({
|
|
onlineUserCount: 1,
|
|
onlineNations: '아국(1)',
|
|
onlineGenerals: '장수',
|
|
nationNotice: '',
|
|
lastExecuted: null,
|
|
latestVote: null,
|
|
});
|
|
if (name === 'messages.getRecent')
|
|
return response({
|
|
private: [],
|
|
national: [],
|
|
public: [],
|
|
diplomacy: [],
|
|
sequence: -1,
|
|
hasMore: { private: false, national: false, public: false, diplomacy: false },
|
|
latestRead: { private: 0, diplomacy: 0 },
|
|
canRespondDiplomacy: false,
|
|
});
|
|
if (name === 'messages.getContacts') return response({ nation: [] });
|
|
if (name === 'board.getAccess') return response({ canMeeting: false, canSecret: false });
|
|
if (name === 'tournament.getState') return response({ stage: 0 });
|
|
if (name === 'turns.reserved.setGeneralBulk') {
|
|
requests.push(body);
|
|
if (rejectGeneral) return errorResponse(name, '대상 도시를 선택할 수 없습니다.');
|
|
const input = (
|
|
body as Record<string, { entries: Array<{ turnList: number[]; action: string; args?: unknown }> }>
|
|
)[String(names.indexOf(name))];
|
|
for (const entry of input?.entries ?? []) {
|
|
for (const index of entry.turnList)
|
|
generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} };
|
|
}
|
|
generalRevision += 1;
|
|
return response({ ok: true, revision: generalRevision, turns: generalTurns, autorunLimit: 2403 });
|
|
}
|
|
if (name === 'turns.reserved.setNationBulk') {
|
|
requests.push(body);
|
|
const input = (
|
|
body as Record<string, { entries: Array<{ turnList: number[]; action: string; args?: unknown }> }>
|
|
)[String(names.indexOf(name))];
|
|
for (const entry of input?.entries ?? []) {
|
|
for (const index of entry.turnList)
|
|
nationTurns[index] = { index, action: entry.action, args: entry.args ?? {} };
|
|
}
|
|
nationRevision += 1;
|
|
return response({ ok: true, revision: nationRevision, turns: nationTurns });
|
|
}
|
|
return errorResponse(name, `unhandled ${name}`);
|
|
});
|
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
|
});
|
|
return requests;
|
|
};
|
|
|
|
test('renders and accepts every Ref strategy command at mobile width', async ({ page }) => {
|
|
await install(page);
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await page.goto('/');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '계략', exact: true }).click();
|
|
const strategies = [
|
|
{ name: '선동', guidance: '선택한 도시에 선동을 실행합니다.' },
|
|
{ name: '탈취', guidance: '선택한 도시에 탈취를 실행합니다.' },
|
|
{ name: '파괴', guidance: '선택한 도시에 파괴를 실행합니다.' },
|
|
{ name: '화계', guidance: '선택한 도시에 화계를 실행합니다.' },
|
|
];
|
|
for (const strategy of strategies) {
|
|
const button = picker.getByRole('button', { name: strategy.name, exact: true });
|
|
await expect(button).toBeVisible();
|
|
await button.click();
|
|
const form = picker.getByTestId('command-argument-form');
|
|
await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance);
|
|
await expect(form.locator('select option')).toHaveCount(3);
|
|
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
|
}
|
|
await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') });
|
|
});
|
|
|
|
test('shows and reserves the Ref spy command for a user on desktop and mobile', async ({ page }) => {
|
|
const requests = await install(page);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/');
|
|
const editor = page.locator('[data-command-scope="general"]');
|
|
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
|
|
let picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
|
const spy = picker.getByRole('button', { name: '첩보', exact: true });
|
|
await expect(spy).toBeVisible();
|
|
await spy.hover();
|
|
await spy.focus();
|
|
await expect(spy).toBeFocused();
|
|
await spy.click();
|
|
const form = picker.getByTestId('command-argument-form');
|
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 첩보를 실행합니다.');
|
|
await expect(form.getByTestId('command-argument-guidance')).toContainText(
|
|
'인접 도시에서는 더 많은 정보를 얻습니다.'
|
|
);
|
|
await form.locator('select').selectOption('2');
|
|
await picker.screenshot({ path: test.info().outputPath('spy-command-desktop-1200.png') });
|
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
|
await expect(editor.locator('.action-column > div').first()).toHaveText('【허창】에 첩보 실행');
|
|
expect(JSON.stringify(requests)).toContain('"action":"che_첩보","args":{"destCityId":2}');
|
|
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
|
await expect(picker.getByRole('button', { name: '첩보', exact: true })).toBeVisible();
|
|
const geometry = await picker.evaluate((element) => ({
|
|
width: element.getBoundingClientRect().width,
|
|
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
|
}));
|
|
expect(geometry.width).toBeLessThanOrEqual(500);
|
|
expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
|
await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') });
|
|
});
|
|
|
|
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => {
|
|
const foundingColors = [
|
|
{ value: 0, label: '색상 1', color: '#FF0000' },
|
|
{ value: 15, label: '색상 16', color: '#6495ED' },
|
|
{ value: 16, label: '색상 17', color: '#7FFFD4' },
|
|
];
|
|
const foundingCommandTable = {
|
|
general: [
|
|
{
|
|
category: '국가',
|
|
values: [
|
|
{
|
|
key: 'che_건국',
|
|
name: '건국',
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'needsInput',
|
|
inputFields: [
|
|
{ key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 },
|
|
{
|
|
key: 'nationType',
|
|
label: '국가 성향',
|
|
kind: 'select',
|
|
required: true,
|
|
optionSource: 'nationTypes',
|
|
},
|
|
{
|
|
key: 'colorType',
|
|
label: '국기 색상',
|
|
kind: 'select',
|
|
required: true,
|
|
optionSource: 'colors',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
nation: [],
|
|
inputOptions: { ...inputOptions, colors: foundingColors },
|
|
};
|
|
await install(page, false, foundingCommandTable);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '국가', exact: true }).click();
|
|
await picker.getByRole('button', { name: '건국', exact: true }).click();
|
|
const nationType = picker.getByLabel('국가 성향');
|
|
await expect(nationType).toHaveValue('che_도적');
|
|
await expect(nationType.locator('option[value="che_중립"]')).toHaveCount(0);
|
|
await expect(nationType.locator('option')).toHaveText(['도적']);
|
|
await nationType.focus();
|
|
await expect(nationType).toBeFocused();
|
|
|
|
const colorType = picker.getByLabel('국기 색상');
|
|
const color16 = colorType.locator('option[value="15"]');
|
|
await expect(color16).toHaveText('색상 16');
|
|
await expect(color16).toHaveCSS('background-color', 'rgb(100, 149, 237)');
|
|
await expect(color16).toHaveCSS('color', 'rgb(255, 255, 255)');
|
|
await colorType.selectOption('15');
|
|
await expect(colorType).toHaveValue('15');
|
|
await expect(colorType).toHaveCSS('background-color', 'rgb(100, 149, 237)');
|
|
await expect(colorType).toHaveCSS('color', 'rgb(255, 255, 255)');
|
|
await colorType.selectOption('16');
|
|
await expect(colorType).toHaveCSS('background-color', 'rgb(127, 255, 212)');
|
|
await expect(colorType).toHaveCSS('color', 'rgb(0, 0, 0)');
|
|
await colorType.selectOption('15');
|
|
await picker.screenshot({ path: test.info().outputPath('founding-colored-option-desktop-1200.png') });
|
|
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const mobilePicker = page.getByTestId('command-picker');
|
|
await mobilePicker.getByRole('button', { name: '국가', exact: true }).click();
|
|
await mobilePicker.getByRole('button', { name: '건국', exact: true }).click();
|
|
const mobileColorType = mobilePicker.getByLabel('국기 색상');
|
|
await mobileColorType.selectOption('15');
|
|
await expect(mobileColorType).toHaveCSS('background-color', 'rgb(100, 149, 237)');
|
|
await expect(mobileColorType).toHaveCSS('color', 'rgb(255, 255, 255)');
|
|
const colorSelectRect = await mobileColorType.evaluate((element) => element.getBoundingClientRect().toJSON());
|
|
expect(colorSelectRect.x).toBeGreaterThanOrEqual(0);
|
|
expect(colorSelectRect.right).toBeLessThanOrEqual(500);
|
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
|
|
await mobilePicker.screenshot({ path: test.info().outputPath('founding-colored-option-mobile-500.png') });
|
|
});
|
|
|
|
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
|
|
const specialCommandTable = {
|
|
general: [
|
|
{
|
|
category: '개인',
|
|
values: [
|
|
{
|
|
key: 'che_은퇴',
|
|
name: '은퇴',
|
|
reqArg: false,
|
|
possible: false,
|
|
status: 'blocked',
|
|
reason: '나이가 60세 이상이어야 합니다.',
|
|
inputFields: [],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
category: '인사',
|
|
values: [
|
|
{
|
|
key: 'che_강행',
|
|
name: '강행',
|
|
reqArg: true,
|
|
possible: true,
|
|
status: 'available',
|
|
inputFields: [
|
|
{
|
|
key: 'destCityId',
|
|
label: '대상 도시',
|
|
kind: 'select',
|
|
required: true,
|
|
optionSource: 'cities',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
category: '국가',
|
|
values: [
|
|
{
|
|
key: 'che_하야',
|
|
name: '하야',
|
|
reqArg: false,
|
|
possible: true,
|
|
status: 'available',
|
|
inputFields: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
nation: [],
|
|
inputOptions,
|
|
};
|
|
const requests = await install(page, false, specialCommandTable);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/');
|
|
|
|
const editor = page.locator('[data-command-scope="general"]');
|
|
|
|
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
let picker = page.getByTestId('command-picker');
|
|
const retirement = picker.getByRole('button', { name: '은퇴', exact: true });
|
|
await expect(retirement).toHaveClass(/blocked/);
|
|
await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.');
|
|
await retirement.hover();
|
|
await retirement.focus();
|
|
await expect(retirement).toBeFocused();
|
|
await picker.screenshot({ path: test.info().outputPath('special-user-commands-desktop-1200.png') });
|
|
await retirement.click();
|
|
await expect(editor.locator('.action-column > div').nth(0)).toHaveText('은퇴');
|
|
|
|
await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '국가', exact: true }).click();
|
|
await picker.getByRole('button', { name: '하야', exact: true }).click();
|
|
await expect(editor.locator('.action-column > div').nth(1)).toHaveText('하야');
|
|
|
|
await editor.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '인사', exact: true }).click();
|
|
await picker.getByRole('button', { name: '강행', exact: true }).click();
|
|
const forceMoveForm = picker.getByTestId('command-argument-form');
|
|
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
|
|
await forceMoveForm.locator('select').selectOption('2');
|
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
|
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행');
|
|
|
|
const serialized = JSON.stringify(requests);
|
|
expect(serialized).toContain('"action":"che_은퇴","args":{}');
|
|
expect(serialized).toContain('"action":"che_하야","args":{}');
|
|
expect(serialized).toContain('"action":"che_강행","args":{"destCityId":2}');
|
|
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await editor.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await expect(picker.locator('.category-btn')).toHaveText(['개인', '인사', '국가']);
|
|
const mobileGeometry = await picker.evaluate((element) => ({
|
|
width: element.getBoundingClientRect().width,
|
|
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
|
categoryColumns: getComputedStyle(element.querySelector<HTMLElement>('.category-list')!).gridTemplateColumns,
|
|
}));
|
|
expect(mobileGeometry.width).toBeLessThanOrEqual(500);
|
|
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
|
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
|
|
await picker.getByRole('button', { name: '개인', exact: true }).click();
|
|
await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible();
|
|
await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') });
|
|
});
|
|
|
|
test('shows every Ref chief command in the exact category and command order', async ({ page }) => {
|
|
await install(page, false, refChiefCommandTable);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/che/chief-center');
|
|
|
|
const editor = page.locator('[data-command-scope="nation"]');
|
|
await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const picker = page.getByTestId('command-picker');
|
|
const expected = {
|
|
휴식: ['휴식'],
|
|
인사: ['발령', '포상', '몰수', '부대 탈퇴 지시'],
|
|
외교: ['원조', '불가침 제의', '선전포고', '종전 제의', '불가침 파기 제의'],
|
|
특수: ['초토화', '천도', '증축', '감축'],
|
|
전략: ['필사즉생', '백성동원', '수몰', '허보', '의병모집', '이호경식', '급습', '피장파장'],
|
|
기타: ['국기변경', '국호변경'],
|
|
} as const;
|
|
|
|
await expect(picker.locator('.category-btn')).toHaveText(Object.keys(expected));
|
|
for (const [category, commands] of Object.entries(expected)) {
|
|
await picker.locator('.category-btn').filter({ hasText: category }).click();
|
|
await expect(picker.locator('.command-grid .command-item')).toHaveText([...commands]);
|
|
}
|
|
const rename = picker.getByRole('button', { name: '국호변경', exact: true });
|
|
await rename.hover();
|
|
await rename.focus();
|
|
await expect(rename).toBeFocused();
|
|
await picker.screenshot({ path: test.info().outputPath('ref-chief-command-list-desktop-1200.png') });
|
|
|
|
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
const mobileEditor = page.locator('[data-command-scope="nation"]:visible');
|
|
await mobileEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const mobilePicker = page.locator('[data-testid="command-picker"]:visible');
|
|
await expect(mobilePicker.locator('.category-btn')).toHaveText(Object.keys(expected));
|
|
const mobileGeometry = await mobilePicker.evaluate((element) => ({
|
|
width: element.getBoundingClientRect().width,
|
|
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
|
categoryColumns: getComputedStyle(element.querySelector<HTMLElement>('.category-list')!).gridTemplateColumns,
|
|
}));
|
|
expect(mobileGeometry.width).toBeLessThanOrEqual(500);
|
|
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
|
expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3);
|
|
await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') });
|
|
});
|
|
|
|
test('keeps general and chief command categories after input and across page reloads', async ({ page, context }) => {
|
|
await install(page);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/');
|
|
|
|
const generalEditor = page.locator('[data-command-scope="general"]');
|
|
await generalEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
let picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
|
await picker.getByRole('button', { name: '출병', exact: true }).click();
|
|
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
|
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
|
await expect(generalEditor.locator('.action-column > div').first()).toHaveText('【단양】으로 출병');
|
|
|
|
await generalEditor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await expect(picker.getByRole('button', { name: '군사', exact: true })).toHaveClass(/active/);
|
|
await expect(picker.getByRole('button', { name: '출병', exact: true })).toBeVisible();
|
|
await picker.screenshot({ path: test.info().outputPath('general-category-after-input-desktop-1200.png') });
|
|
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
|
|
await expect
|
|
.poll(() => page.evaluate(() => localStorage.getItem('core2026:general:1:category')))
|
|
.toBe(JSON.stringify('general:군사'));
|
|
|
|
await page.reload();
|
|
const reloadedGeneralEditor = page.locator('[data-command-scope="general"]');
|
|
await reloadedGeneralEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const reloadedGeneralPicker = page.getByTestId('command-picker');
|
|
await expect(reloadedGeneralPicker.getByRole('button', { name: '군사', exact: true })).toHaveClass(/active/);
|
|
await expect(reloadedGeneralPicker.getByRole('button', { name: '출병', exact: true })).toBeVisible();
|
|
await reloadedGeneralPicker.screenshot({
|
|
path: test.info().outputPath('general-category-after-reload-desktop-1200.png'),
|
|
});
|
|
|
|
const chiefPage = await context.newPage();
|
|
await install(chiefPage, false, refChiefCommandTable);
|
|
await chiefPage.setViewportSize({ width: 500, height: 900 });
|
|
await chiefPage.goto('/che/chief-center');
|
|
const chiefEditor = chiefPage.locator('[data-command-scope="nation"]');
|
|
await chiefEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
picker = chiefPage.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '전략', exact: true }).click();
|
|
await picker.getByRole('button', { name: '필사즉생', exact: true }).click();
|
|
await expect(chiefEditor.locator('.action-column > div').first()).toHaveText('필사즉생');
|
|
|
|
await chiefEditor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
|
picker = chiefPage.getByTestId('command-picker');
|
|
await expect(picker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
|
|
await expect(picker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
|
|
await picker.screenshot({ path: test.info().outputPath('chief-category-after-input-mobile-500.png') });
|
|
await picker.getByRole('button', { name: '명령 입력 닫기', exact: true }).click();
|
|
await expect
|
|
.poll(() => chiefPage.evaluate(() => localStorage.getItem('core2026:nation:1:5:category')))
|
|
.toBe(JSON.stringify('nation:전략'));
|
|
await chiefPage.reload();
|
|
const reloadedChiefEditor = chiefPage.locator('[data-command-scope="nation"]');
|
|
await reloadedChiefEditor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const reloadedChiefPicker = chiefPage.getByTestId('command-picker');
|
|
await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
|
|
await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
|
|
await expect
|
|
.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height))
|
|
.toBeGreaterThan(200);
|
|
await reloadedChiefPicker.screenshot({
|
|
path: test.info().outputPath('chief-category-after-reload-mobile-500.png'),
|
|
});
|
|
});
|
|
|
|
test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => {
|
|
await install(page);
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await page.goto('/che/chief-center');
|
|
|
|
const editor = page.locator('[data-command-scope="nation"]:visible');
|
|
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
|
await expect(editor.locator('.index-column > button')).toHaveCount(12);
|
|
await expect(editor.locator('.index-column > button').last()).toHaveText('12');
|
|
await expect(editor.locator('.advanced-actions')).toContainText('선택한 턴을');
|
|
await expect(editor.locator('.advanced-actions')).toContainText('명령 선택');
|
|
|
|
const frame = page.locator('.chief-overview-frame');
|
|
await expect(frame.locator('.chief-overview-row')).toHaveCount(2);
|
|
await expect(frame.locator('.overview-turn-index')).toHaveCount(4);
|
|
for (const gutter of await frame.locator('.overview-turn-index').all()) {
|
|
await expect(gutter.locator('span').filter({ hasText: /\d+/u })).toHaveText(
|
|
Array.from({ length: 12 }, (_, index) => String(index + 1))
|
|
);
|
|
}
|
|
await expect(frame.locator('.compact-name')).toHaveCount(8);
|
|
|
|
const geometry = await page.locator('.chief-page').evaluate((element) => {
|
|
const editorElement = element.querySelector<HTMLElement>('[data-command-scope="nation"]')!;
|
|
const queue = editorElement.querySelector<HTMLElement>('.queue-grid')!;
|
|
const lastTurn = editorElement.querySelectorAll<HTMLElement>('.action-column > div')[11]!;
|
|
const actions = editorElement.querySelector<HTMLElement>('.advanced-actions')!;
|
|
const overviewFrame = element.querySelector<HTMLElement>('.chief-overview-frame')!;
|
|
const firstOverviewRow = element.querySelector<HTMLElement>('.chief-overview-row')!;
|
|
const overviewRows = [...element.querySelectorAll<HTMLElement>('.chief-overview-row')];
|
|
const gutters = [...firstOverviewRow.querySelectorAll<HTMLElement>('.overview-turn-index')];
|
|
const cards = [...firstOverviewRow.querySelectorAll<HTMLElement>('.chief-card')];
|
|
const names = [...overviewFrame.querySelectorAll<HTMLElement>('.compact-name')];
|
|
const frameRect = overviewFrame.getBoundingClientRect();
|
|
const editorRect = editorElement.getBoundingClientRect();
|
|
const actionsRect = actions.getBoundingClientRect();
|
|
return {
|
|
editorBottom: editorRect.bottom,
|
|
editorHeight: editorRect.height,
|
|
queueBottom: queue.getBoundingClientRect().bottom,
|
|
lastTurnBottom: lastTurn.getBoundingClientRect().bottom,
|
|
actionsTop: actionsRect.top,
|
|
actionsBottom: actionsRect.bottom,
|
|
frameTop: frameRect.top,
|
|
frameWidth: frameRect.width,
|
|
rowWidth: firstOverviewRow.getBoundingClientRect().width,
|
|
rowEdges: overviewRows.map((item) => ({
|
|
top: item.getBoundingClientRect().top - frameRect.top,
|
|
bottom: item.getBoundingClientRect().bottom - frameRect.top,
|
|
})),
|
|
gutterWidths: gutters.map((item) => item.getBoundingClientRect().width),
|
|
gutterEdges: gutters.map((item) => ({
|
|
left: item.getBoundingClientRect().left - frameRect.left,
|
|
right: item.getBoundingClientRect().right - frameRect.left,
|
|
})),
|
|
cardWidths: cards.map((item) => item.getBoundingClientRect().width),
|
|
namesInsideFrame: names.every((item) => {
|
|
const rect = item.getBoundingClientRect();
|
|
return rect.top >= frameRect.top && rect.bottom <= frameRect.bottom && rect.height > 0;
|
|
}),
|
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
};
|
|
});
|
|
|
|
expect(geometry.lastTurnBottom).toBeLessThanOrEqual(geometry.actionsTop);
|
|
expect(geometry.queueBottom).toBeLessThanOrEqual(geometry.actionsTop);
|
|
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.editorBottom);
|
|
expect(geometry.frameTop).toBeGreaterThanOrEqual(geometry.editorBottom);
|
|
expect(geometry.editorHeight).toBeGreaterThanOrEqual(404);
|
|
expect(geometry.frameWidth).toBe(500);
|
|
expect(geometry.rowWidth).toBe(500);
|
|
expect(geometry.rowEdges).toEqual([
|
|
{ top: 0, bottom: 155 },
|
|
{ top: 155, bottom: 310 },
|
|
]);
|
|
expect(geometry.gutterWidths).toEqual([12, 12]);
|
|
expect(geometry.gutterEdges).toEqual([
|
|
{ left: 0, right: 12 },
|
|
{ left: 488, right: 500 },
|
|
]);
|
|
expect(geometry.cardWidths).toEqual([119, 119, 119, 119]);
|
|
expect(geometry.namesInsideFrame).toBe(true);
|
|
expect(geometry.documentOverflow).toBeLessThanOrEqual(0);
|
|
await page.screenshot({ path: test.info().outputPath('chief-advanced-mobile-500.png'), fullPage: true });
|
|
});
|
|
|
|
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
|
const requests = await install(page);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/');
|
|
await expect(page.getByTestId('current-city-marker')).toHaveCount(0);
|
|
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
|
const form = page.getByTestId('command-argument-form');
|
|
await expect(form).toBeVisible();
|
|
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
|
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
|
|
const commandMap = form.getByTestId('command-argument-map');
|
|
const currentCityMarker = commandMap.getByTestId('current-city-marker');
|
|
const selectionStatus = form.getByTestId('command-map-selection-status');
|
|
await expect(currentCityMarker).toHaveText('현재');
|
|
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
|
|
await expect(selectionStatus).toContainText('현재 도시업');
|
|
await expect(selectionStatus).toContainText('선택 도시업');
|
|
const mapCities = commandMap.locator('.city-base');
|
|
await expect(mapCities).toHaveCount(2);
|
|
await expect(commandMap.locator('.city-bg')).toHaveCount(2);
|
|
await expect(commandMap.locator('.city-flag')).toHaveCount(2);
|
|
await expect(commandMap.locator('.city-state')).toHaveCount(1);
|
|
await expect
|
|
.poll(() =>
|
|
commandMap
|
|
.locator('.city-icon')
|
|
.evaluateAll((images: HTMLImageElement[]) =>
|
|
images.every((image) => image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)
|
|
)
|
|
)
|
|
.toBe(true);
|
|
const castleGeometry = await commandMap.locator('.city-icon').evaluateAll((images: HTMLImageElement[]) =>
|
|
images.map((image) => ({
|
|
src: new URL(image.src).pathname,
|
|
naturalWidth: image.naturalWidth,
|
|
naturalHeight: image.naturalHeight,
|
|
renderedWidth: image.getBoundingClientRect().width,
|
|
renderedHeight: image.getBoundingClientRect().height,
|
|
}))
|
|
);
|
|
expect(castleGeometry).toEqual([
|
|
expect.objectContaining({ src: '/game/cast_8.gif', naturalWidth: 32, naturalHeight: 24 }),
|
|
expect.objectContaining({ src: '/game/cast_7.gif', naturalWidth: 28, naturalHeight: 20 }),
|
|
]);
|
|
for (const castle of castleGeometry) {
|
|
expect(castle.renderedWidth).toBeGreaterThan(castle.naturalWidth * 0.9);
|
|
expect(castle.renderedHeight).toBeGreaterThan(castle.naturalHeight * 0.9);
|
|
expect(castle.renderedWidth / castle.naturalWidth).toBeCloseTo(castle.renderedHeight / castle.naturalHeight, 2);
|
|
}
|
|
const layerStyles = await commandMap.evaluate((element) => ({
|
|
background: getComputedStyle(element.querySelector<HTMLElement>('.map-bglayer1')!).backgroundImage,
|
|
road: getComputedStyle(element.querySelector<HTMLElement>('.map-bgroad')!).backgroundImage,
|
|
}));
|
|
expect(layerStyles.background).toContain('/game/map/che/bg_spring.jpg');
|
|
expect(layerStyles.road).toContain('/game/map/che/che_road.png');
|
|
await mapCities.nth(1).click();
|
|
await expect(form.locator('select')).toHaveValue('2');
|
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
|
|
await expect(selectionStatus).toContainText('현재 도시업');
|
|
await expect(selectionStatus).toContainText('선택 도시허창');
|
|
await expect(currentCityMarker).toHaveAttribute('aria-label', '현재 도시 업');
|
|
await expect(mapCities.nth(0)).toHaveClass(/mine/);
|
|
await expect(mapCities.nth(1)).toHaveClass(/selected/);
|
|
await mapCities.nth(1).hover();
|
|
expect(await mapCities.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
|
await mapCities.nth(1).focus();
|
|
await expect(mapCities.nth(1)).toBeFocused();
|
|
await expect(page).toHaveURL(/\/$/);
|
|
await commandMap.screenshot({ path: test.info().outputPath('main-city-current-marker-desktop.png') });
|
|
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
|
|
const area = element.querySelector<HTMLElement>('.map-area')!;
|
|
const rect = area.getBoundingClientRect();
|
|
return { width: rect.width, height: rect.height };
|
|
});
|
|
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
|
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
|
'【허창】에 화계실행'
|
|
);
|
|
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const chiefPicker = page.getByTestId('command-picker');
|
|
await chiefPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
|
await chiefPicker.getByRole('button', { name: /포상/ }).click();
|
|
const chiefForm = chiefPicker.getByTestId('command-argument-form');
|
|
await chiefForm.getByRole('button', { name: '쌀', exact: true }).click();
|
|
await chiefForm.locator('input[type=number]').fill('300');
|
|
const chiefTarget = chiefForm.locator('#command-arg-destGeneralId');
|
|
await expect(chiefTarget.locator('option')).toHaveText([
|
|
'장수 (아국 · 업)',
|
|
'여포NPC (아국 · 업)',
|
|
'관우 (아국 · 업)',
|
|
]);
|
|
await chiefTarget.selectOption('3');
|
|
const geometry = await chiefForm.evaluate((element) => {
|
|
const row = element.querySelector('.argument-row');
|
|
const rect = element.getBoundingClientRect();
|
|
const style = getComputedStyle(element);
|
|
return {
|
|
width: rect.width,
|
|
rowHeight: row?.getBoundingClientRect().height ?? 0,
|
|
borderStyle: style.borderStyle,
|
|
fontSize: style.fontSize,
|
|
};
|
|
});
|
|
await chiefPicker.getByRole('button', { name: '입력', exact: true }).click();
|
|
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
|
|
'【여포NPC】 쌀 300 포상'
|
|
);
|
|
|
|
expect(JSON.stringify(requests)).toContain('"destCityId":2');
|
|
expect(JSON.stringify(requests)).toContain('"isGold":false');
|
|
expect(JSON.stringify(requests)).toContain('"amount":300');
|
|
expect(JSON.stringify(requests)).toContain('"destGeneralId":3');
|
|
|
|
expect(mapGeometry.width).toBeGreaterThan(650);
|
|
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
|
|
|
|
expect(geometry.width).toBeGreaterThan(200);
|
|
expect(geometry.rowHeight).toBeGreaterThanOrEqual(34);
|
|
expect(geometry.borderStyle).toBe('solid');
|
|
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
|
|
});
|
|
|
|
test('uses a Ref-style full recruitment page without horizontal overflow on desktop or mobile', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const requests = await install(page);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/');
|
|
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
let picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
|
await picker.getByRole('button', { name: '징병', exact: true }).click();
|
|
await expect(picker).toHaveAttribute('role', 'dialog');
|
|
await expect(picker).toHaveAttribute('aria-modal', 'true');
|
|
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused();
|
|
const form = picker.getByTestId('recruitment-command-form');
|
|
await expect(form).toContainText('현재 기술력 : 1등급');
|
|
await expect(form).toContainText('공격');
|
|
await expect(form).toContainText('방어');
|
|
await expect(form).toContainText('기동');
|
|
await expect(form).toContainText('회피');
|
|
await expect(form).toContainText('가격');
|
|
await expect(form).toContainText('군량');
|
|
await expect(form).toContainText('표준적인 보병입니다.');
|
|
await expect(form.getByRole('button', { name: '정예병 선택 불가', exact: true })).toHaveCount(0);
|
|
await form.getByRole('button', { name: '선택 할 수 없는 병종도 보기', exact: true }).click();
|
|
const unavailable = form.getByRole('button', { name: '정예병 선택 불가', exact: true });
|
|
await expect(unavailable).toBeVisible();
|
|
await expect(unavailable.locator('.crew-name')).toHaveCSS('background-color', 'rgb(201, 0, 0)');
|
|
|
|
const desktopGeometry = await form.evaluate(async (element) => {
|
|
const row = element.querySelector('.crew-row');
|
|
const image = row?.querySelector('.crew-image');
|
|
const info = row?.querySelector('.crew-info');
|
|
const backgroundImage = image ? getComputedStyle(image).backgroundImage : '';
|
|
const imageUrl = backgroundImage.match(/^url\(["']?(.*?)["']?\)$/)?.[1];
|
|
const naturalSize = imageUrl
|
|
? await new Promise<{ width: number; height: number } | null>((resolve) => {
|
|
const probe = new Image();
|
|
probe.onload = () => resolve({ width: probe.naturalWidth, height: probe.naturalHeight });
|
|
probe.onerror = () => resolve(null);
|
|
probe.src = imageUrl;
|
|
})
|
|
: null;
|
|
return {
|
|
overlay: element.closest('[data-testid="command-picker"]')?.getBoundingClientRect().toJSON(),
|
|
formWidth: element.getBoundingClientRect().width,
|
|
rowHeight: row?.getBoundingClientRect().height ?? 0,
|
|
infoWidth: info?.getBoundingClientRect().width ?? 0,
|
|
imageNaturalSize: naturalSize,
|
|
scrollWidth: element.scrollWidth,
|
|
clientWidth: element.clientWidth,
|
|
bodyOverflow: getComputedStyle(document.body).overflow,
|
|
};
|
|
});
|
|
expect(desktopGeometry.overlay).toMatchObject({ x: 0, y: 0, width: 1200, height: 900 });
|
|
expect(desktopGeometry.formWidth).toBe(1000);
|
|
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(64);
|
|
expect(desktopGeometry.infoWidth).toBeCloseTo(250, 0);
|
|
expect(desktopGeometry.imageNaturalSize).toEqual({ width: 128, height: 128 });
|
|
expect(desktopGeometry.scrollWidth).toBe(desktopGeometry.clientWidth);
|
|
expect(desktopGeometry.bodyOverflow).toBe('hidden');
|
|
|
|
const infantry = form.getByRole('button', { name: '보병 선택 가능', exact: true });
|
|
await infantry.getByRole('button', { name: '절반', exact: true }).click();
|
|
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
|
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
|
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
|
'【보병】 3500명 징병'
|
|
);
|
|
expect(JSON.stringify(requests)).toContain('"crewType":1100');
|
|
expect(JSON.stringify(requests)).toContain('"amount":3500');
|
|
|
|
await page.setViewportSize({ width: 500, height: 844 });
|
|
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
|
await picker.getByRole('button', { name: '징병', exact: true }).click();
|
|
await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused();
|
|
const referenceWidthGeometry = await picker.evaluate((element) => {
|
|
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
|
|
const row = formElement.querySelector<HTMLElement>('.crew-row')!;
|
|
const selectedPanel = formElement.querySelector<HTMLElement>('.mobile-selected-panel')!;
|
|
return {
|
|
overlayWidth: element.getBoundingClientRect().width,
|
|
rowWidth: row.getBoundingClientRect().width,
|
|
rowGridColumns: getComputedStyle(row).gridTemplateColumns,
|
|
selectedWidth: selectedPanel.getBoundingClientRect().width,
|
|
selectedGridColumns: getComputedStyle(selectedPanel).gridTemplateColumns,
|
|
scrollWidth: element.scrollWidth,
|
|
clientWidth: element.clientWidth,
|
|
};
|
|
});
|
|
expect(referenceWidthGeometry).toMatchObject({
|
|
overlayWidth: 500,
|
|
rowWidth: 500,
|
|
rowGridColumns: '64px 76px 30px 30px 30px 270px',
|
|
selectedWidth: 500,
|
|
selectedGridColumns: '64px 76px 270px 90px',
|
|
scrollWidth: 500,
|
|
clientWidth: 500,
|
|
});
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
const mobileGeometry = await picker.evaluate((element) => {
|
|
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
|
|
const row = formElement.querySelector('.crew-row');
|
|
const image = row?.querySelector('.crew-image');
|
|
const info = row?.querySelector('.crew-info');
|
|
const selectedPanel = formElement.querySelector('.mobile-selected-panel');
|
|
const rect = element.getBoundingClientRect();
|
|
const bottomElement = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 1);
|
|
return {
|
|
overlay: rect.toJSON(),
|
|
overlayCoversBottom: bottomElement instanceof Element && element.contains(bottomElement),
|
|
formLeft: formElement.getBoundingClientRect().left,
|
|
formWidth: formElement.getBoundingClientRect().width,
|
|
rowWidth: row?.getBoundingClientRect().width ?? 0,
|
|
rowHeight: row?.getBoundingClientRect().height ?? 0,
|
|
rowGridColumns: row ? getComputedStyle(row).gridTemplateColumns : '',
|
|
rowGridRows: row ? getComputedStyle(row).gridTemplateRows : '',
|
|
imageWidth: image?.getBoundingClientRect().width ?? 0,
|
|
infoWidth: info?.getBoundingClientRect().width ?? 0,
|
|
selectedWidth: selectedPanel?.getBoundingClientRect().width ?? 0,
|
|
selectedDisplay: selectedPanel ? getComputedStyle(selectedPanel).display : '',
|
|
actionsBottom: element.querySelector<HTMLElement>('.picker-actions')?.getBoundingClientRect().bottom ?? 0,
|
|
viewportHeight: window.innerHeight,
|
|
scrollWidth: element.scrollWidth,
|
|
clientWidth: element.clientWidth,
|
|
bodyOverflow: getComputedStyle(document.body).overflow,
|
|
};
|
|
});
|
|
expect(mobileGeometry.overlay).toMatchObject({ x: 0, y: 0, width: 390, height: 844 });
|
|
expect(mobileGeometry.overlayCoversBottom).toBe(true);
|
|
expect(mobileGeometry.rowGridColumns).toBe('64px 76px 30px 30px 30px 160px');
|
|
expect(mobileGeometry).toMatchObject({
|
|
formLeft: 0,
|
|
formWidth: 390,
|
|
rowWidth: 390,
|
|
imageWidth: 64,
|
|
selectedWidth: 390,
|
|
selectedDisplay: 'grid',
|
|
actionsBottom: 844,
|
|
viewportHeight: 844,
|
|
bodyOverflow: 'hidden',
|
|
});
|
|
expect(mobileGeometry.rowHeight).toBeGreaterThanOrEqual(64);
|
|
expect(mobileGeometry.infoWidth).toBeGreaterThanOrEqual(150);
|
|
expect(mobileGeometry.scrollWidth).toBe(mobileGeometry.clientWidth);
|
|
|
|
await page.setViewportSize({ width: 390, height: 360 });
|
|
await picker.evaluate((element) => (element.scrollTop = 160));
|
|
const stickyGeometry = await picker.evaluate((element) => {
|
|
const header = element.querySelector<HTMLElement>(':scope > header')!;
|
|
const listFront = element.querySelector<HTMLElement>('.recruitment-list-front')!;
|
|
const actions = element.querySelector<HTMLElement>('.picker-actions')!;
|
|
return {
|
|
scrollTop: element.scrollTop,
|
|
headerTop: header.getBoundingClientRect().top,
|
|
listFrontTop: listFront.getBoundingClientRect().top,
|
|
actionsBottom: actions.getBoundingClientRect().bottom,
|
|
viewportHeight: window.innerHeight,
|
|
};
|
|
});
|
|
expect(stickyGeometry.scrollTop).toBeGreaterThan(0);
|
|
expect(stickyGeometry.headerTop).toBe(0);
|
|
expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(44);
|
|
expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight);
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await picker.evaluate((element) => (element.scrollTop = 0));
|
|
await page.screenshot({ path: testInfo.outputPath('recruitment-mobile.png') });
|
|
|
|
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
|
|
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
|
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
|
await picker.getByRole('button', { name: '모병', exact: true }).click();
|
|
const mercenaryForm = picker.getByTestId('recruitment-command-form');
|
|
await expect(mercenaryForm).toContainText('모병은 가격 2배의 자금이 소요됩니다.');
|
|
await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금');
|
|
await page.keyboard.press('Escape');
|
|
await expect(picker).toHaveCount(0);
|
|
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
|
});
|
|
|
|
test('keeps arbitrary direct recruitment and mercenary amounts for all four arms after turn refresh', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const requests = await install(page, false, fourArmRecruitmentCommandTable);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/');
|
|
|
|
const entries = [
|
|
{ turn: 1, command: '징병', crewTypeId: 1100, name: '보병', inputAmount: 13, savedAmount: 1300 },
|
|
{ turn: 2, command: '징병', crewTypeId: 1200, name: '궁병', inputAmount: 27, savedAmount: 2700 },
|
|
{ turn: 3, command: '징병', crewTypeId: 1300, name: '기병', inputAmount: 41, savedAmount: 4100 },
|
|
{ turn: 4, command: '징병', crewTypeId: 1400, name: '귀병', inputAmount: 59, savedAmount: 5900 },
|
|
{ turn: 5, command: '모병', crewTypeId: 1100, name: '보병', inputAmount: 17, savedAmount: 1700 },
|
|
{ turn: 6, command: '모병', crewTypeId: 1200, name: '궁병', inputAmount: 31, savedAmount: 3100 },
|
|
{ turn: 7, command: '모병', crewTypeId: 1300, name: '기병', inputAmount: 43, savedAmount: 4300 },
|
|
{ turn: 8, command: '모병', crewTypeId: 1400, name: '귀병', inputAmount: 61, savedAmount: 6100 },
|
|
];
|
|
|
|
for (const entry of entries) {
|
|
await page.getByRole('button', { name: `${entry.turn}턴 명령 입력`, exact: true }).click();
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
|
await picker.getByRole('button', { name: entry.command, exact: true }).click();
|
|
|
|
const row = picker.getByRole('button', { name: `${entry.name} 선택 가능`, exact: true });
|
|
const amountInput = row.locator('input[type=number]');
|
|
await amountInput.fill(String(entry.inputAmount));
|
|
await expect(amountInput).toHaveValue(String(entry.inputAmount));
|
|
if (entry.turn === 1) {
|
|
const inputGeometry = await amountInput.evaluate((element) => {
|
|
if (!(element instanceof HTMLInputElement)) throw new Error('Expected recruitment amount input');
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
width: rect.width,
|
|
height: rect.height,
|
|
textAlign: getComputedStyle(element).textAlign,
|
|
value: element.value,
|
|
};
|
|
});
|
|
expect(inputGeometry).toMatchObject({ height: 28, textAlign: 'right', value: '13' });
|
|
expect(inputGeometry.width).toBeGreaterThan(0);
|
|
await picker.screenshot({ path: testInfo.outputPath('recruitment-direct-amount-desktop.png') });
|
|
}
|
|
await row.getByRole('button', { name: entry.command, exact: true }).click();
|
|
|
|
await expect(picker).toHaveCount(0);
|
|
await expect(
|
|
page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)
|
|
).toHaveText(`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`);
|
|
}
|
|
|
|
const refreshResponse = page.waitForResponse((apiResponse) =>
|
|
decodeURIComponent(apiResponse.url()).includes('turns.reserved.getGeneral')
|
|
);
|
|
await page.getByRole('button', { name: '갱 신', exact: true }).click();
|
|
await refreshResponse;
|
|
|
|
for (const entry of entries) {
|
|
await expect(
|
|
page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)
|
|
).toHaveText(`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`);
|
|
}
|
|
await page
|
|
.locator('[data-command-scope="general"]')
|
|
.screenshot({ path: testInfo.outputPath('recruitment-arbitrary-amounts-after-refresh.png') });
|
|
|
|
const serializedRequests = JSON.stringify(requests);
|
|
for (const entry of entries) {
|
|
expect(serializedRequests).toContain(`"crewType":${entry.crewTypeId}`);
|
|
expect(serializedRequests).toContain(`"amount":${entry.savedAmount}`);
|
|
}
|
|
});
|
|
|
|
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
|
|
await install(page);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
|
await picker.getByRole('button', { name: /선전포고/ }).click();
|
|
const form = picker.getByTestId('command-argument-form');
|
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
|
|
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
|
|
await expect(form.locator('select')).toHaveValue('2');
|
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
|
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
|
|
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
|
|
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
|
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
|
});
|
|
|
|
test('shows city or capital maps for every requested chief command', async ({ page }) => {
|
|
await install(page);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
const cases = [
|
|
{ category: '인사', action: '발령', mode: 'city' },
|
|
{ category: '특수', action: '초토화', mode: 'city' },
|
|
{ category: '특수', action: '천도', mode: 'city' },
|
|
{ category: '특수', action: '증축', mode: 'capital' },
|
|
{ category: '특수', action: '감축', mode: 'capital' },
|
|
{ category: '전략', action: '수몰', mode: 'city' },
|
|
{ category: '전략', action: '허보', mode: 'city' },
|
|
{ category: '전략', action: '백성동원', mode: 'city' },
|
|
];
|
|
|
|
for (const entry of cases) {
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${entry.category}$`) }).click();
|
|
await picker.getByRole('button', { name: new RegExp(entry.action) }).click();
|
|
const form = picker.getByTestId('command-argument-form');
|
|
const map = form.getByTestId('command-argument-map');
|
|
await expect(map, `${entry.action} 지도`).toBeVisible();
|
|
await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
|
|
|
|
if (entry.mode === 'capital') {
|
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업');
|
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 수도');
|
|
await expect(map.locator('.city-base').first()).toHaveJSProperty('tagName', 'DIV');
|
|
expect(
|
|
await map
|
|
.locator('.city-base')
|
|
.first()
|
|
.evaluate((node) => getComputedStyle(node).cursor)
|
|
).toBe('default');
|
|
} else {
|
|
await map.locator('.city-base').nth(1).click();
|
|
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
|
}
|
|
}
|
|
|
|
await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true });
|
|
});
|
|
|
|
test('prioritizes own cities for assignment while retaining other map targets', async ({ page }) => {
|
|
const assignmentTable = structuredClone(commandTable);
|
|
assignmentTable.inputOptions.cities = [
|
|
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
|
|
{ value: 3, label: '단양 (무주)' },
|
|
{ value: 1, label: '업 (아국)' },
|
|
];
|
|
|
|
await install(page, false, assignmentTable);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
|
await picker.getByRole('button', { name: /발령/ }).click();
|
|
|
|
const form = picker.getByTestId('command-argument-form');
|
|
const citySelect = form.locator('#command-arg-destCityId');
|
|
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
|
await expect(citySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
|
|
await expect(citySelect).toHaveValue('1');
|
|
|
|
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
|
|
await expect(citySelect).toHaveValue('2');
|
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
|
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
|
await form.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority.png') });
|
|
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const mobilePicker = page.getByTestId('command-picker');
|
|
await mobilePicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
|
await mobilePicker.getByRole('button', { name: /발령/ }).click();
|
|
|
|
const mobileForm = mobilePicker.getByTestId('command-argument-form');
|
|
const mobileMap = mobileForm.getByTestId('command-argument-map');
|
|
const mobileCitySelect = mobileForm.locator('#command-arg-destCityId');
|
|
await expect(mobileMap).toBeVisible();
|
|
await expect(mobileCitySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
|
|
const mobileGeometry = await mobilePicker.evaluate((element) => ({
|
|
width: element.getBoundingClientRect().width,
|
|
overflow: element.scrollWidth - element.clientWidth,
|
|
}));
|
|
expect(mobileGeometry).toEqual({ width: 500, overflow: 0 });
|
|
const mapGeometry = await mobileMap.locator('.map-area').evaluate((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return { width: rect.width, height: rect.height };
|
|
});
|
|
expect(mapGeometry.width / mapGeometry.height).toBeCloseTo(7 / 5, 2);
|
|
await mobileMap.screenshot({ path: test.info().outputPath('chief-assignment-map-mobile.png') });
|
|
await mobileCitySelect.scrollIntoViewIfNeeded();
|
|
await mobilePicker.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority-mobile.png') });
|
|
});
|
|
|
|
test('prioritizes current nation targets while preserving every choice', async ({ page }) => {
|
|
await install(page);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
const cases = [
|
|
{ action: '원조', first: '적국' },
|
|
{ action: '불가침 제의', first: '적국' },
|
|
{ action: '선전포고', first: '적국' },
|
|
{ action: '종전 제의', first: '적국' },
|
|
{ action: '불가침 파기 제의', first: '불가침국' },
|
|
];
|
|
|
|
for (const entry of cases) {
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
|
await picker.getByRole('button', { name: new RegExp(entry.action) }).click();
|
|
const form = picker.getByTestId('command-argument-form');
|
|
const targetList = form.getByTestId('nation-target-list');
|
|
const targets = targetList.locator('.target-option');
|
|
await expect(targets).toHaveCount(3);
|
|
await expect(targets.first().locator('strong')).toHaveText(entry.first);
|
|
await expect(targets.first().locator('.target-state')).toHaveText('우선 대상');
|
|
await expect(targets.last().locator('.target-state')).toHaveText('현재 불가');
|
|
await expect(form.locator('#command-arg-destNationId')).toHaveValue(entry.first === '불가침국' ? '3' : '2');
|
|
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
|
}
|
|
|
|
const form = page.getByTestId('command-picker').getByTestId('command-argument-form');
|
|
const targets = form.getByTestId('nation-target-list').locator('.target-option');
|
|
await targets.filter({ hasText: '적국' }).click();
|
|
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
|
await expect(targets.filter({ hasText: '적국' })).toHaveClass(/selected/);
|
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창');
|
|
await page.screenshot({ path: test.info().outputPath('chief-nation-target-priority.png'), fullPage: true });
|
|
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const mobilePicker = page.getByTestId('command-picker');
|
|
await mobilePicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
|
await mobilePicker.getByRole('button', { name: /불가침 파기 제의/ }).click();
|
|
const mobileTargets = mobilePicker.getByTestId('nation-target-list').locator('.target-option');
|
|
await mobileTargets.nth(1).hover();
|
|
expect(await mobileTargets.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
|
await mobileTargets.nth(1).focus();
|
|
await expect(mobileTargets.nth(1)).toBeFocused();
|
|
const mobileGeometry = await mobilePicker.evaluate((element) => {
|
|
const list = element.querySelector<HTMLElement>('[data-testid="nation-target-list"]')!;
|
|
const cards = Array.from(list.querySelectorAll<HTMLElement>('.target-option'));
|
|
const listRect = list.getBoundingClientRect();
|
|
return {
|
|
pickerWidth: element.getBoundingClientRect().width,
|
|
pickerOverflow: element.scrollWidth - element.clientWidth,
|
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
cardsInsideList: cards.every((card) => {
|
|
const rect = card.getBoundingClientRect();
|
|
return rect.left >= listRect.left && rect.right <= listRect.right;
|
|
}),
|
|
};
|
|
});
|
|
expect(mobileGeometry).toEqual({
|
|
pickerWidth: 500,
|
|
pickerOverflow: 0,
|
|
documentOverflow: 0,
|
|
cardsInsideList: true,
|
|
});
|
|
await page.screenshot({ path: test.info().outputPath('chief-nation-target-priority-mobile.png'), fullPage: true });
|
|
});
|
|
|
|
test('offers Ref amount presets and rich, command-specific general lists', async ({ page }) => {
|
|
const requests = await install(page);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
let picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
|
await picker.getByRole('button', { name: /포상/ }).click();
|
|
let form = picker.getByTestId('command-argument-form');
|
|
const amount = form.locator('#command-arg-amount');
|
|
const amountPreset = form.getByRole('combobox', { name: '금액 프리셋' });
|
|
await expect(amount).toHaveValue('1000');
|
|
await expect(amountPreset.locator('option')).toHaveText(['프리셋', '100', '500', '1,000', '5,000', '10,000']);
|
|
await amountPreset.selectOption('5000');
|
|
await expect(amount).toHaveValue('5000');
|
|
await amount.fill('1375');
|
|
await expect(amount).toHaveValue('1375');
|
|
|
|
let generalList = form.getByTestId('general-target-list');
|
|
await expect(generalList.locator('.target-option strong')).toHaveText([
|
|
'관우 (아국 · 업)',
|
|
'여포NPC (아국 · 업)',
|
|
'장수 (아국 · 업)',
|
|
]);
|
|
await expect(generalList).toContainText('금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)');
|
|
await form.getByRole('button', { name: '쌀', exact: true }).click();
|
|
await expect(generalList.locator('.target-option strong')).toHaveText([
|
|
'장수 (아국 · 업)',
|
|
'여포NPC (아국 · 업)',
|
|
'관우 (아국 · 업)',
|
|
]);
|
|
await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click();
|
|
const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
|
await awardResponse;
|
|
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
|
await picker.getByRole('button', { name: /몰수/ }).click();
|
|
form = picker.getByTestId('command-argument-form');
|
|
generalList = form.getByTestId('general-target-list');
|
|
await expect(generalList.locator('.target-option strong')).toHaveText([
|
|
'장수 (아국 · 업)',
|
|
'여포NPC (아국 · 업)',
|
|
'관우 (아국 · 업)',
|
|
]);
|
|
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
|
await picker.getByRole('button', { name: /발령/ }).click();
|
|
form = picker.getByTestId('command-argument-form');
|
|
await expect(form.getByTestId('general-target-list')).toContainText('병력 1,200 · 탑승 부대 청룡대 (부대장)');
|
|
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
|
await picker.getByRole('button', { name: /부대 탈퇴 지시/ }).click();
|
|
form = picker.getByTestId('command-argument-form');
|
|
generalList = form.getByTestId('general-target-list');
|
|
await expect(generalList.locator('.target-option strong').first()).toHaveText('여포NPC (아국 · 업)');
|
|
await expect(generalList.locator('.target-option').first().locator('.target-state')).toHaveText('우선 대상');
|
|
await expect(generalList.locator('.target-option').nth(1).locator('.target-state')).toHaveText('현재 불가');
|
|
|
|
await page.goto('/che/chief-center');
|
|
await page.getByRole('button', { name: '5턴 명령 입력', exact: true }).click();
|
|
picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
|
await picker.getByRole('button', { name: /원조/ }).click();
|
|
form = picker.getByTestId('command-argument-form');
|
|
const tupleInputs = form.locator('.tuple-options input[type=number]');
|
|
await expect(tupleInputs.nth(0)).toHaveValue('1000');
|
|
await expect(tupleInputs.nth(1)).toHaveValue('1000');
|
|
await form.getByRole('combobox', { name: '금 금액 프리셋' }).selectOption('20000');
|
|
await tupleInputs.nth(1).fill('1370');
|
|
await expect(tupleInputs.nth(0)).toHaveValue('20000');
|
|
await expect(tupleInputs.nth(1)).toHaveValue('1370');
|
|
const aidResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
|
await aidResponse;
|
|
|
|
const serialized = JSON.stringify(requests);
|
|
expect(serialized).toContain('"amount":1375');
|
|
expect(serialized).toContain('"destGeneralId":3');
|
|
expect(serialized).toContain('"amountList":[20000,1370]');
|
|
await page.screenshot({ path: test.info().outputPath('chief-ref-guidance-controls.png'), fullPage: true });
|
|
});
|
|
|
|
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
|
|
await install(page);
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
await page.goto('/');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: /화계/ }).click();
|
|
const mobileForm = picker.getByTestId('command-argument-form');
|
|
const mobileMap = mobileForm.getByTestId('command-argument-map');
|
|
const mobileCities = mobileMap.locator('.city-base');
|
|
await mobileCities.nth(1).click();
|
|
await expect(mobileForm.locator('select')).toHaveValue('2');
|
|
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('현재 도시업');
|
|
await expect(mobileForm.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
|
await expect(mobileMap.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
|
await expect(mobileCities.nth(0)).toHaveClass(/mine/);
|
|
await expect(mobileCities.nth(1)).toHaveClass(/selected/);
|
|
const geometry = await picker.evaluate((element) => {
|
|
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
|
|
const pickerRect = element.getBoundingClientRect();
|
|
const mapRect = map.getBoundingClientRect();
|
|
return {
|
|
pickerX: pickerRect.x,
|
|
pickerRight: pickerRect.right,
|
|
pickerWidth: pickerRect.width,
|
|
pickerScrollWidth: element.scrollWidth,
|
|
mapWidth: mapRect.width,
|
|
mapHeight: mapRect.height,
|
|
marker: (() => {
|
|
const marker = element.querySelector<HTMLElement>('[data-testid="current-city-marker"]')!;
|
|
const rect = marker.getBoundingClientRect();
|
|
const style = getComputedStyle(marker);
|
|
return {
|
|
left: rect.left,
|
|
right: rect.right,
|
|
top: rect.top,
|
|
bottom: rect.bottom,
|
|
pointerEvents: style.pointerEvents,
|
|
borderColor: style.borderColor,
|
|
};
|
|
})(),
|
|
};
|
|
});
|
|
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
|
|
expect(geometry.pickerRight).toBeLessThanOrEqual(500);
|
|
expect(geometry.pickerWidth).toBeGreaterThanOrEqual(488);
|
|
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
|
|
expect(geometry.mapWidth).toBeGreaterThan(470);
|
|
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
|
|
expect(geometry.marker.left).toBeGreaterThanOrEqual(0);
|
|
expect(geometry.marker.right).toBeLessThanOrEqual(500);
|
|
expect(geometry.marker.top).toBeGreaterThanOrEqual(0);
|
|
expect(geometry.marker.bottom).toBeLessThanOrEqual(900);
|
|
expect(geometry.marker.pointerEvents).toBe('none');
|
|
expect(geometry.marker.borderColor).toBe('rgb(130, 207, 255)');
|
|
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
|
|
});
|
|
|
|
test('keeps the entered command visible and reports a server validation error', async ({ page }) => {
|
|
await install(page, true);
|
|
await page.goto('/');
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
|
await page.getByTestId('command-argument-form').locator('select').selectOption('2');
|
|
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
|
|
|
await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.');
|
|
await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
|
|
});
|
|
|
|
test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page, context }) => {
|
|
await install(page);
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.goto('/');
|
|
|
|
const editor = page.locator('[data-command-scope="general"]');
|
|
const firstRow = editor.locator('.action-column > div').first();
|
|
await expect(editor.locator('[data-command-autorun-status]')).toHaveCount(0);
|
|
await expect(firstRow).toContainText('휴식(자율 행동)');
|
|
await expect(firstRow).toHaveAttribute(
|
|
'data-autorun-tooltip',
|
|
/자율 행동: 200年 3月 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}까지/u
|
|
);
|
|
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
|
|
|
|
const tooltipStyle = () =>
|
|
firstRow.evaluate((element) => {
|
|
const style = getComputedStyle(element, '::after');
|
|
return { content: style.content, opacity: style.opacity, visibility: style.visibility };
|
|
});
|
|
expect((await tooltipStyle()).visibility).toBe('hidden');
|
|
const heightBeforeHover = await editor.evaluate((element) => element.getBoundingClientRect().height);
|
|
await firstRow.hover();
|
|
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('visible');
|
|
expect((await tooltipStyle()).content).toContain('자율 행동: 200年 3月');
|
|
expect(await editor.evaluate((element) => element.getBoundingClientRect().height)).toBe(heightBeforeHover);
|
|
await page.screenshot({
|
|
path: test.info().outputPath('command-brief-autorun-hover-desktop-1200.png'),
|
|
fullPage: true,
|
|
});
|
|
await page.mouse.move(0, 0);
|
|
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('hidden');
|
|
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '군사', exact: true }).click();
|
|
await picker.getByRole('button', { name: '출병', exact: true }).click();
|
|
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
|
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
|
|
|
await expect(firstRow).toHaveText('【단양】으로 출병');
|
|
await expect(firstRow).toHaveAttribute('data-autorun-tooltip', /자율 행동: 200年 3月 · .*까지/u);
|
|
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
|
|
await firstRow.focus();
|
|
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('visible');
|
|
await expect(firstRow).toBeFocused();
|
|
|
|
const desktopGeometry = await editor.evaluate((element) => {
|
|
const layout = element.querySelector<HTMLElement>('.editor-layout');
|
|
const controlPad = element.querySelector<HTMLElement>('.control-pad');
|
|
const row = element.querySelector<HTMLElement>('.action-column > div');
|
|
if (!layout || !controlPad || !row) throw new Error('autonomous command geometry is missing');
|
|
return {
|
|
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
|
controlPadOffset: controlPad.getBoundingClientRect().top - layout.getBoundingClientRect().top,
|
|
rowHeight: row.getBoundingClientRect().height,
|
|
};
|
|
});
|
|
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
|
expect(desktopGeometry.controlPadOffset).toBe(0);
|
|
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20);
|
|
await page.screenshot({
|
|
path: test.info().outputPath('command-brief-autorun-focus-desktop-1200.png'),
|
|
fullPage: true,
|
|
});
|
|
|
|
const mobilePage = await context.newPage();
|
|
await install(mobilePage);
|
|
await mobilePage.setViewportSize({ width: 500, height: 900 });
|
|
await mobilePage.goto('/');
|
|
const mobileEditor = mobilePage.locator('[data-command-scope="general"]');
|
|
const mobileFirstRow = mobileEditor.locator('.action-column > div').first();
|
|
await expect(mobileFirstRow).toContainText('휴식(자율 행동)');
|
|
await expect(mobileFirstRow).toHaveAttribute('data-autorun-tooltip', /자율 행동: 200年 3月 · .*까지/u);
|
|
await mobileFirstRow.focus();
|
|
await expect
|
|
.poll(() => mobileFirstRow.evaluate((element) => getComputedStyle(element, '::after').visibility))
|
|
.toBe('visible');
|
|
expect(await mobileEditor.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
|
|
await mobilePage.screenshot({
|
|
path: test.info().outputPath('command-brief-autorun-focus-mobile-500.png'),
|
|
fullPage: true,
|
|
});
|
|
await mobilePage.close();
|
|
});
|
|
|
|
test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => {
|
|
const requests = await install(page);
|
|
await page.goto('/');
|
|
|
|
const editor = page.locator('[data-command-scope="general"]');
|
|
await expect(editor).toBeVisible();
|
|
await editor.getByRole('button', { name: '고급 모드', exact: true }).click();
|
|
const drag = async (first: number, last: number, selector = '.index-column > button') => {
|
|
const cells = editor.locator(selector);
|
|
const from = await cells.nth(first).boundingBox();
|
|
const to = await cells.nth(last).boundingBox();
|
|
if (!from || !to) throw new Error('turn buttons are not measurable');
|
|
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 8 });
|
|
await page.mouse.up();
|
|
};
|
|
|
|
await drag(0, 2);
|
|
await expect(editor.locator('.index-column > button.selected')).toHaveCount(3);
|
|
await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click();
|
|
const picker = editor.getByTestId('command-picker');
|
|
const blockedFire = picker.getByRole('button', { name: '화계', exact: true });
|
|
await expect(blockedFire).toBeEnabled();
|
|
await blockedFire.click();
|
|
await picker.getByTestId('command-argument-form').locator('select').selectOption('2');
|
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
|
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
|
|
|
|
await drag(0, 2);
|
|
await editor.locator('details.selected-menu > summary').click();
|
|
await editor.getByRole('button', { name: '복사하기', exact: true }).click();
|
|
await expect.poll(() => page.evaluate(() => localStorage.getItem('core2026:general:1:clipboard'))).not.toBeNull();
|
|
await editor.locator('details.range-menu > summary').click();
|
|
await editor.getByRole('button', { name: '모든턴', exact: true }).click();
|
|
await expect(editor.locator('.index-column > button.selected')).toHaveCount(15);
|
|
await editor.locator('details.selected-menu > summary').click();
|
|
await editor.getByRole('button', { name: '붙여넣기', exact: true }).click();
|
|
await expect(editor.locator('.action-column > div').nth(5)).toHaveText('【허창】에 화계실행');
|
|
|
|
await drag(0, 2);
|
|
page.once('dialog', (dialog) => dialog.accept('화계 세트'));
|
|
await editor.locator('details.selected-menu > summary').click();
|
|
await editor.getByRole('button', { name: '보관하기', exact: true }).click();
|
|
await editor
|
|
.locator('details')
|
|
.filter({ has: page.getByText('보관함', { exact: true }) })
|
|
.locator('summary')
|
|
.click();
|
|
await expect(editor.getByRole('button', { name: '화계 세트', exact: true })).toBeVisible();
|
|
|
|
expect(JSON.stringify(requests)).toContain('"turnList":[0,1,2]');
|
|
expect(JSON.stringify(requests)).toContain('"turnList":[0,3,6,9,12');
|
|
await page.screenshot({ path: test.info().outputPath('advanced-command-editor.png'), fullPage: true });
|
|
});
|
|
|
|
test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => {
|
|
const requests = await install(page);
|
|
await page.setViewportSize({ width: 1000, height: 900 });
|
|
await page.goto('/');
|
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
|
|
|
const mainGeometry = await page.locator('.main-page').evaluate((element) => {
|
|
const header = element.querySelector<HTMLElement>('.game-shell__header')!;
|
|
const title = element.querySelector<HTMLElement>('.game-shell__title')!;
|
|
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle')!;
|
|
const action = element.querySelector<HTMLElement>('.game-shell__action')!;
|
|
return {
|
|
width: element.getBoundingClientRect().width,
|
|
padding: getComputedStyle(element).padding,
|
|
gap: getComputedStyle(element).gap,
|
|
headerWidth: header.getBoundingClientRect().width,
|
|
headerGap: getComputedStyle(header).gap,
|
|
headerBorder: getComputedStyle(header).borderBottomWidth,
|
|
headerPadding: getComputedStyle(header).paddingBottom,
|
|
titleFontSize: getComputedStyle(title).fontSize,
|
|
subtitleFontSize: getComputedStyle(subtitle).fontSize,
|
|
actionPadding: getComputedStyle(action).padding,
|
|
actionFontSize: getComputedStyle(action).fontSize,
|
|
};
|
|
});
|
|
expect(mainGeometry).toMatchObject({
|
|
width: 1000,
|
|
padding: '0px',
|
|
gap: '10px',
|
|
headerWidth: 1000,
|
|
headerGap: '12px',
|
|
headerBorder: '1px',
|
|
headerPadding: '12px',
|
|
actionPadding: '6px 12px',
|
|
});
|
|
expect(Number.parseFloat(mainGeometry.titleFontSize)).toBeGreaterThan(20);
|
|
expect(Number.parseFloat(mainGeometry.subtitleFontSize)).toBeGreaterThan(10);
|
|
expect(Number.parseFloat(mainGeometry.actionFontSize)).toBeGreaterThan(10);
|
|
|
|
const mainAction = page.getByRole('link', { name: '세력 정보' });
|
|
await mainAction.hover();
|
|
expect(await mainAction.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
|
await mainAction.focus();
|
|
expect(await mainAction.evaluate((element) => document.activeElement === element)).toBe(true);
|
|
|
|
await page.setViewportSize({ width: 1200, height: 900 });
|
|
await page.locator('.main-nation-menu').first().locator('[data-navigation-id="chief-center"]').click();
|
|
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
|
await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible();
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
await expect(page.getByTestId('command-picker')).toBeVisible();
|
|
await page
|
|
.getByTestId('command-picker')
|
|
.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true })
|
|
.click();
|
|
await page.getByTestId('command-picker').getByRole('button', { name: /포상/ }).click();
|
|
const chiefArgumentForm = page.getByTestId('command-picker').getByTestId('command-argument-form');
|
|
await chiefArgumentForm.getByRole('button', { name: '쌀' }).click();
|
|
await chiefArgumentForm.locator('input[type=number]').fill('300');
|
|
await chiefArgumentForm.locator('select').selectOption('2');
|
|
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
|
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
|
|
'【관우】 쌀 300 포상'
|
|
);
|
|
expect(JSON.stringify(requests)).toContain('"action":"che_포상"');
|
|
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
|
|
const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({
|
|
width: element.getBoundingClientRect().width,
|
|
padding: getComputedStyle(element).padding,
|
|
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
|
|
}));
|
|
expect(chiefDesktop).toEqual({ width: 1000, padding: '0px', headerWidth: 1000 });
|
|
|
|
await page.setViewportSize({ width: 500, height: 900 });
|
|
const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({
|
|
width: element.getBoundingClientRect().width,
|
|
padding: getComputedStyle(element).padding,
|
|
headerWidth: element.querySelector<HTMLElement>('.chief-top')!.getBoundingClientRect().width,
|
|
}));
|
|
expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 });
|
|
});
|